From a6b6df2336d58869a77e60ad1dac4733d1019e6c Mon Sep 17 00:00:00 2001 From: Andreas Schulz Date: Mon, 12 Dec 2022 09:45:21 +0530 Subject: [PATCH] Add JVM Support --- README.md | 10 +- buildSrc/build.gradle.kts | 6 +- buildSrc/src/main/kotlin/AndroidConfig.kt | 4 +- .../kotlin/multiplatform-setup.gradle.kts | 2 + libs.versions.toml | 19 +- sample/README.md | 6 + sample/app-android/build.gradle.kts | 2 +- .../com/shepeliev/webrtckmp/sample/App.kt | 16 +- .../shepeliev/webrtckmp/sample/VideoScreen.kt | 2 +- sample/app-jvm/build.gradle.kts | 38 ++ .../kotlin/com/shepeliev/webrtckmp/App.kt | 27 ++ .../com/shepeliev/webrtckmp/JoinRoomButton.kt | 59 +++ .../kotlin/com/shepeliev/webrtckmp/Main.kt | 76 ++++ .../webrtckmp/SelectMicrophoneCameraScreen.kt | 243 +++++++++++++ .../kotlin/com/shepeliev/webrtckmp/Video.kt | 125 +++++++ .../com/shepeliev/webrtckmp/VideoScreen.kt | 137 +++++++ sample/shared/build.gradle.kts | 2 + sample/shared/shared.podspec | 11 + .../webrtckmp/sample/shared/RoomDataSource.kt | 2 +- .../shepeliev/webrtckmp/sample/shared/Room.kt | 7 +- .../webrtckmp/sample/shared/RoomComponent.kt | 51 ++- .../webrtckmp/sample/shared/RoomDataSource.kt | 2 +- .../webrtckmp/sample/shared/RoomDataSource.kt | 2 +- .../webrtckmp/sample/shared/RoomDataSource.kt | 2 +- .../webrtckmp/sample/shared/RoomDataSource.kt | 130 +++++++ settings.gradle.kts | 3 + webrtc-kmp/build.gradle.kts | 26 +- .../webrtckmp/MediaStreamTrackImpl.kt | 2 +- .../shepeliev/webrtckmp/MediaDeviceInfo.kt | 2 +- .../com/shepeliev/webrtckmp/ByteBuffer.kt | 10 + .../com/shepeliev/webrtckmp/DataChannel.kt | 91 +++++ .../webrtckmp/DesktopVideoStreamTrack.kt | 28 ++ .../com/shepeliev/webrtckmp/DtmfSender.kt | 19 + .../com/shepeliev/webrtckmp/IceCandidate.kt | 15 + .../com/shepeliev/webrtckmp/IceServer.kt | 38 ++ .../webrtckmp/LocalAudioStreamTrack.kt | 15 + .../webrtckmp/LocalVideoStreamTrack.kt | 27 ++ .../com/shepeliev/webrtckmp/MediaDevices.kt | 214 +++++++++++ .../com/shepeliev/webrtckmp/MediaStream.kt | 50 +++ .../webrtckmp/MediaStreamTrackImpl.kt | 69 ++++ .../com/shepeliev/webrtckmp/PeerConnection.kt | 344 ++++++++++++++++++ .../shepeliev/webrtckmp/PeerConnectionExt.kt | 57 +++ .../webrtckmp/RemoteAudioStreamTrack.kt | 7 + .../webrtckmp/RemoteVideoStreamTrack.kt | 96 +++++ .../webrtckmp/RenderedVideoStreamTrack.kt | 19 + .../shepeliev/webrtckmp/RtcCertificatePem.kt | 126 +++++++ .../shepeliev/webrtckmp/RtcConfiguration.kt | 36 ++ .../com/shepeliev/webrtckmp/RtcStats.kt | 11 + .../com/shepeliev/webrtckmp/RtcStatsReport.kt | 9 + .../com/shepeliev/webrtckmp/RtpParameters.kt | 92 +++++ .../com/shepeliev/webrtckmp/RtpReceiver.kt | 11 + .../com/shepeliev/webrtckmp/RtpSender.kt | 34 ++ .../com/shepeliev/webrtckmp/RtpTransceiver.kt | 54 +++ .../webrtckmp/SessionDescriptionExt.kt | 30 ++ .../shepeliev/webrtckmp/VideoStreamTrack.kt | 10 + .../kotlin/com/shepeliev/webrtckmp/WebRtc.kt | 103 ++++++ .../com/shepeliev/webrtckmp/IceServerTests.kt | 73 ++++ .../shepeliev/webrtckmp/MediaDevicesTests.kt | 29 ++ .../webrtckmp/PeerConnectionsTests.kt | 44 +++ .../webrtckmp/RtcCertificatePemTests.kt | 22 ++ .../com/shepeliev/webrtckmp/TestUtils.kt | 17 + webrtc-kmp/webrtc_kmp.podspec | 11 + 62 files changed, 2779 insertions(+), 46 deletions(-) create mode 100644 sample/app-jvm/build.gradle.kts create mode 100644 sample/app-jvm/src/jvmMain/kotlin/com/shepeliev/webrtckmp/App.kt create mode 100644 sample/app-jvm/src/jvmMain/kotlin/com/shepeliev/webrtckmp/JoinRoomButton.kt create mode 100644 sample/app-jvm/src/jvmMain/kotlin/com/shepeliev/webrtckmp/Main.kt create mode 100644 sample/app-jvm/src/jvmMain/kotlin/com/shepeliev/webrtckmp/SelectMicrophoneCameraScreen.kt create mode 100644 sample/app-jvm/src/jvmMain/kotlin/com/shepeliev/webrtckmp/Video.kt create mode 100644 sample/app-jvm/src/jvmMain/kotlin/com/shepeliev/webrtckmp/VideoScreen.kt create mode 100644 sample/shared/src/jvmMain/kotlin/com/shepeliev/webrtckmp/sample/shared/RoomDataSource.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/ByteBuffer.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/DataChannel.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/DesktopVideoStreamTrack.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/DtmfSender.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/IceCandidate.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/IceServer.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/LocalAudioStreamTrack.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/LocalVideoStreamTrack.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/MediaDevices.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/MediaStream.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/MediaStreamTrackImpl.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/PeerConnection.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/PeerConnectionExt.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RemoteAudioStreamTrack.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RemoteVideoStreamTrack.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RenderedVideoStreamTrack.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcCertificatePem.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcConfiguration.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcStats.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcStatsReport.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpParameters.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpReceiver.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpSender.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpTransceiver.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/SessionDescriptionExt.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/VideoStreamTrack.kt create mode 100644 webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/WebRtc.kt create mode 100644 webrtc-kmp/src/jvmTest/kotlin/com/shepeliev/webrtckmp/IceServerTests.kt create mode 100644 webrtc-kmp/src/jvmTest/kotlin/com/shepeliev/webrtckmp/MediaDevicesTests.kt create mode 100644 webrtc-kmp/src/jvmTest/kotlin/com/shepeliev/webrtckmp/PeerConnectionsTests.kt create mode 100644 webrtc-kmp/src/jvmTest/kotlin/com/shepeliev/webrtckmp/RtcCertificatePemTests.kt create mode 100644 webrtc-kmp/src/jvmTest/kotlin/com/shepeliev/webrtckmp/TestUtils.kt diff --git a/README.md b/README.md index fbe53df6..2079c01d 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,11 @@ WebRTC Kotlin Multiplatform SDK ## API implementation map - API | Android | iOS | JS - :-: | :-----: | :-: | :---: - Audio/Video | :white_check_mark: | :white_check_mark: | :white_check_mark: - Data channel | :white_check_mark: | :white_check_mark: | :white_check_mark: - Screen Capture | | | :white_check_mark: +| API | Android | iOS | JS | JVM | +|:--------------:|:------------------:|:------------------:|:------------------:|:------------------:| +| Audio/Video | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Data channel | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| Screen Capture | | | :white_check_mark: | :white_check_mark: | ## WebRTC revision Current revision: M114 diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index a9f63e4c..862131fa 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -6,10 +6,12 @@ repositories { gradlePluginPortal() mavenCentral() google() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") } dependencies { - implementation("com.android.tools.build:gradle:8.0.2") - implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.22") + implementation("com.android.tools.build:gradle:8.2.1") + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.21") implementation("org.jlleitschuh.gradle:ktlint-gradle:10.3.0") + implementation("org.jetbrains.compose:compose-gradle-plugin:1.5.11") } diff --git a/buildSrc/src/main/kotlin/AndroidConfig.kt b/buildSrc/src/main/kotlin/AndroidConfig.kt index c5a3388c..f9fe0488 100644 --- a/buildSrc/src/main/kotlin/AndroidConfig.kt +++ b/buildSrc/src/main/kotlin/AndroidConfig.kt @@ -1,5 +1,5 @@ object AndroidConfig { const val minSdkVersion = 21 - const val compileSdkVersion = 33 - const val targetSdkVersion = 33 + const val compileSdkVersion = 34 + const val targetSdkVersion = 34 } diff --git a/buildSrc/src/main/kotlin/multiplatform-setup.gradle.kts b/buildSrc/src/main/kotlin/multiplatform-setup.gradle.kts index 813dc181..3cc0c217 100644 --- a/buildSrc/src/main/kotlin/multiplatform-setup.gradle.kts +++ b/buildSrc/src/main/kotlin/multiplatform-setup.gradle.kts @@ -12,6 +12,8 @@ kotlin { browser() } + jvm() + sourceSets { getByName("commonTest") { dependencies { diff --git a/libs.versions.toml b/libs.versions.toml index 805afb49..08c9862a 100644 --- a/libs.versions.toml +++ b/libs.versions.toml @@ -1,14 +1,16 @@ [versions] -kotlinCoroutines = "1.6.4" +kotlinCoroutines = "1.7.3" kotlinWrappers = "1.0.0-pre.343" -androidxCompose = "1.4.3" -decompose = "1.0.0-alpha-01" +androidxCompose = "1.6.0" +decompose = "2.0.1" [libraries] -webrtcSdk = "io.github.webrtc-sdk:android:114.5735.02" +webrtc-android = "io.github.webrtc-sdk:android:114.5735.02" +webrtc-java = "dev.onvoid.webrtc:webrtc-java:0.8.0" kotlin-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinCoroutines" } +kotlin-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinCoroutines" } kotlin-coroutinesPlayServices = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinCoroutines" } kotlin-wrappers-bom = { module = "org.jetbrains.kotlin-wrappers:kotlin-wrappers-bom", version.ref = "kotlinWrappers" } kotlin-wrappers-emotion = { module = "org.jetbrains.kotlin-wrappers:kotlin-emotion", version.ref = "kotlinWrappers" } @@ -16,11 +18,12 @@ kotlin-wrappers-react = { module = "org.jetbrains.kotlin-wrappers:kotlin-react", kotlin-wrappers-reactDom = { module = "org.jetbrains.kotlin-wrappers:kotlin-react-dom", version.ref = "kotlinWrappers" } kotlin-wrappers-mui = { module = "org.jetbrains.kotlin-wrappers:kotlin-mui", version.ref = "kotlinWrappers" } kotlin-serialization-json = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.3" +kotlin-datetime = "org.jetbrains.kotlinx:kotlinx-datetime:0.4.0" androidx-coreKtx = "androidx.core:core-ktx:1.10.1" androidx-appcompat = "androidx.appcompat:appcompat:1.6.1" -androidx-activity-activityKtx = "androidx.activity:activity-ktx:1.7.2" -androidx-compose-activity = "androidx.activity:activity-compose:1.7.2" +androidx-activity-activityKtx = "androidx.activity:activity-ktx:1.8.2" +androidx-compose-activity = "androidx.activity:activity-compose:1.8.2" androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "androidxCompose" } androidx-compose-animation = { module = "androidx.compose.animation:animation", version.ref = "androidxCompose" } androidx-lifecycle-runtime = "androidx.lifecycle:lifecycle-runtime-ktx:2.6.1" @@ -32,8 +35,12 @@ accompanist-permissions = "com.google.accompanist:accompanist-permissions:0.25.0 decompose = { module = "com.arkivanov.decompose:decompose", version.ref = "decompose" } decompose-compose = { module = "com.arkivanov.decompose:extensions-compose-jetpack", version.ref = "decompose" } +decompose-kmp = { module = "com.arkivanov.decompose:extensions-compose-jetbrains", version.ref = "decompose" } firebase-bom = "com.google.firebase:firebase-bom:32.2.0" firebase-firestore = { module = "com.google.firebase:firebase-firestore-ktx" } +firebase-firestore-jvm = "dev.gitlive:firebase-firestore:1.11.1" kermit = "co.touchlab:kermit:1.1.2" + +bouncyCastle = "org.bouncycastle:bcpkix-jdk18on:1.77" \ No newline at end of file diff --git a/sample/README.md b/sample/README.md index 4517766c..19c5bb8b 100644 --- a/sample/README.md +++ b/sample/README.md @@ -26,3 +26,9 @@ Open `sample/app-ios/app-ios.xcworkspace` in XCode build and run ```bash ./gradlew browserRun ``` + +### JVM Desktop + +```bash +./gradlew ":sample:app-jvm:jvmRun" -DmainClass="com.shepeliev.webrtckmp.MainKt" --quiet +``` \ No newline at end of file diff --git a/sample/app-android/build.gradle.kts b/sample/app-android/build.gradle.kts index 1b512f67..4a907df6 100644 --- a/sample/app-android/build.gradle.kts +++ b/sample/app-android/build.gradle.kts @@ -32,7 +32,7 @@ android { } composeOptions { - kotlinCompilerExtensionVersion = "1.4.8" + kotlinCompilerExtensionVersion = "1.5.8" } } diff --git a/sample/app-android/src/main/java/com/shepeliev/webrtckmp/sample/App.kt b/sample/app-android/src/main/java/com/shepeliev/webrtckmp/sample/App.kt index 0e6cd522..c306b4f9 100644 --- a/sample/app-android/src/main/java/com/shepeliev/webrtckmp/sample/App.kt +++ b/sample/app-android/src/main/java/com/shepeliev/webrtckmp/sample/App.kt @@ -1,11 +1,14 @@ package com.shepeliev.webrtckmp.sample import androidx.compose.animation.Crossfade +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier import com.arkivanov.decompose.extensions.compose.jetpack.subscribeAsState import com.shepeliev.webrtckmp.sample.shared.Room @@ -21,10 +24,15 @@ fun App(room: Room) { val roomModel by room.model.subscribeAsState() - Crossfade(targetState = roomModel) { model -> - when (model.localStream) { - null -> OpenMicrophoneAndCameraScreen(room) - else -> VideoScreen(room) + Box( + modifier = Modifier + .padding(it) + ) { + Crossfade(targetState = roomModel) { model -> + when (model.localStream) { + null -> OpenMicrophoneAndCameraScreen(room) + else -> VideoScreen(room) + } } } } diff --git a/sample/app-android/src/main/java/com/shepeliev/webrtckmp/sample/VideoScreen.kt b/sample/app-android/src/main/java/com/shepeliev/webrtckmp/sample/VideoScreen.kt index 4bf50b86..662d1944 100644 --- a/sample/app-android/src/main/java/com/shepeliev/webrtckmp/sample/VideoScreen.kt +++ b/sample/app-android/src/main/java/com/shepeliev/webrtckmp/sample/VideoScreen.kt @@ -62,7 +62,7 @@ fun VideoScreen(room: Room) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - Crossfade(targetState = roomModel) { + Crossfade(targetState = roomModel, label = "") { when { it.isJoining -> CircularProgressIndicator() diff --git a/sample/app-jvm/build.gradle.kts b/sample/app-jvm/build.gradle.kts new file mode 100644 index 00000000..577b7e69 --- /dev/null +++ b/sample/app-jvm/build.gradle.kts @@ -0,0 +1,38 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + kotlin("multiplatform") + id("org.jetbrains.compose") +} + +kotlin { + jvm { + compilations.all { + kotlinOptions.jvmTarget = "17" + } + withJava() + } + sourceSets { + val jvmMain by getting { + dependencies { + implementation(project(":sample:shared")) + implementation(compose.material) + implementation(compose.desktop.currentOs) + implementation(deps.decompose.kmp) + implementation(deps.kotlin.coroutines.swing) + } + } + val jvmTest by getting + } +} + +compose.desktop { + application { + mainClass = "com.shepeliev.webrtckmp.MainKt" + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "KMPTemplate" + packageVersion = "1.0.0" + } + } +} diff --git a/sample/app-jvm/src/jvmMain/kotlin/com/shepeliev/webrtckmp/App.kt b/sample/app-jvm/src/jvmMain/kotlin/com/shepeliev/webrtckmp/App.kt new file mode 100644 index 00000000..a1f8ac88 --- /dev/null +++ b/sample/app-jvm/src/jvmMain/kotlin/com/shepeliev/webrtckmp/App.kt @@ -0,0 +1,27 @@ +package com.shepeliev.webrtckmp + +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState +import com.shepeliev.webrtckmp.sample.shared.Room + +@Composable +fun App(room: Room) { + val roomModel by room.model.subscribeAsState() + + Crossfade(targetState = roomModel) { model -> + when (model.localStream) { + null -> SelectMicrophoneCameraScreen(room) + else -> VideoScreen(room) + } + } +} \ No newline at end of file diff --git a/sample/app-jvm/src/jvmMain/kotlin/com/shepeliev/webrtckmp/JoinRoomButton.kt b/sample/app-jvm/src/jvmMain/kotlin/com/shepeliev/webrtckmp/JoinRoomButton.kt new file mode 100644 index 00000000..883212d2 --- /dev/null +++ b/sample/app-jvm/src/jvmMain/kotlin/com/shepeliev/webrtckmp/JoinRoomButton.kt @@ -0,0 +1,59 @@ +package com.shepeliev.webrtckmp + +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun JoinRoomButton(onJoin: (String) -> Unit, enabled: Boolean) { + var isJoinDialogVisible by remember { mutableStateOf(false) } + + if (isJoinDialogVisible) { + var roomId by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = { isJoinDialogVisible = false }, + + confirmButton = { + TextButton( + onClick = { + onJoin(roomId) + isJoinDialogVisible = false + }, + enabled = roomId.isNotBlank() + ) { + Text("Join") + } + }, + + dismissButton = { + TextButton(onClick = { isJoinDialogVisible = false }) { + Text("Cancel") + } + }, + + title = { Text("Join into room") }, + + text = { + OutlinedTextField( + value = roomId, + onValueChange = { roomId = it }, + placeholder = { Text("Room ID") } + ) + } + ) + } + + Button(onClick = { isJoinDialogVisible = true }, enabled = enabled) { + Text("Join") +} +} diff --git a/sample/app-jvm/src/jvmMain/kotlin/com/shepeliev/webrtckmp/Main.kt b/sample/app-jvm/src/jvmMain/kotlin/com/shepeliev/webrtckmp/Main.kt new file mode 100644 index 00000000..b82b4db8 --- /dev/null +++ b/sample/app-jvm/src/jvmMain/kotlin/com/shepeliev/webrtckmp/Main.kt @@ -0,0 +1,76 @@ +package com.shepeliev.webrtckmp + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.LocalWindowExceptionHandlerFactory +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowExceptionHandler +import androidx.compose.ui.window.WindowExceptionHandlerFactory +import androidx.compose.ui.window.WindowState +import androidx.compose.ui.window.application +import androidx.compose.ui.window.singleWindowApplication +import com.arkivanov.decompose.DefaultComponentContext +import com.arkivanov.essenty.lifecycle.LifecycleRegistry +import com.shepeliev.webrtckmp.sample.shared.RoomComponent +import dev.onvoid.webrtc.logging.Logging +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import java.awt.event.WindowEvent +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.system.exitProcess + + +@OptIn(ExperimentalComposeUiApi::class) +fun main() { + var lastError: Throwable? by mutableStateOf(null) + + WebRtc.configureBuilder { + loggingSeverity = Logging.Severity.INFO + } + + application(exitProcessOnExit = false) { + System.setProperty("compose.interop.blending", "true") + + val lifecycle = LifecycleRegistry() + + val room = RoomComponent( + componentContext = DefaultComponentContext(lifecycle), + scope = CoroutineScope(EmptyCoroutineContext + CoroutineName("App")), + ) + + CompositionLocalProvider( + LocalWindowExceptionHandlerFactory provides WindowExceptionHandlerFactory { window -> + WindowExceptionHandler { + lastError = it + window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING)) + } + } + ) { + Window(onCloseRequest = ::exitApplication) { + App(room) + } + } + } + + if (lastError != null) { + lastError?.printStackTrace() + singleWindowApplication( + state = WindowState(width = 200.dp, height = Dp.Unspecified), + exitProcessOnExit = false + ) { + Text(lastError?.message ?: "Unknown error", Modifier.padding(8.dp)) + } + + exitProcess(1) + } else { + exitProcess(0) + } +} diff --git a/sample/app-jvm/src/jvmMain/kotlin/com/shepeliev/webrtckmp/SelectMicrophoneCameraScreen.kt b/sample/app-jvm/src/jvmMain/kotlin/com/shepeliev/webrtckmp/SelectMicrophoneCameraScreen.kt new file mode 100644 index 00000000..e89f3ff6 --- /dev/null +++ b/sample/app-jvm/src/jvmMain/kotlin/com/shepeliev/webrtckmp/SelectMicrophoneCameraScreen.kt @@ -0,0 +1,243 @@ +package com.shepeliev.webrtckmp + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.shepeliev.webrtckmp.sample.shared.Room +import kotlinx.coroutines.launch + +@Composable +fun SelectMicrophoneCameraScreen(room: Room) { + val scope = rememberCoroutineScope() + + var camera: MediaDeviceInfo? by remember { mutableStateOf(null) } + var microphone: MediaDeviceInfo? by remember { mutableStateOf(null) } + var speaker: MediaDeviceInfo? by remember { mutableStateOf(WebRtc.getDefaultAudioOutput()) } + + var cameraList: Set by remember { mutableStateOf(emptySet()) } + var microphoneList: Set by remember { mutableStateOf(emptySet()) } + var speakerList: Set by remember { mutableStateOf(emptySet()) } + + suspend fun refreshDevices() { + val devices = MediaDevices.enumerateDevices() + cameraList = devices.filter { it.kind == MediaDeviceKind.VideoInput }.toSet() + microphoneList = devices.filter { it.kind == MediaDeviceKind.AudioInput }.toSet() + speakerList = devices.filter { it.kind == MediaDeviceKind.AudioOutput }.toSet() + + if(camera == null || !cameraList.any { it.deviceId == camera?.deviceId }) { + camera = cameraList.firstOrNull() + } + + if(microphone == null || !microphoneList.any { it.deviceId == microphone?.deviceId }) { + microphone = microphoneList.firstOrNull() + } + + if(speaker == null || !speakerList.any { it.deviceId == speaker?.deviceId }) { + speaker = WebRtc.getDefaultAudioOutput() + } + } + + val deviceObserver = object: MediaDeviceListener { + override fun deviceConnected(device: MediaDeviceInfo) { + when(device.kind) { + MediaDeviceKind.VideoInput -> cameraList = cameraList + device + MediaDeviceKind.AudioInput -> microphoneList = microphoneList + device + MediaDeviceKind.AudioOutput -> speakerList = speakerList + device + } + } + + override fun deviceDisconnected(device: MediaDeviceInfo) { + when(device.kind) { + MediaDeviceKind.VideoInput -> { + cameraList = cameraList - device + if(camera == device) { + camera = null + } + } + MediaDeviceKind.AudioInput -> { + microphoneList = microphoneList - device + if(microphone == device) { + microphone = null + } + } + MediaDeviceKind.AudioOutput -> { + speakerList = speakerList - device + if(speaker == device) { + speaker = WebRtc.getDefaultAudioOutput() + } + } + } + } + } + + DisposableEffect(room) { + WebRtc.addDeviceChangeListener(deviceObserver) + onDispose { + WebRtc.removeDeviceChangeListener(deviceObserver) + } + } + + LaunchedEffect(room) { + refreshDevices() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Select Devices") }, + actions = { + IconButton( + onClick = { + scope.launch { + refreshDevices() + } + } + ) { + Icon(Icons.Rounded.Refresh, contentDescription = "Refresh") + } + } + ) + } + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(it), + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("Select Camera") + DeviceSelector( + current = camera, + list = cameraList, + ) { + camera = it + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("Select Microphone") + DeviceSelector( + current = microphone, + list = microphoneList, + ) { + microphone = it + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("Select Speaker") + DeviceSelector( + current = speaker, + list = speakerList, + ) { + if(it != null) { + speaker = it + WebRtc.setAudioOutputDevice(it) + } + } + } + + OutlinedButton( + onClick = { + room.openUserMedia { + camera?.let { + video { + deviceId(it.deviceId) + } + } + + microphone?.let { + audio { + deviceId(it.deviceId) + } + } + } + }, + enabled = camera != null || microphone != null + ) { + Text("Confirm") + } + + OutlinedButton( + onClick = { + room.openDesktopMedia() + }, + ) { + Text("Share Desktop") + } + } + } +} + +@Composable +private fun DeviceSelector( + modifier: Modifier = Modifier, + list: Set?, + current: MediaDeviceInfo?, + allowSelectNone: Boolean = false, + onSelected: (MediaDeviceInfo?) -> Unit, +) { + var isOpen by remember { mutableStateOf(false) } + list?.let { devices -> + TextButton( + modifier = modifier, + onClick = { + isOpen = true + } + ) { + Text(current?.label ?: "None") + } + DropdownMenu( + expanded = isOpen, + onDismissRequest = { isOpen = false }) { + if(allowSelectNone) { + DropdownMenuItem(onClick = { + onSelected(null) + isOpen = false + }) { + Text(text = "None") + } + } + + devices.forEach { device -> + DropdownMenuItem(onClick = { + onSelected(device) + isOpen = false + }) { + Text(text = device.label) + } + } + } + } ?: CircularProgressIndicator() +} \ No newline at end of file diff --git a/sample/app-jvm/src/jvmMain/kotlin/com/shepeliev/webrtckmp/Video.kt b/sample/app-jvm/src/jvmMain/kotlin/com/shepeliev/webrtckmp/Video.kt new file mode 100644 index 00000000..988afd3b --- /dev/null +++ b/sample/app-jvm/src/jvmMain/kotlin/com/shepeliev/webrtckmp/Video.kt @@ -0,0 +1,125 @@ +package com.shepeliev.webrtckmp + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asComposeImageBitmap +import androidx.compose.ui.unit.IntSize +import dev.onvoid.webrtc.media.FourCC +import dev.onvoid.webrtc.media.video.VideoBufferConverter +import dev.onvoid.webrtc.media.video.VideoFrame +import dev.onvoid.webrtc.media.video.VideoTrackSink +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ColorType +import org.jetbrains.skia.ImageInfo +import java.nio.ByteBuffer + + +@Composable +fun Video( + track: VideoStreamTrack, + modifier: Modifier = Modifier, +) { + val renderer = remember(track) { VideoRenderer(track = track) } + + DisposableEffect(track) { + renderer.start() + + onDispose { + renderer.stop() + } + } + + val image = renderer.image.collectAsState() + + BoxWithConstraints( + modifier = modifier, + ) { + val constraints = this.constraints + Canvas(modifier = Modifier.fillMaxSize()) { + drawRoundRect( + color = Color.Black, + size = Size( + width = constraints.maxWidth.toFloat(), + height = constraints.maxHeight.toFloat(), + ) + ) + image.value?.let { + drawImage( + image = it.asComposeImageBitmap(), + dstSize = IntSize( + width = constraints.maxWidth, + height = constraints.maxHeight, + ), + ) + } + } + } +} + +private class VideoRenderer( + private val track: VideoStreamTrack, +) : VideoTrackSink { + + private val _image = MutableStateFlow(null) + val image = _image.asStateFlow() + private var byteBuffer: ByteBuffer? = null + private var frameBuffer: Bitmap? = null + + fun start() { + track.addSink(this) + track.enabled = true + } + + fun stop() { + track.removeSink(this) + _image.value = null + byteBuffer = null + frameBuffer = null + } + + override fun onVideoFrame(frame: VideoFrame) { + try { + frame.retain() + + val buffer = frame.buffer + val width = buffer.width + val height = buffer.height + + if (frameBuffer == null || frameBuffer?.width != width || frameBuffer?.height != height) { + byteBuffer = ByteBuffer.allocate(width * height * 4) + frameBuffer = Bitmap().apply { + allocPixels( + ImageInfo( + width = width, + height = height, + colorType = ColorType.RGBA_8888, + alphaType = ColorAlphaType.OPAQUE + ) + ) + } + } + + byteBuffer?.let { + VideoBufferConverter.convertFromI420(buffer, it, FourCC.ABGR) + frameBuffer?.installPixels(it.array()) + _image.value = frameBuffer?.makeClone() + } + } catch (ex: Exception) { + println(ex) + ex.printStackTrace() + } finally { + frame.release() + } + } +} \ No newline at end of file diff --git a/sample/app-jvm/src/jvmMain/kotlin/com/shepeliev/webrtckmp/VideoScreen.kt b/sample/app-jvm/src/jvmMain/kotlin/com/shepeliev/webrtckmp/VideoScreen.kt new file mode 100644 index 00000000..f73737cd --- /dev/null +++ b/sample/app-jvm/src/jvmMain/kotlin/com/shepeliev/webrtckmp/VideoScreen.kt @@ -0,0 +1,137 @@ +package com.shepeliev.webrtckmp + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.rounded.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.buildAnnotatedString +import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState +import com.shepeliev.webrtckmp.sample.shared.Room + +@Composable +fun VideoScreen(room: Room) { + val roomModel by room.model.subscribeAsState() + Scaffold( + topBar = { + TopAppBar( + title = { Text("WebRTC") }, + navigationIcon = { + IconButton( + onClick = { + room.hangup() + } + ) { + Icon(Icons.Rounded.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.BottomCenter, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + val remoteStream = roomModel.remoteStream + + val animatedWeight by animateFloatAsState( + targetValue = remoteStream?.let { 1f } ?: 0.01f + ) + + remoteStream?.let { + it.videoTracks.firstOrNull()?.let { track -> + Video( + track = track, + modifier = Modifier.weight(animatedWeight), + ) + } + } + + roomModel.localStream?.let { + it.videoTracks.firstOrNull()?.let { track -> + Video( + track = track, + modifier = Modifier.weight(1f), + ) + } + } + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Crossfade(targetState = roomModel) { + when { + it.isJoining -> CircularProgressIndicator() + + it.roomId != null -> { + val roomId = roomModel.roomId + val clipboardManager = LocalClipboardManager.current + + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "Room ID: $roomId", color = Color.White) + + IconButton(onClick = { + val text = buildAnnotatedString { append(roomId!!) } + clipboardManager.setText(text) + }) { + Icon( + Icons.Default.Share, + contentDescription = "Copy", + tint = Color.White, + ) + } + } + } + } + } + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + if (roomModel.roomId == null) { + Button(onClick = room::createRoom, enabled = !roomModel.isJoining) { + Text("Create") + } + + JoinRoomButton(onJoin = room::joinRoom, enabled = !roomModel.isJoining) + } + + Button(onClick = room::hangup) { + Text("Hangup") + } + } + } + } + } + } +} \ No newline at end of file diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index 1b2cdee9..8802ba27 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -34,6 +34,7 @@ kotlin { ios() iosSimulatorArm64() + jvm() sourceSets { val iosMain by getting @@ -59,4 +60,5 @@ dependencies { androidMainImplementation(deps.firebase.firestore) androidMainImplementation(deps.kotlin.coroutinesPlayServices) jsMainImplementation(npm("firebase", version = "9.9.1")) + jvmMainImplementation(deps.firebase.firestore.jvm) } diff --git a/sample/shared/shared.podspec b/sample/shared/shared.podspec index 6faa44ed..0a8d7056 100644 --- a/sample/shared/shared.podspec +++ b/sample/shared/shared.podspec @@ -13,6 +13,17 @@ Pod::Spec.new do |spec| spec.dependency 'FirebaseFirestore' spec.dependency 'WebRTC-SDK', '114.5735.02' + if !Dir.exist?('build/cocoapods/framework/shared.framework') || Dir.empty?('build/cocoapods/framework/shared.framework') + raise " + + Kotlin framework 'shared' doesn't exist yet, so a proper Xcode project can't be generated. + 'pod install' should be executed after running ':generateDummyFramework' Gradle task: + + ./gradlew :sample:shared:generateDummyFramework + + Alternatively, proper pod installation is performed during Gradle sync in the IDE (if Podfile location is set)" + end + spec.pod_target_xcconfig = { 'KOTLIN_PROJECT_PATH' => ':sample:shared', 'PRODUCT_MODULE_NAME' => 'shared', diff --git a/sample/shared/src/androidMain/kotlin/com/shepeliev/webrtckmp/sample/shared/RoomDataSource.kt b/sample/shared/src/androidMain/kotlin/com/shepeliev/webrtckmp/sample/shared/RoomDataSource.kt index 57a2b3d8..5c6dfb53 100644 --- a/sample/shared/src/androidMain/kotlin/com/shepeliev/webrtckmp/sample/shared/RoomDataSource.kt +++ b/sample/shared/src/androidMain/kotlin/com/shepeliev/webrtckmp/sample/shared/RoomDataSource.kt @@ -20,7 +20,7 @@ actual class RoomDataSource actual constructor() { private val firestore by lazy { Firebase.firestore } private val roomsRef by lazy { firestore.collection("rooms") } - actual fun createRoom(): String { + actual suspend fun createRoom(): String { return roomsRef.document().id } diff --git a/sample/shared/src/commonMain/kotlin/com/shepeliev/webrtckmp/sample/shared/Room.kt b/sample/shared/src/commonMain/kotlin/com/shepeliev/webrtckmp/sample/shared/Room.kt index 6e997ad8..9d81adcf 100644 --- a/sample/shared/src/commonMain/kotlin/com/shepeliev/webrtckmp/sample/shared/Room.kt +++ b/sample/shared/src/commonMain/kotlin/com/shepeliev/webrtckmp/sample/shared/Room.kt @@ -2,12 +2,17 @@ package com.shepeliev.webrtckmp.sample.shared import com.arkivanov.decompose.value.Value import com.shepeliev.webrtckmp.MediaStream +import com.shepeliev.webrtckmp.MediaStreamConstraintsBuilder interface Room { val model: Value - fun openUserMedia() + fun openUserMedia(streamConstraints: MediaStreamConstraintsBuilder.() -> Unit = { + video() + audio() + }) + fun openDesktopMedia() fun switchCamera() fun createRoom() fun joinRoom(roomId: String) diff --git a/sample/shared/src/commonMain/kotlin/com/shepeliev/webrtckmp/sample/shared/RoomComponent.kt b/sample/shared/src/commonMain/kotlin/com/shepeliev/webrtckmp/sample/shared/RoomComponent.kt index a95be72f..1b8bf32b 100644 --- a/sample/shared/src/commonMain/kotlin/com/shepeliev/webrtckmp/sample/shared/RoomComponent.kt +++ b/sample/shared/src/commonMain/kotlin/com/shepeliev/webrtckmp/sample/shared/RoomComponent.kt @@ -5,7 +5,6 @@ import co.touchlab.kermit.platformLogWriter import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value -import com.arkivanov.decompose.value.reduce import com.arkivanov.essenty.instancekeeper.InstanceKeeper import com.arkivanov.essenty.instancekeeper.getOrCreate import com.arkivanov.essenty.lifecycle.doOnCreate @@ -16,6 +15,7 @@ import com.arkivanov.essenty.lifecycle.doOnStart import com.arkivanov.essenty.lifecycle.doOnStop import com.shepeliev.webrtckmp.IceServer import com.shepeliev.webrtckmp.MediaDevices +import com.shepeliev.webrtckmp.MediaStreamConstraintsBuilder import com.shepeliev.webrtckmp.MediaStreamTrack import com.shepeliev.webrtckmp.MediaStreamTrackKind import com.shepeliev.webrtckmp.OfferAnswerOptions @@ -29,6 +29,7 @@ import com.shepeliev.webrtckmp.onIceGatheringState import com.shepeliev.webrtckmp.onSignalingStateChange import com.shepeliev.webrtckmp.onTrack import com.shepeliev.webrtckmp.videoTracks +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.MainScope import kotlinx.coroutines.SupervisorJob @@ -45,8 +46,11 @@ class RoomComponent( viewModel: Room, ) : Room by viewModel, ComponentContext by componentContext { - constructor(componentContext: ComponentContext) : - this(componentContext, componentContext.instanceKeeper.getOrCreate { ViewModel() }) + constructor( + componentContext: ComponentContext, + scope: CoroutineScope = MainScope(), + ) : + this(componentContext, componentContext.instanceKeeper.getOrCreate { ViewModel(scope) }) private val logger = Logger.withTag("RoomComponent") @@ -61,26 +65,26 @@ class RoomComponent( lifecycle.doOnDestroy { logger.d { "onDestroy" } } } - private class ViewModel : InstanceKeeper.Instance, Room { + private class ViewModel( + val scope: CoroutineScope, + ) : InstanceKeeper.Instance, Room { private val logger = Logger.withTag("RoomComponent => ViewModel") private val _model = MutableValue(Room.Model()) override val model: Value get() = _model - private val scope = MainScope() - private val roomDataSource = RoomDataSource() private var peerConnection: PeerConnection? = null private var roomSessionJob: Job? = null - override fun openUserMedia() { + override fun openUserMedia(streamConstraints: MediaStreamConstraintsBuilder.() -> Unit) { logger.i { "Open user media" } roomSessionJob = SupervisorJob() scope.launch { try { - val stream = MediaDevices.getUserMedia(audio = true, video = true) - _model.reduce { it.copy(localStream = stream) } + val stream = MediaDevices.getUserMedia(streamConstraints) + _model.value = _model.value.copy(localStream = stream) listenTrackState(stream.videoTracks.first(), "Local video") } catch (e: Throwable) { logger.e(e) { "Getting user media failed" } @@ -88,6 +92,21 @@ class RoomComponent( } } + override fun openDesktopMedia() { + logger.i { "Open desktop media" } + roomSessionJob = SupervisorJob() + + scope.launch { + try { + val stream = MediaDevices.getDisplayMedia() + _model.value = _model.value.copy(localStream = stream) + listenTrackState(stream.videoTracks.first(), "Local video") + } catch (e: Throwable) { + logger.e(e) { "Getting desktop media failed" } + } + } + } + override fun switchCamera() { logger.i { "Switch camera" } scope.launch { @@ -99,7 +118,7 @@ class RoomComponent( override fun createRoom() { logger.i { "Create room" } - _model.reduce { it.copy(isJoining = true, isCaller = true) } + _model.value = _model.value.copy(isJoining = true, isCaller = true) val peerConnection = createPeerConnection() this@ViewModel.peerConnection = peerConnection @@ -114,7 +133,7 @@ class RoomComponent( } roomDataSource.insertOffer(roomId, offer) - _model.reduce { it.copy(roomId = roomId, isJoining = false) } + _model.value = _model.value.copy(roomId = roomId, isJoining = false) logger.d { "Waiting answer" } val answer = roomDataSource.getAnswer(roomId) @@ -126,7 +145,7 @@ class RoomComponent( override fun joinRoom(roomId: String) { logger.i { "Join room: $roomId" } - _model.reduce { it.copy(isJoining = true, roomId = roomId, isCaller = false) } + _model.value = _model.value.copy(isJoining = true, roomId = roomId, isCaller = false) roomSessionJob = SupervisorJob() val peerConnection = createPeerConnection() @@ -136,7 +155,7 @@ class RoomComponent( val offer = roomDataSource.getOffer(roomId) if (offer == null) { logger.e { "No offer SDP in the room [id = $roomId]" } - _model.reduce { it.copy(isJoining = false, isCaller = null) } + _model.value = _model.value.copy(isJoining = false, isCaller = null) return@launch } @@ -148,7 +167,7 @@ class RoomComponent( roomDataSource.insertAnswer(roomId, it) } - _model.reduce { it.copy(isJoining = false) } + _model.value = _model.value.copy(isJoining = false) } } @@ -188,7 +207,7 @@ class RoomComponent( peerConnection.onTrack .onEach { logger.i { "Remote track received: [id = ${it.track?.id}, kind: ${it.track?.kind} ]" } } .filter { it.track?.kind == MediaStreamTrackKind.Video } - .onEach { event -> _model.reduce { it.copy(remoteStream = event.streams.first()) } } + .onEach { event -> _model.value = _model.value.copy(remoteStream = event.streams.first()) } .onEach { listenTrackState(it.track!!, "Remote video") } .launchIn(scope + roomSessionJob!!) } @@ -223,7 +242,7 @@ class RoomComponent( roomSessionJob?.cancel() roomSessionJob = null _model.value.localStream?.release() - _model.reduce { Room.Model() } + _model.value = Room.Model() peerConnection?.close() peerConnection = null diff --git a/sample/shared/src/commonMain/kotlin/com/shepeliev/webrtckmp/sample/shared/RoomDataSource.kt b/sample/shared/src/commonMain/kotlin/com/shepeliev/webrtckmp/sample/shared/RoomDataSource.kt index ea151162..ceba207e 100644 --- a/sample/shared/src/commonMain/kotlin/com/shepeliev/webrtckmp/sample/shared/RoomDataSource.kt +++ b/sample/shared/src/commonMain/kotlin/com/shepeliev/webrtckmp/sample/shared/RoomDataSource.kt @@ -7,7 +7,7 @@ typealias IceCandidate = com.shepeliev.webrtckmp.IceCandidate typealias SessionDescriptionType = com.shepeliev.webrtckmp.SessionDescriptionType expect class RoomDataSource() { - fun createRoom(): String + suspend fun createRoom(): String suspend fun insertOffer(roomId: String, description: SessionDescription) suspend fun insertAnswer(roomId: String, description: SessionDescription) suspend fun insertIceCandidate(roomId: String, peerName: String, candidate: IceCandidate) diff --git a/sample/shared/src/iosMain/kotlin/com/shepeliev/webrtckmp/sample/shared/RoomDataSource.kt b/sample/shared/src/iosMain/kotlin/com/shepeliev/webrtckmp/sample/shared/RoomDataSource.kt index 22601538..4f67cd77 100644 --- a/sample/shared/src/iosMain/kotlin/com/shepeliev/webrtckmp/sample/shared/RoomDataSource.kt +++ b/sample/shared/src/iosMain/kotlin/com/shepeliev/webrtckmp/sample/shared/RoomDataSource.kt @@ -24,7 +24,7 @@ actual class RoomDataSource actual constructor() { FIRFirestore.firestore().collectionWithPath("rooms") } - actual fun createRoom(): String { + actual suspend fun createRoom(): String { return roomsRef.documentWithAutoID().documentID } diff --git a/sample/shared/src/jsMain/kotlin/com/shepeliev/webrtckmp/sample/shared/RoomDataSource.kt b/sample/shared/src/jsMain/kotlin/com/shepeliev/webrtckmp/sample/shared/RoomDataSource.kt index 760ca30a..3988293a 100644 --- a/sample/shared/src/jsMain/kotlin/com/shepeliev/webrtckmp/sample/shared/RoomDataSource.kt +++ b/sample/shared/src/jsMain/kotlin/com/shepeliev/webrtckmp/sample/shared/RoomDataSource.kt @@ -27,7 +27,7 @@ actual class RoomDataSource actual constructor() { private val roomsRef by lazy { collection(firestore, "rooms") } - actual fun createRoom(): String { + actual suspend fun createRoom(): String { return doc(roomsRef).id } diff --git a/sample/shared/src/jvmMain/kotlin/com/shepeliev/webrtckmp/sample/shared/RoomDataSource.kt b/sample/shared/src/jvmMain/kotlin/com/shepeliev/webrtckmp/sample/shared/RoomDataSource.kt new file mode 100644 index 00000000..e5b5a615 --- /dev/null +++ b/sample/shared/src/jvmMain/kotlin/com/shepeliev/webrtckmp/sample/shared/RoomDataSource.kt @@ -0,0 +1,130 @@ +package com.shepeliev.webrtckmp.sample.shared + +import android.app.Application +import com.google.firebase.FirebasePlatform +import com.google.firebase.firestore.DocumentChange +import dev.gitlive.firebase.Firebase +import dev.gitlive.firebase.FirebaseOptions +import dev.gitlive.firebase.firestore.firestore +import dev.gitlive.firebase.initialize +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.tasks.await +import java.util.Date +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +actual class RoomDataSource actual constructor() { + + private val firebaseApp by lazy { + FirebasePlatform.initializeFirebasePlatform(object : FirebasePlatform() { + val storage = mutableMapOf() + override fun store(key: String, value: String) = storage.set(key, value) + override fun retrieve(key: String) = storage[key] + override fun clear(key: String) { + storage.remove(key) + } + + override fun log(msg: String) = println(msg) + }) + + Firebase.initialize( + Application(), + FirebaseOptions( + projectId = "app-rtc-kmp", + applicationId = "1:216132728347:web:f10a385863ec2d43872abe", + apiKey = "AIzaSyDa0FDyeGNZZcKKBXnALeJSqfUxSNKut4w", + authDomain = "app-rtc-kmp.firebaseapp.com", + storageBucket = "app-rtc-kmp.appspot.com", + gcmSenderId = "216132728347", + ), + ) + } + + private val firestore by lazy { + Firebase.firestore(firebaseApp).apply { + setSettings(persistenceEnabled = false) + }.android + } + private val roomsRef by lazy { firestore.collection("rooms") } + + actual suspend fun createRoom(): String { + return roomsRef.document().id + } + + actual suspend fun insertOffer(roomId: String, description: SessionDescription) { + roomsRef.document(roomId).set( + mapOf( + "offer" to description.sdp, + "expireAt" to getExpireAtTime() + ) + ).await() + } + + actual suspend fun insertAnswer(roomId: String, description: SessionDescription) { + roomsRef.document(roomId).update(mapOf("answer" to description.sdp)).await() + } + + actual suspend fun insertIceCandidate(roomId: String, peerName: String, candidate: IceCandidate) { + roomsRef.document(roomId) + .collection(peerName) + .add( + mapOf( + "candidate" to candidate.candidate, + "sdpMLineIndex" to candidate.sdpMLineIndex, + "sdpMid" to candidate.sdpMid, + "expireAt" to getExpireAtTime(), + ) + ) + .await() + } + + private fun getExpireAtTime(): Date { + val expireAt = System.currentTimeMillis() + FIRESTORE_DOCUMENT_TTL_SECONDS * 1000 + return Date(expireAt) + } + + actual suspend fun getOffer(roomId: String): SessionDescription? { + val snapshot = roomsRef.document(roomId).get().await() + val offerSdp = snapshot.takeIf { it.exists() }?.getString("offer") + return offerSdp?.let { SessionDescription(SessionDescriptionType.Offer, it) } + } + + actual suspend fun getAnswer(roomId: String): SessionDescription = suspendCancellableCoroutine { cont -> + val registration = roomsRef.document(roomId).addSnapshotListener { value, error -> + val answer = value?.data?.get("answer") as? String + when { + answer != null -> cont.resume(SessionDescription(SessionDescriptionType.Answer, answer)) + error != null -> cont.resumeWithException(error) + } + } + + cont.invokeOnCancellation { registration.remove() } + } + + actual fun observeIceCandidates(roomId: String, peerName: String): Flow = callbackFlow { + val registration = roomsRef.document(roomId).collection(peerName).addSnapshotListener { value, error -> + if (error != null) { + channel.close(error) + } + + val dc = value?.documentChanges ?: return@addSnapshotListener + + dc.filter { it.type == DocumentChange.Type.ADDED } + .map { + IceCandidate( + sdpMid = it.document.data["sdpMid"] as String, + sdpMLineIndex = (it.document.data["sdpMLineIndex"] as Long).toInt(), + candidate = it.document.data["candidate"] as String, + ) + } + .forEach { trySendBlocking(it) } + } + + awaitClose { registration.remove() } + } + +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 8bf0b79b..f455d9b3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,7 +21,10 @@ pluginManagement { } } +rootProject.name = "WebRTC" + include(":webrtc-kmp") include(":sample:shared") include(":sample:app-android") include(":sample:app-web") +include(":sample:app-jvm") diff --git a/webrtc-kmp/build.gradle.kts b/webrtc-kmp/build.gradle.kts index d7424120..82c7ecc1 100644 --- a/webrtc-kmp/build.gradle.kts +++ b/webrtc-kmp/build.gradle.kts @@ -27,7 +27,7 @@ kotlin { android { publishAllLibraryVariants() } - + jvm() ios { configureIos() } iosSimulatorArm64 { configureIos() } @@ -53,9 +53,31 @@ android { dependencies { commonMainImplementation(deps.kotlin.coroutines) androidMainImplementation(deps.androidx.coreKtx) - androidMainApi(deps.webrtcSdk) + androidMainApi(deps.webrtc.android) androidTestImplementation(deps.androidx.test.core) androidTestImplementation(deps.androidx.test.runner) + + jvmMainApi(deps.webrtc.java) + jvmMainImplementation( + group = deps.webrtc.java.get().group!!, + name = deps.webrtc.java.get().name, + version = deps.webrtc.java.get().version, + classifier = "windows-x86_64" + ) + jvmMainImplementation( + group = deps.webrtc.java.get().group!!, + name = deps.webrtc.java.get().name, + version = deps.webrtc.java.get().version, + classifier = "macos-x86_64" + ) + jvmMainImplementation( + group = deps.webrtc.java.get().group!!, + name = deps.webrtc.java.get().name, + version = deps.webrtc.java.get().version, + classifier = "linux-x86_64" + ) + jvmMainImplementation(deps.bouncyCastle) + jsMainImplementation(npm("webrtc-adapter", "8.1.1")) } diff --git a/webrtc-kmp/src/androidMain/kotlin/com/shepeliev/webrtckmp/MediaStreamTrackImpl.kt b/webrtc-kmp/src/androidMain/kotlin/com/shepeliev/webrtckmp/MediaStreamTrackImpl.kt index 1d32eeae..25066692 100644 --- a/webrtc-kmp/src/androidMain/kotlin/com/shepeliev/webrtckmp/MediaStreamTrackImpl.kt +++ b/webrtc-kmp/src/androidMain/kotlin/com/shepeliev/webrtckmp/MediaStreamTrackImpl.kt @@ -64,7 +64,7 @@ internal abstract class MediaStreamTrackImpl( private fun getInitialState(): MediaStreamTrackState { return when (checkNotNull(android.state())) { AndroidMediaStreamTrack.State.LIVE -> MediaStreamTrackState.Live(muted = false) - AndroidMediaStreamTrack.State.ENDED -> MediaStreamTrackState.Live(muted = false) + AndroidMediaStreamTrack.State.ENDED -> MediaStreamTrackState.Ended(muted = false) } } } diff --git a/webrtc-kmp/src/commonMain/kotlin/com/shepeliev/webrtckmp/MediaDeviceInfo.kt b/webrtc-kmp/src/commonMain/kotlin/com/shepeliev/webrtckmp/MediaDeviceInfo.kt index a6dc0a88..43b37ce5 100644 --- a/webrtc-kmp/src/commonMain/kotlin/com/shepeliev/webrtckmp/MediaDeviceInfo.kt +++ b/webrtc-kmp/src/commonMain/kotlin/com/shepeliev/webrtckmp/MediaDeviceInfo.kt @@ -6,4 +6,4 @@ data class MediaDeviceInfo( val kind: MediaDeviceKind, ) -enum class MediaDeviceKind { VideoInput, AudioInput } +enum class MediaDeviceKind { VideoInput, AudioInput, AudioOutput } diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/ByteBuffer.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/ByteBuffer.kt new file mode 100644 index 00000000..b7207c91 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/ByteBuffer.kt @@ -0,0 +1,10 @@ +package com.shepeliev.webrtckmp + +import java.nio.ByteBuffer + +fun ByteBuffer.toByteArray(): ByteArray { + val bytes = ByteArray(remaining()) + get(bytes) + rewind() + return bytes +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/DataChannel.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/DataChannel.kt new file mode 100644 index 00000000..01e63246 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/DataChannel.kt @@ -0,0 +1,91 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.RTCDataChannelBuffer +import dev.onvoid.webrtc.RTCDataChannelObserver +import dev.onvoid.webrtc.RTCDataChannelState +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import java.nio.ByteBuffer +import dev.onvoid.webrtc.RTCDataChannel as NativeDataChannel + +actual class DataChannel(val native: NativeDataChannel) { + + actual val label: String + get() = native.label + + actual val id: Int + get() = native.id + + actual val readyState: DataChannelState + get() = native.state.toCommon() + + actual val bufferedAmount: Long + get() = native.bufferedAmount + + private val dataChannelEvent = callbackFlow { + val observer = object : RTCDataChannelObserver { + override fun onBufferedAmountChange(p0: Long) { + // not implemented + } + + override fun onStateChange() { + trySendBlocking(DataChannelEvent.StateChanged) + } + + override fun onMessage(buffer: RTCDataChannelBuffer) { + trySendBlocking(DataChannelEvent.MessageReceived(buffer)) + } + } + + native.registerObserver(observer) + + awaitClose { native.unregisterObserver() } + } + + actual val onOpen: Flow = dataChannelEvent + .filter { it is DataChannelEvent.StateChanged && native.state == RTCDataChannelState.OPEN } + .map { } + + actual val onClosing: Flow = dataChannelEvent + .filter { it is DataChannelEvent.StateChanged && native.state == RTCDataChannelState.CLOSING } + .map { } + + actual val onClose: Flow = dataChannelEvent + .filter { it is DataChannelEvent.StateChanged && native.state == RTCDataChannelState.CLOSED } + .map { } + + actual val onError: Flow = emptyFlow() + + actual val onMessage: Flow = dataChannelEvent + .map { it as? DataChannelEvent.MessageReceived } + .filterNotNull() + .map { it.buffer.data.toByteArray() } + + actual fun send(data: ByteArray): Boolean { + val buffer = RTCDataChannelBuffer(ByteBuffer.wrap(data), true) + native.send(buffer) + return true + } + + actual fun close() = native.dispose() + + private fun RTCDataChannelState.toCommon(): DataChannelState { + return when (this) { + RTCDataChannelState.CONNECTING -> DataChannelState.Connecting + RTCDataChannelState.OPEN -> DataChannelState.Open + RTCDataChannelState.CLOSING -> DataChannelState.Closing + RTCDataChannelState.CLOSED -> DataChannelState.Closed + } + } + + private sealed interface DataChannelEvent { + object StateChanged : DataChannelEvent + data class MessageReceived(val buffer: RTCDataChannelBuffer) : DataChannelEvent + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/DesktopVideoStreamTrack.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/DesktopVideoStreamTrack.kt new file mode 100644 index 00000000..9ae32f08 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/DesktopVideoStreamTrack.kt @@ -0,0 +1,28 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.media.video.VideoDesktopSource +import dev.onvoid.webrtc.media.video.VideoDeviceSource +import dev.onvoid.webrtc.media.video.VideoTrack + + +internal class DesktopVideoStreamTrack( + native: VideoTrack, + private val videoSource: VideoDesktopSource, + override val settings: MediaTrackSettings, +) : RenderedVideoStreamTrack(native), VideoStreamTrack { + + init { + videoSource.start() + } + + override suspend fun switchCamera(deviceId: String?) {} + + override fun onSetEnabled(enabled: Boolean) { + native.isEnabled = enabled + } + + override fun onStop() { + videoSource.stop() + videoSource.dispose() + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/DtmfSender.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/DtmfSender.kt new file mode 100644 index 00000000..b7313719 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/DtmfSender.kt @@ -0,0 +1,19 @@ +package com.shepeliev.webrtckmp + +actual class DtmfSender() { + + actual val canInsertDtmf: Boolean + get() = false + + actual val duration: Int + get() = 0 + + actual val interToneGap: Int + get() = 0 + + actual fun insertDtmf(tones: String, durationMs: Int, interToneGapMs: Int): Boolean { + TODO("Not yet implemented for JVM platform") + } + + actual fun tones(): String = "" +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/IceCandidate.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/IceCandidate.kt new file mode 100644 index 00000000..c22f161a --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/IceCandidate.kt @@ -0,0 +1,15 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.RTCIceCandidate as NativeIceCandidate + +actual class IceCandidate internal constructor(val native: NativeIceCandidate) { + actual constructor(sdpMid: String, sdpMLineIndex: Int, candidate: String) : this( + NativeIceCandidate(sdpMid, sdpMLineIndex, candidate) + ) + + actual val sdpMid: String = native.sdpMid + actual val sdpMLineIndex: Int = native.sdpMLineIndex + actual val candidate: String = native.sdp + + actual override fun toString(): String = native.toString() +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/IceServer.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/IceServer.kt new file mode 100644 index 00000000..f0fcf60e --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/IceServer.kt @@ -0,0 +1,38 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.RTCIceServer as NativeIceServer +import dev.onvoid.webrtc.TlsCertPolicy as NativeTlsCertPolicy + +actual class IceServer internal constructor(val native: NativeIceServer) { + actual constructor( + urls: List, + username: String, + password: String, + tlsCertPolicy: TlsCertPolicy, + hostname: String, + tlsAlpnProtocols: List?, + tlsEllipticCurves: List? + ) : this( + NativeIceServer().apply { + this.urls = urls + this.username = username + this.password = password + this.tlsCertPolicy = tlsCertPolicy.asNative() + this.hostname = hostname + this.tlsAlpnProtocols = tlsAlpnProtocols + this.tlsEllipticCurves = tlsEllipticCurves + } + ) + + actual override fun toString(): String = native.toString() +} + +private fun TlsCertPolicy.asNative(): NativeTlsCertPolicy { + return when (this) { + TlsCertPolicy.TlsCertPolicySecure -> NativeTlsCertPolicy.SECURE + + TlsCertPolicy.TlsCertPolicyInsecureNoCheck -> { + NativeTlsCertPolicy.INSECURE_NO_CHECK + } + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/LocalAudioStreamTrack.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/LocalAudioStreamTrack.kt new file mode 100644 index 00000000..38f4943e --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/LocalAudioStreamTrack.kt @@ -0,0 +1,15 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.media.audio.AudioTrack +import dev.onvoid.webrtc.media.audio.AudioTrackSource + +internal class LocalAudioStreamTrack( + native: AudioTrack, + private val audioSource: AudioTrackSource, + override val constraints: MediaTrackConstraints, +) : MediaStreamTrackImpl(native), AudioStreamTrack { + + override fun onStop() { + // audioSource.dispose() + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/LocalVideoStreamTrack.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/LocalVideoStreamTrack.kt new file mode 100644 index 00000000..60d3b0ea --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/LocalVideoStreamTrack.kt @@ -0,0 +1,27 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.media.video.VideoDeviceSource +import dev.onvoid.webrtc.media.video.VideoTrack + + +internal class LocalVideoStreamTrack( + native: VideoTrack, + private val videoSource: VideoDeviceSource, + override val settings: MediaTrackSettings, +) : RenderedVideoStreamTrack(native), VideoStreamTrack { + + init { + videoSource.start() + } + + override suspend fun switchCamera(deviceId: String?) {} + + override fun onSetEnabled(enabled: Boolean) { + native.isEnabled = enabled + } + + override fun onStop() { + videoSource.stop() + videoSource.dispose() + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/MediaDevices.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/MediaDevices.kt new file mode 100644 index 00000000..cf73472e --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/MediaDevices.kt @@ -0,0 +1,214 @@ +@file:JvmName("JVMMediaDevices") + +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.media.Device +import dev.onvoid.webrtc.media.DeviceChangeListener +import dev.onvoid.webrtc.media.audio.AudioDevice +import dev.onvoid.webrtc.media.audio.AudioOptions +import dev.onvoid.webrtc.media.video.VideoCaptureCapability +import dev.onvoid.webrtc.media.video.VideoDesktopSource +import dev.onvoid.webrtc.media.video.VideoDevice +import dev.onvoid.webrtc.media.video.VideoDeviceSource +import java.util.UUID +import dev.onvoid.webrtc.media.MediaDevices as NativeMediaDevices + +internal actual val mediaDevices: MediaDevices = MediaDevicesImpl + +interface MediaDeviceListener { + fun deviceConnected(device: MediaDeviceInfo) + fun deviceDisconnected(device: MediaDeviceInfo) +} + +internal object MediaDevicesImpl : MediaDevices, DeviceChangeListener { + + private val deviceListeners: MutableList = mutableListOf() + + init { + NativeMediaDevices.addDeviceChangeListener(this) + } + + override suspend fun getUserMedia(streamConstraints: MediaStreamConstraintsBuilder.() -> Unit): MediaStream { + val constraints = MediaStreamConstraintsBuilder().let { + streamConstraints(it) + it.constraints + } + + var audioTrack: AudioStreamTrack? = null + if (constraints.audio != null) { + val audioDevices = NativeMediaDevices.getAudioCaptureDevices() + + if(audioDevices.isNotEmpty()) { + val device = constraints.audio.deviceId?.let { deviceId -> + audioDevices.first { device -> + device.descriptor == deviceId + } + } ?: NativeMediaDevices.getDefaultAudioCaptureDevice() + + WebRtc.audioDeviceModule.setRecordingDevice(device) + WebRtc.audioDeviceModule.initRecording() + + val mediaConstraints = AudioOptions().apply { + this.autoGainControl = constraints.audio.autoGainControl?.value == true + this.echoCancellation = constraints.audio.echoCancellation?.value == true + this.noiseSuppression = constraints.audio.noiseSuppression?.value == true + } + val audioSource = WebRtc.peerConnectionFactory.createAudioSource(mediaConstraints) + val androidTrack = WebRtc.peerConnectionFactory.createAudioTrack( + UUID.randomUUID().toString(), + audioSource + ) + audioTrack = LocalAudioStreamTrack(androidTrack, audioSource, constraints.audio) + } + } + + var videoTrack: LocalVideoStreamTrack? = null + if (constraints.video != null) { + val videoDevicesWithCapabilities = NativeMediaDevices.getVideoCaptureDevices().map { + Pair(it, getMatchingCapabilities(it, constraints.video)) + } + + if (videoDevicesWithCapabilities.isNotEmpty()) { + val matchingDevice = constraints.video.deviceId?.let { deviceId -> + videoDevicesWithCapabilities.first { device -> + device.first.descriptor == deviceId + } + } ?: videoDevicesWithCapabilities.firstOrNull { + it.second.isNotEmpty() + } + + if(matchingDevice != null) { + val videoSource = VideoDeviceSource().apply { + setVideoCaptureDevice(matchingDevice.first) + setVideoCaptureCapability(matchingDevice.second.first()) + } + val nativeTrack = WebRtc.peerConnectionFactory.createVideoTrack( + UUID.randomUUID().toString(), + videoSource + ) + videoTrack = LocalVideoStreamTrack( + native = nativeTrack, + videoSource = videoSource, + settings = MediaTrackSettings(), + ) + } + } + } + + return MediaStream().apply { + if (audioTrack != null) addTrack(audioTrack) + if (videoTrack != null) addTrack(videoTrack) + } + } + + private fun getMatchingCapabilities(device: VideoDevice, constraints: MediaTrackConstraints): List { + val capabilities = NativeMediaDevices.getVideoCaptureCapabilities(device) + + val exact = capabilities.firstOrNull { capability -> + constraints.height?.exact?.let { it == capability.height } ?: true && + constraints.width?.exact?.let { it == capability.width } ?: true && + constraints.frameRate?.exact?.let { it.toInt() == capability.frameRate } ?: true + }?.let { + listOf(it) + } + + return exact ?: capabilities.filter { capability -> + val satisfyHeight = constraints.height?.value?.let { + it >= capability.height + } ?: true + + val satisfyWidth = constraints.width?.value?.let { + it >= capability.width + } ?: true + + val satisfyFrameRate = constraints.frameRate?.value?.let { + it >= capability.frameRate + } ?: true + + satisfyHeight && satisfyWidth && satisfyFrameRate + } + } + + override suspend fun getDisplayMedia(): MediaStream { + val source = VideoDesktopSource() + val track = WebRtc.peerConnectionFactory.createVideoTrack("desktop", source) + + return MediaStream().apply { + addTrack( + DesktopVideoStreamTrack( + native = track, + videoSource = source, + settings = MediaTrackSettings(), + ) + ) + } + } + + override suspend fun supportsDisplayMedia(): Boolean = true + + override suspend fun enumerateDevices(): List { + val audioInputDevices = NativeMediaDevices.getAudioCaptureDevices().map { + MediaDeviceInfo( + deviceId = it.descriptor, + label = it.name, + kind = MediaDeviceKind.AudioInput + ) + } + val audioOutputDevices = NativeMediaDevices.getAudioRenderDevices().map { + MediaDeviceInfo( + deviceId = it.descriptor, + label = it.name, + kind = MediaDeviceKind.AudioOutput + ) + } + val videoDevices = NativeMediaDevices.getVideoCaptureDevices().map { + MediaDeviceInfo( + deviceId = it.descriptor, + label = it.name, + kind = MediaDeviceKind.VideoInput + ) + } + + + return audioInputDevices + audioOutputDevices + videoDevices + } + + fun addDeviceChangeListener(listener: MediaDeviceListener) { + deviceListeners.add(listener) + } + + fun removeDeviceChangeListener(listener: MediaDeviceListener) { + deviceListeners.remove(listener) + } + + override fun deviceConnected(device: Device) { + val deviceInfo = MediaDeviceInfo( + deviceId = device.descriptor, + label = device.name, + kind = when(device) { + is AudioDevice -> MediaDeviceKind.AudioInput + else -> MediaDeviceKind.VideoInput + } + ) + deviceListeners.forEach { + it.deviceConnected(deviceInfo) + } + } + + override fun deviceDisconnected(device: Device) { + val deviceInfo = MediaDeviceInfo( + deviceId = device.descriptor, + label = device.name, + kind = when(device) { + is AudioDevice -> if(NativeMediaDevices.getAudioCaptureDevices().contains(device)) + MediaDeviceKind.AudioInput + else + MediaDeviceKind.AudioOutput + else -> MediaDeviceKind.VideoInput + } + ) + deviceListeners.forEach { + it.deviceDisconnected(deviceInfo) + } + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/MediaStream.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/MediaStream.kt new file mode 100644 index 00000000..2f2c3bc2 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/MediaStream.kt @@ -0,0 +1,50 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.media.audio.AudioTrack +import dev.onvoid.webrtc.media.video.VideoTrack +import dev.onvoid.webrtc.media.MediaStream as NativeMediaStream +import java.util.UUID + +actual class MediaStream internal constructor( + val native: NativeMediaStream? = null, + actual val id: String = native?.id() ?: UUID.randomUUID().toString(), +) { + + private val _tracks = mutableListOf() + actual val tracks: List = _tracks + + actual open fun addTrack(track: MediaStreamTrack) { + require(track is MediaStreamTrackImpl) + + native?.let { + when (track.native) { + is AudioTrack -> it.addTrack(track.native) + is VideoTrack -> it.addTrack(track.native) + else -> error("Unknown MediaStreamTrack kind: ${track.kind}") + } + } + _tracks += track + } + + actual open fun getTrackById(id: String): MediaStreamTrack? { + return tracks.firstOrNull { it.id == id } + } + + actual open fun removeTrack(track: MediaStreamTrack) { + require(track is MediaStreamTrackImpl) + + native?.let { + when (track.native) { + is AudioTrack -> it.removeTrack(track.native) + is VideoTrack -> it.removeTrack(track.native) + else -> error("Unknown MediaStreamTrack kind: ${track.kind}") + } + } + _tracks -= track + } + + actual open fun release() { + tracks.forEach(MediaStreamTrack::stop) + native?.dispose() + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/MediaStreamTrackImpl.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/MediaStreamTrackImpl.kt new file mode 100644 index 00000000..81a3063d --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/MediaStreamTrackImpl.kt @@ -0,0 +1,69 @@ +@file:JvmName("JVMMediaStreamTrack") + +package com.shepeliev.webrtckmp + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import dev.onvoid.webrtc.media.MediaStreamTrack as NativeMediaStreamTrack + +internal abstract class MediaStreamTrackImpl( + val native: NativeMediaStreamTrack +) : MediaStreamTrack { + + override val id: String + get() = native.id + + override val kind: MediaStreamTrackKind + get() = when (native.kind) { + NativeMediaStreamTrack.AUDIO_TRACK_KIND -> MediaStreamTrackKind.Audio + NativeMediaStreamTrack.VIDEO_TRACK_KIND -> MediaStreamTrackKind.Video + else -> error("Unknown track kind: ${native.kind}") + } + + override val label: String + get() = when (kind) { + MediaStreamTrackKind.Audio -> "microphone" + MediaStreamTrackKind.Video -> "camera" + } + + override var enabled: Boolean + get() = native.isEnabled + set(value) { + if (value == native.isEnabled) return + native.isEnabled = value + onSetEnabled(value) + } + + private val _state = MutableStateFlow(getInitialState()) + override val state: StateFlow = _state.asStateFlow() + + override val constraints: MediaTrackConstraints = MediaTrackConstraints() + override val settings: MediaTrackSettings = MediaTrackSettings() + + override fun stop() { + if (_state.value is MediaStreamTrackState.Ended) return + _state.update { MediaStreamTrackState.Ended(it.muted) } + onStop() + } + + protected open fun setMuted(muted: Boolean) { + if (muted) { + _state.update { it.mute() } + } else { + _state.update { it.unmute() } + } + } + + protected open fun onSetEnabled(enabled: Boolean) {} + + protected open fun onStop() {} + + private fun getInitialState(): MediaStreamTrackState { + return when (checkNotNull(native.state)) { + dev.onvoid.webrtc.media.MediaStreamTrackState.LIVE -> MediaStreamTrackState.Live(muted = false) + dev.onvoid.webrtc.media.MediaStreamTrackState.ENDED -> MediaStreamTrackState.Ended(muted = false) + } + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/PeerConnection.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/PeerConnection.kt new file mode 100644 index 00000000..83333287 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/PeerConnection.kt @@ -0,0 +1,344 @@ +package com.shepeliev.webrtckmp + +import com.shepeliev.webrtckmp.PeerConnectionEvent.ConnectionStateChange +import com.shepeliev.webrtckmp.PeerConnectionEvent.IceConnectionStateChange +import com.shepeliev.webrtckmp.PeerConnectionEvent.IceGatheringStateChange +import com.shepeliev.webrtckmp.PeerConnectionEvent.NegotiationNeeded +import com.shepeliev.webrtckmp.PeerConnectionEvent.NewDataChannel +import com.shepeliev.webrtckmp.PeerConnectionEvent.NewIceCandidate +import com.shepeliev.webrtckmp.PeerConnectionEvent.RemoveTrack +import com.shepeliev.webrtckmp.PeerConnectionEvent.RemovedIceCandidates +import com.shepeliev.webrtckmp.PeerConnectionEvent.SignalingStateChange +import com.shepeliev.webrtckmp.PeerConnectionEvent.StandardizedIceConnectionChange +import com.shepeliev.webrtckmp.PeerConnectionEvent.Track +import dev.onvoid.webrtc.CreateSessionDescriptionObserver +import dev.onvoid.webrtc.PeerConnectionObserver +import dev.onvoid.webrtc.RTCAnswerOptions +import dev.onvoid.webrtc.RTCDataChannel +import dev.onvoid.webrtc.RTCDataChannelInit +import dev.onvoid.webrtc.RTCIceCandidate +import dev.onvoid.webrtc.RTCIceConnectionState +import dev.onvoid.webrtc.RTCIceGatheringState +import dev.onvoid.webrtc.RTCOfferOptions +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine +import dev.onvoid.webrtc.RTCPeerConnection +import dev.onvoid.webrtc.RTCPeerConnectionIceErrorEvent +import dev.onvoid.webrtc.RTCPeerConnectionState +import dev.onvoid.webrtc.RTCRtpReceiver +import dev.onvoid.webrtc.RTCRtpTransceiver +import dev.onvoid.webrtc.RTCRtpTransceiverDirection +import dev.onvoid.webrtc.RTCSessionDescription +import dev.onvoid.webrtc.RTCSignalingState +import dev.onvoid.webrtc.SetSessionDescriptionObserver +import dev.onvoid.webrtc.media.audio.AudioTrack +import dev.onvoid.webrtc.media.video.VideoTrack +import dev.onvoid.webrtc.media.MediaStream as NativeMediaStream + +actual class PeerConnection actual constructor(rtcConfiguration: RtcConfiguration) { + + val native: RTCPeerConnection by lazy { + WebRtc.peerConnectionFactory.createPeerConnection( + rtcConfiguration.native, + NativePeerConnectionObserver() + ) ?: error("Creating PeerConnection failed") + } + + actual val localDescription: SessionDescription? + get() = native.localDescription?.asCommon() + + actual val remoteDescription: SessionDescription? + get() = native.remoteDescription?.asCommon() + + actual val signalingState: SignalingState + get() = native.signalingState.asCommon() + + actual val iceConnectionState: IceConnectionState + get() = native.iceConnectionState.asCommon() + + actual val connectionState: PeerConnectionState + get() = native.connectionState.asCommon() + + actual val iceGatheringState: IceGatheringState + get() = native.iceGatheringState.asCommon() + + private val _peerConnectionEvent = MutableSharedFlow( + extraBufferCapacity = FLOW_BUFFER_CAPACITY, + ) + internal actual val peerConnectionEvent: Flow = _peerConnectionEvent.asSharedFlow() + + private val localTracks = mutableMapOf() + private val remoteTracks = mutableMapOf() + + actual fun createDataChannel( + label: String, + id: Int, + ordered: Boolean, + maxRetransmitTimeMs: Int, + maxRetransmits: Int, + protocol: String, + negotiated: Boolean + ): DataChannel? { + val init = RTCDataChannelInit().apply { + this.id = id + this.ordered = ordered + this.maxRetransmits = maxRetransmits + this.protocol = protocol + this.negotiated = negotiated + } + return native.createDataChannel(label, init)?.let { DataChannel(it) } + } + + actual suspend fun createOffer(options: OfferAnswerOptions): SessionDescription { + return native.createOffer(RTCOfferOptions().apply { + this.iceRestart = options.iceRestart == true + }).asCommon() + } + + actual suspend fun createAnswer(options: OfferAnswerOptions): SessionDescription { + return native.createAnswer(RTCAnswerOptions().apply { + this.voiceActivityDetection = options.voiceActivityDetection == true + }).asCommon() + } + + actual suspend fun setLocalDescription(description: SessionDescription) { + return native.setLocalDescription(description.asNative()) + } + + actual suspend fun setRemoteDescription(description: SessionDescription) { + return native.setRemoteDescription(description.asNative()) + } + + actual fun setConfiguration(configuration: RtcConfiguration): Boolean { + native.configuration = configuration.native + return true + } + + actual fun addIceCandidate(candidate: IceCandidate): Boolean { + native.addIceCandidate(candidate.native) + return true + } + + actual fun removeIceCandidates(candidates: List): Boolean { + native.removeIceCandidates(candidates.map { it.native }.toTypedArray()) + return true + } + + actual fun getSenders(): List = native.senders.map { + RtpSender(it, localTracks[it.track?.id]) + } + + actual fun getReceivers(): List = native.receivers.map { + RtpReceiver(it, remoteTracks[it.track?.id]) + } + + actual fun getTransceivers(): List = + native.transceivers.map { + val senderTrack = localTracks[it.sender.track?.id] + val receiverTrack = remoteTracks[it.receiver.track?.id] + RtpTransceiver(it, senderTrack, receiverTrack) + } + + actual fun addTrack(track: MediaStreamTrack, vararg streams: MediaStream): RtpSender { + require(track is MediaStreamTrackImpl) + + val streamIds = streams.map { it.id } + localTracks[track.id] = track + return RtpSender(native.addTrack(track.native, streamIds), track) + } + + actual fun removeTrack(sender: RtpSender): Boolean { + localTracks.remove(sender.track?.id) + native.removeTrack(sender.native) + return true + } + + actual suspend fun getStats(): RtcStatsReport? { + return suspendCoroutine { cont -> + native.getStats { cont.resume(RtcStatsReport(it)) } + } + } + + actual fun close() { + remoteTracks.values.forEach(MediaStreamTrack::stop) + remoteTracks.clear() + native.close() + } + + internal inner class NativePeerConnectionObserver : PeerConnectionObserver { + override fun onSignalingChange(newState: RTCSignalingState) { + _peerConnectionEvent.tryEmit(SignalingStateChange(newState.asCommon())) + } + + override fun onIceConnectionChange(newState: RTCIceConnectionState) { + _peerConnectionEvent.tryEmit(IceConnectionStateChange(newState.asCommon())) + } + + override fun onStandardizedIceConnectionChange(newState: RTCIceConnectionState) { + _peerConnectionEvent.tryEmit(StandardizedIceConnectionChange(newState.asCommon())) + } + + override fun onConnectionChange(newState: RTCPeerConnectionState) { + _peerConnectionEvent.tryEmit(ConnectionStateChange(newState.asCommon())) + } + + override fun onIceConnectionReceivingChange(receiving: Boolean) {} + + override fun onIceGatheringChange(newState: RTCIceGatheringState) { + _peerConnectionEvent.tryEmit(IceGatheringStateChange(newState.asCommon())) + } + + override fun onIceCandidate(candidate: RTCIceCandidate) { + _peerConnectionEvent.tryEmit(NewIceCandidate(IceCandidate(candidate))) + } + + override fun onIceCandidatesRemoved(candidates: Array) { + _peerConnectionEvent.tryEmit(RemovedIceCandidates(candidates.map { IceCandidate(it) })) + } + + override fun onAddStream(nativeStream: NativeMediaStream) { + // this deprecated API should not longer be used + // https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/onaddstream + } + + override fun onRemoveStream(nativeStream: NativeMediaStream) { + // The removestream event has been removed from the WebRTC specification in favor of + // the existing removetrack event on the remote MediaStream and the corresponding + // MediaStream.onremovetrack event handler property of the remote MediaStream. + // The RTCPeerConnection API is now track-based, so having zero tracks in the remote + // stream is equivalent to the remote stream being removed and the old removestream event. + // https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/onremovestream + } + + override fun onDataChannel(dataChannel: RTCDataChannel) { + _peerConnectionEvent.tryEmit(NewDataChannel(DataChannel(dataChannel))) + } + + override fun onRenegotiationNeeded() { + _peerConnectionEvent.tryEmit(NegotiationNeeded) + } + + override fun onAddTrack(receiver: RTCRtpReceiver, mediaStreams: Array) { + val transceiver = native.transceivers.find { it.receiver.track.id == receiver.track.id } ?: return + if (mediaStreams.isEmpty()) return + + val audioTracks = mediaStreams + .flatMap { it.audioTracks?.toList() ?: emptyList() } + .map { remoteTracks.getOrPut(it.id) { RemoteAudioStreamTrack(it) } } + + val videoTracks = mediaStreams + .flatMap { it.videoTracks?.toList() ?: emptyList() } + .map { remoteTracks.getOrPut(it.id) { RemoteVideoStreamTrack(it) } } + + if(audioTracks.isEmpty() && videoTracks.isEmpty()) { + return + } + + val streams = mediaStreams.map { nativeStream -> + MediaStream( + native = nativeStream, + ).apply { + audioTracks.forEach(::addTrack) + videoTracks.forEach(::addTrack) + } + } + + val senderTrack = localTracks[transceiver.sender.track?.id] + val receiverTrack = remoteTracks[receiver.track?.id] + + val trackEvent = TrackEvent( + receiver = RtpReceiver(receiver, receiverTrack), + streams = streams, + track = receiverTrack, + transceiver = RtpTransceiver(transceiver, senderTrack, receiverTrack) + ) + + _peerConnectionEvent.tryEmit(Track(trackEvent)) + } + + override fun onRemoveTrack(receiver: RTCRtpReceiver) { + val track = remoteTracks.remove(receiver.track?.id) + _peerConnectionEvent.tryEmit(RemoveTrack(RtpReceiver(receiver, track))) + track?.stop() + } + + override fun onTrack(transceiver: RTCRtpTransceiver) { + transceiver.receiver?.let { receiver -> + receiver.track?.let { mediaTrack -> + val track = when (mediaTrack.kind) { + "audio" -> remoteTracks.getOrPut(mediaTrack.id) { RemoteAudioStreamTrack(mediaTrack as AudioTrack) } + "video" -> remoteTracks.getOrPut(mediaTrack.id) { RemoteVideoStreamTrack(mediaTrack as VideoTrack) } + else -> error("Unknown media stream track kind: $this") + } + + val senderTrack = localTracks[transceiver.sender.track?.id] + remoteTracks[track.id] = track + + val trackEvent = TrackEvent( + receiver = RtpReceiver(receiver, track), + streams = listOf( + MediaStream().apply { + remoteTracks.values.forEach(::addTrack) + } + ), + track = track, + transceiver = RtpTransceiver(transceiver, senderTrack, track) + ) + + _peerConnectionEvent.tryEmit(Track(trackEvent)) + } + } + } + + override fun onIceCandidateError(event: RTCPeerConnectionIceErrorEvent) { + super.onIceCandidateError(event) + } + } +} + +private fun RTCSignalingState.asCommon(): SignalingState { + return when (this) { + RTCSignalingState.STABLE -> SignalingState.Stable + RTCSignalingState.HAVE_LOCAL_OFFER -> SignalingState.HaveLocalOffer + RTCSignalingState.HAVE_LOCAL_PR_ANSWER -> SignalingState.HaveLocalPranswer + RTCSignalingState.HAVE_REMOTE_OFFER -> SignalingState.HaveRemoteOffer + RTCSignalingState.HAVE_REMOTE_PR_ANSWER -> SignalingState.HaveRemotePranswer + RTCSignalingState.CLOSED -> SignalingState.Closed + } +} + +private fun RTCIceConnectionState.asCommon(): IceConnectionState { + return when (this) { + RTCIceConnectionState.NEW -> IceConnectionState.New + RTCIceConnectionState.CHECKING -> IceConnectionState.Checking + RTCIceConnectionState.CONNECTED -> IceConnectionState.Connected + RTCIceConnectionState.COMPLETED -> IceConnectionState.Completed + RTCIceConnectionState.FAILED -> IceConnectionState.Failed + RTCIceConnectionState.DISCONNECTED -> IceConnectionState.Disconnected + RTCIceConnectionState.CLOSED -> IceConnectionState.Closed + } +} + +private fun RTCPeerConnectionState.asCommon(): PeerConnectionState { + return when (this) { + RTCPeerConnectionState.NEW -> PeerConnectionState.New + RTCPeerConnectionState.CONNECTING -> PeerConnectionState.Connecting + RTCPeerConnectionState.CONNECTED -> PeerConnectionState.Connected + RTCPeerConnectionState.DISCONNECTED -> PeerConnectionState.Disconnected + RTCPeerConnectionState.FAILED -> PeerConnectionState.Failed + RTCPeerConnectionState.CLOSED -> PeerConnectionState.Closed + } +} + +private fun RTCIceGatheringState.asCommon(): IceGatheringState { + return when (this) { + RTCIceGatheringState.NEW -> IceGatheringState.New + RTCIceGatheringState.GATHERING -> IceGatheringState.Gathering + RTCIceGatheringState.COMPLETE -> IceGatheringState.Complete + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/PeerConnectionExt.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/PeerConnectionExt.kt new file mode 100644 index 00000000..c624b0a1 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/PeerConnectionExt.kt @@ -0,0 +1,57 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.CreateSessionDescriptionObserver +import dev.onvoid.webrtc.RTCAnswerOptions +import dev.onvoid.webrtc.RTCOfferOptions +import dev.onvoid.webrtc.RTCPeerConnection +import dev.onvoid.webrtc.RTCSessionDescription +import dev.onvoid.webrtc.SetSessionDescriptionObserver +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +suspend fun RTCPeerConnection.createOffer(options: RTCOfferOptions = RTCOfferOptions()): RTCSessionDescription = suspendCancellableCoroutine { + createOffer(options, object : CreateSessionDescriptionObserver { + override fun onSuccess(description: RTCSessionDescription) { + it.resume(description) + } + + override fun onFailure(error: String) { + it.resumeWithException(RuntimeException(error)) + } + }) +} + +suspend fun RTCPeerConnection.setLocalDescription(description: RTCSessionDescription) = suspendCancellableCoroutine { + setLocalDescription(description, object : SetSessionDescriptionObserver { + override fun onSuccess() { + it.resume(Unit) + } + override fun onFailure(error: String) { + it.resumeWithException(RuntimeException(error)) + } + }) +} + +suspend fun RTCPeerConnection.createAnswer(options: RTCAnswerOptions = RTCAnswerOptions()): RTCSessionDescription = suspendCancellableCoroutine { + createAnswer(options, object : CreateSessionDescriptionObserver { + override fun onSuccess(description: RTCSessionDescription) { + it.resume(description) + } + + override fun onFailure(error: String) { + it.resumeWithException(RuntimeException(error)) + } + }) +} + +suspend fun RTCPeerConnection.setRemoteDescription(description: RTCSessionDescription) = suspendCancellableCoroutine { + setRemoteDescription(description, object : SetSessionDescriptionObserver { + override fun onSuccess() { + it.resume(Unit) + } + override fun onFailure(error: String) { + it.resumeWithException(RuntimeException(error)) + } + }) +} \ No newline at end of file diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RemoteAudioStreamTrack.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RemoteAudioStreamTrack.kt new file mode 100644 index 00000000..2217e459 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RemoteAudioStreamTrack.kt @@ -0,0 +1,7 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.media.audio.AudioTrack + +internal class RemoteAudioStreamTrack( + native: AudioTrack +) : MediaStreamTrackImpl(native), AudioStreamTrack diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RemoteVideoStreamTrack.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RemoteVideoStreamTrack.kt new file mode 100644 index 00000000..dfc96920 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RemoteVideoStreamTrack.kt @@ -0,0 +1,96 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.logging.Logging +import dev.onvoid.webrtc.media.video.VideoFrame +import dev.onvoid.webrtc.media.video.VideoTrack +import dev.onvoid.webrtc.media.video.VideoTrackSink +import java.util.Timer +import java.util.TimerTask +import java.util.concurrent.atomic.AtomicInteger + +internal class RemoteVideoStreamTrack( + native: VideoTrack, +) : RenderedVideoStreamTrack(native), VideoStreamTrack { + private val trackMuteDetector = TrackMuteDetector().apply { + addSink(this) + start() + } + + override suspend fun switchCamera(deviceId: String?) { + Logging.error("switchCamera is not supported for remote tracks") + } + + override fun onSetEnabled(enabled: Boolean) { + if (enabled) { + trackMuteDetector.start() + } else { + trackMuteDetector.stop() + } + } + + override fun onStop() { + removeSink(trackMuteDetector) + trackMuteDetector.dispose() + } + + /** + * Implements 'mute'/'unmute' events for remote video tracks through the [VideoSink] interface. + * + * The original idea is from React Native WebRTC + * https://github.com/react-native-webrtc/react-native-webrtc/blob/95cf638dfa/android/src/main/java/com/oney/WebRTCModule/VideoTrackAdapter.java#L69 + */ + private inner class TrackMuteDetector : VideoTrackSink { + private val timer: Timer = Timer("VideoTrackMutedTimer") + private var setMuteTask: TimerTask? = null + + @Volatile + private var disposed = false + private val frameCounter: AtomicInteger = AtomicInteger() + private var mutedState = false + + override fun onVideoFrame(frame: VideoFrame) { + frameCounter.addAndGet(1) + } + + fun start() { + if (disposed) return + + synchronized(this) { + setMuteTask?.cancel() + setMuteTask = object : TimerTask() { + private var lastFrameNumber: Int = frameCounter.get() + + override fun run() { + if (disposed) return + + val frameCount = frameCounter.get() + val isMuted = lastFrameNumber == frameCount + if (isMuted != mutedState) { + mutedState = isMuted + setMuted(isMuted) + } + lastFrameNumber = frameCounter.get() + } + } + timer.schedule(setMuteTask, INITIAL_MUTE_DELAY, MUTE_DELAY) + } + } + + fun stop() { + if (disposed) return + + synchronized(this) { + setMuteTask?.cancel() + setMuteTask = null + } + } + + fun dispose() { + stop() + disposed = true + } + } +} + +private const val INITIAL_MUTE_DELAY: Long = 3000 +private const val MUTE_DELAY: Long = 1500 diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RenderedVideoStreamTrack.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RenderedVideoStreamTrack.kt new file mode 100644 index 00000000..0ef7d0ee --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RenderedVideoStreamTrack.kt @@ -0,0 +1,19 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.media.video.VideoTrack +import dev.onvoid.webrtc.media.video.VideoTrackSink + + +internal abstract class RenderedVideoStreamTrack( + native: VideoTrack +) : MediaStreamTrackImpl(native), VideoStreamTrack { + override fun addSink(sink: VideoTrackSink) { + native as VideoTrack + native.addSink(sink) + } + + override fun removeSink(sink: VideoTrackSink) { + native as VideoTrack + native.removeSink(sink) + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcCertificatePem.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcCertificatePem.kt new file mode 100644 index 00000000..ea3a2cdb --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcCertificatePem.kt @@ -0,0 +1,126 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.logging.Logging +import org.bouncycastle.asn1.ASN1Encodable +import org.bouncycastle.asn1.DERSequence +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo +import org.bouncycastle.asn1.x509.BasicConstraints +import org.bouncycastle.asn1.x509.ExtendedKeyUsage +import org.bouncycastle.asn1.x509.Extension +import org.bouncycastle.asn1.x509.GeneralName.dNSName +import org.bouncycastle.asn1.x509.KeyPurposeId +import org.bouncycastle.asn1.x509.KeyUsage.digitalSignature +import org.bouncycastle.asn1.x509.KeyUsage.keyEncipherment +import org.bouncycastle.cert.X509CertificateHolder +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.jce.ECNamedCurveTable +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.bouncycastle.operator.ContentSigner +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder +import java.io.IOException +import java.math.BigInteger +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.Security +import java.util.Base64 +import java.util.Date +import javax.security.auth.x500.X500Principal +import dev.onvoid.webrtc.RTCCertificatePEM as NativeRtcCertificatePem + + +actual class RtcCertificatePem internal constructor(val native: NativeRtcCertificatePem) { + actual val privateKey: String + get() = native.privateKey + + actual val certificate: String + get() = native.certificate + + actual companion object { + + init { + Security.addProvider(BouncyCastleProvider()) + } + + actual suspend fun generateCertificate(keyType: KeyType, expires: Long): RtcCertificatePem { + val generator = when(keyType) { + KeyType.RSA -> { + KeyPairGenerator.getInstance("RSA", "BC").apply { + initialize(1024) + } + } + KeyType.ECDSA -> { + KeyPairGenerator.getInstance("ECDSA", "BC").apply { + val ecSpec = ECNamedCurveTable.getParameterSpec("secp256r1") + initialize(ecSpec) + } + } + } + + val pair = generator.generateKeyPair() + val cert = generateSelfSignedCertificate( + keyPair = pair, + expires = expires, + algorithm = when(keyType) { + KeyType.ECDSA -> "SHA256withECDSA" + KeyType.RSA -> "SHA256withRSA" + } + ) + return RtcCertificatePem( + NativeRtcCertificatePem( + pair.getPrivateKeyPkcs1Pem(), + cert.getCertificatePem(), + cert.notAfter.time, + ) + ) + } + + private fun generateSelfSignedCertificate(keyPair: KeyPair, expires: Long, algorithm: String): X509CertificateHolder { + Security.addProvider(BouncyCastleProvider()) + val subject = X500Principal("CN=WebRTC") + val notAfter = expires + 1000L * 3600L * 24 * 365 + val encodableAltNames = arrayOf(org.bouncycastle.asn1.x509.GeneralName(dNSName, "WebRTC")) + val purposes = arrayOf(KeyPurposeId.id_kp_serverAuth, KeyPurposeId.id_kp_clientAuth) + val certBuilder = JcaX509v3CertificateBuilder( + subject, + BigInteger.ONE, + Date(expires), + Date(notAfter), + subject, + keyPair.public, + ) + + try { + certBuilder.addExtension(Extension.basicConstraints, true, BasicConstraints(false)) + certBuilder.addExtension(Extension.keyUsage, true, org.bouncycastle.asn1.x509.KeyUsage(digitalSignature + keyEncipherment)) + certBuilder.addExtension(Extension.extendedKeyUsage, false, ExtendedKeyUsage(purposes)) + certBuilder.addExtension(Extension.subjectAlternativeName, false, DERSequence(encodableAltNames)) + val signer: ContentSigner = JcaContentSignerBuilder(algorithm).build(keyPair.private) + return certBuilder.build(signer) + } catch (e: Exception) { + Logging.error(e.message) + throw AssertionError(e.message) + } + } + } +} + +@Throws(IOException::class) +private fun X509CertificateHolder.getCertificatePem(): String { + val encoder: Base64.Encoder = Base64.getEncoder() + val result = StringBuilder() + result.append("-----BEGIN CERTIFICATE-----\n") + result.append(encoder.encodeToString(encoded)) + result.append("\n-----END CERTIFICATE-----\n") + return result.toString() +} + +@Throws(IOException::class) +private fun KeyPair.getPrivateKeyPkcs1Pem(): String { + val encoder: Base64.Encoder = Base64.getEncoder() + val privateKeyInfo = PrivateKeyInfo.getInstance(private.encoded) + val result = StringBuilder() + result.append("-----BEGIN RSA PRIVATE KEY-----\n") + result.append(encoder.encodeToString(privateKeyInfo.parsePrivateKey().toASN1Primitive().encoded)) + result.append("\n-----END RSA PRIVATE KEY-----\n") + return result.toString() +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcConfiguration.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcConfiguration.kt new file mode 100644 index 00000000..1da1d89b --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcConfiguration.kt @@ -0,0 +1,36 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.RTCBundlePolicy +import dev.onvoid.webrtc.RTCConfiguration +import dev.onvoid.webrtc.RTCRtcpMuxPolicy + +actual class RtcConfiguration actual constructor( + bundlePolicy: BundlePolicy, + certificates: List?, + iceCandidatePoolSize: Int, + iceServers: List, + iceTransportPolicy: IceTransportPolicy, + rtcpMuxPolicy: RtcpMuxPolicy, +) { + val native = RTCConfiguration().apply { + this.iceServers = iceServers.map { it.native } + this.bundlePolicy = bundlePolicy.asNative() + this.certificates = certificates?.map { it.native } + this.rtcpMuxPolicy = rtcpMuxPolicy.asNative() + } +} + +private fun RtcpMuxPolicy.asNative(): RTCRtcpMuxPolicy { + return when (this) { + RtcpMuxPolicy.Negotiate -> RTCRtcpMuxPolicy.NEGOTIATE + RtcpMuxPolicy.Require -> RTCRtcpMuxPolicy.REQUIRE + } +} + +private fun BundlePolicy.asNative(): RTCBundlePolicy { + return when (this) { + BundlePolicy.Balanced -> RTCBundlePolicy.BALANCED + BundlePolicy.MaxBundle -> RTCBundlePolicy.MAX_BUNDLE + BundlePolicy.MaxCompat -> RTCBundlePolicy.MAX_COMPAT + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcStats.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcStats.kt new file mode 100644 index 00000000..a5091b61 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcStats.kt @@ -0,0 +1,11 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.RTCStats + +actual class RtcStats internal constructor(val native: RTCStats) { + actual val timestampUs: Long = native.timestamp + actual val type: String = native.type.name + actual val id: String = native.id + actual val members: Map = native.members + actual override fun toString(): String = native.toString() +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcStatsReport.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcStatsReport.kt new file mode 100644 index 00000000..22e7908f --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtcStatsReport.kt @@ -0,0 +1,9 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.RTCStatsReport + +actual class RtcStatsReport(val native: RTCStatsReport) { + actual val timestampUs: Long = native.stats.values.firstOrNull()?.timestamp ?: -1 + actual val stats: Map = native.stats.mapValues { (_, v) -> RtcStats(v) } + actual override fun toString(): String = native.toString() +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpParameters.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpParameters.kt new file mode 100644 index 00000000..64c23bbe --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpParameters.kt @@ -0,0 +1,92 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.RTCRtcpParameters +import dev.onvoid.webrtc.RTCRtpCodecParameters +import dev.onvoid.webrtc.RTCRtpEncodingParameters +import dev.onvoid.webrtc.RTCRtpHeaderExtensionParameters +import dev.onvoid.webrtc.RTCRtpParameters + +actual class RtpParameters(val native: RTCRtpParameters) { + actual val codecs: List + get() = native.codecs.map { RtpCodecParameters(it) } + + actual val encodings: List + get() = emptyList() + + actual val headerExtension: List + get() = native.headerExtensions.map { HeaderExtension(it) } + + actual val rtcp: RtcpParameters + get() = RtcpParameters(native.rtcp) + + actual val transactionId: String + get() = "TODO" +} + +actual class RtpCodecParameters(val native: RTCRtpCodecParameters) { + actual val payloadType: Int + get() = native.payloadType + + actual val mimeType: String? + get() = native.mediaType.name + + actual val clockRate: Int? + get() = native.clockRate + + actual val numChannels: Int? + get() = native.channels + + actual val parameters: Map + get() = native.parameters +} + +actual class RtpEncodingParameters(val native: RTCRtpEncodingParameters) { + actual val rid: String? + get() = null + + actual val active: Boolean + get() = native.active + + actual val bitratePriority: Double + get() = 0.0 + + actual val networkPriority: Int + get() = 0 + + actual val maxBitrateBps: Int? + get() = native.maxBitrate + + actual val minBitrateBps: Int? + get() = native.minBitrate + + actual val maxFramerate: Int? + get() = native.maxFramerate.toInt() + + actual val numTemporalLayers: Int? + get() = null + + actual val scaleResolutionDownBy: Double? + get() = native.scaleResolutionDownBy + + actual val ssrc: Long? + get() = native.ssrc +} + +actual class HeaderExtension(val native: RTCRtpHeaderExtensionParameters) { + actual val uri: String + get() = native.uri + + actual val id: Int + get() = native.id + + actual val encrypted: Boolean + get() = native.encrypted +} + +actual class RtcpParameters(val native: RTCRtcpParameters) { + actual val cname: String + get() = native.cName + + actual val reducedSize: Boolean + get() = native.reducedSize +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpReceiver.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpReceiver.kt new file mode 100644 index 00000000..0648671f --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpReceiver.kt @@ -0,0 +1,11 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.RTCRtpReceiver + +actual class RtpReceiver(val native: RTCRtpReceiver, actual val track: MediaStreamTrack?) { + actual val id: String + get() = "TODO" + + actual val parameters: RtpParameters + get() = RtpParameters(native.parameters) +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpSender.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpSender.kt new file mode 100644 index 00000000..0d96ed72 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpSender.kt @@ -0,0 +1,34 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.RTCRtpSendParameters +import dev.onvoid.webrtc.RTCRtpSender + +actual class RtpSender internal constructor( + val native: RTCRtpSender, + track: MediaStreamTrack? +) { + actual val id: String + get() = native.track.id + + private var _track: MediaStreamTrack? = track + actual val track: MediaStreamTrack? get() = _track + + actual var parameters: RtpParameters + get() = RtpParameters(native.parameters) + set(value) { + native.parameters = RTCRtpSendParameters().apply { + this.transactionId = value.transactionId + this.codecs = value.codecs.map { + it.native + } + } + } + + actual val dtmf: DtmfSender? + get() = null + + actual suspend fun replaceTrack(track: MediaStreamTrack?) { + native.replaceTrack((track as? MediaStreamTrackImpl)?.native) + _track = track + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpTransceiver.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpTransceiver.kt new file mode 100644 index 00000000..09efef44 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/RtpTransceiver.kt @@ -0,0 +1,54 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.RTCRtpTransceiver +import dev.onvoid.webrtc.RTCRtpTransceiverDirection + +actual class RtpTransceiver( + val native: RTCRtpTransceiver, + private val senderTrack: MediaStreamTrack?, + private val receiverTrack: MediaStreamTrack?, +) { + + actual var direction: RtpTransceiverDirection + get() = native.direction.asCommon() + set(value) { + native.direction = value.asNative() + } + + actual val currentDirection: RtpTransceiverDirection? + get() = native.currentDirection?.asCommon() + + actual val mid: String + get() = native.mid + + actual val sender: RtpSender + get() = RtpSender(native.sender, senderTrack) + + actual val receiver: RtpReceiver + get() = RtpReceiver(native.receiver, receiverTrack) + + actual val stopped: Boolean + get() = native.stopped() + + actual fun stop() = native.stop() +} + +private fun RTCRtpTransceiverDirection.asCommon(): RtpTransceiverDirection { + return when (this) { + RTCRtpTransceiverDirection.SEND_RECV -> RtpTransceiverDirection.SendRecv + RTCRtpTransceiverDirection.SEND_ONLY -> RtpTransceiverDirection.SendOnly + RTCRtpTransceiverDirection.RECV_ONLY -> RtpTransceiverDirection.RecvOnly + RTCRtpTransceiverDirection.INACTIVE -> RtpTransceiverDirection.Inactive + RTCRtpTransceiverDirection.STOPPED -> RtpTransceiverDirection.Stopped + } +} + +internal fun RtpTransceiverDirection.asNative(): RTCRtpTransceiverDirection { + return when (this) { + RtpTransceiverDirection.SendRecv -> RTCRtpTransceiverDirection.SEND_RECV + RtpTransceiverDirection.SendOnly -> RTCRtpTransceiverDirection.SEND_ONLY + RtpTransceiverDirection.RecvOnly -> RTCRtpTransceiverDirection.RECV_ONLY + RtpTransceiverDirection.Inactive -> RTCRtpTransceiverDirection.INACTIVE + RtpTransceiverDirection.Stopped -> RTCRtpTransceiverDirection.INACTIVE + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/SessionDescriptionExt.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/SessionDescriptionExt.kt new file mode 100644 index 00000000..a33ef88a --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/SessionDescriptionExt.kt @@ -0,0 +1,30 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.RTCSdpType +import dev.onvoid.webrtc.RTCSessionDescription + +internal fun SessionDescription.asNative(): RTCSessionDescription { + return RTCSessionDescription(type.asNative(), sdp) +} + +private fun SessionDescriptionType.asNative(): RTCSdpType { + return when (this) { + SessionDescriptionType.Offer -> RTCSdpType.OFFER + SessionDescriptionType.Pranswer -> RTCSdpType.PR_ANSWER + SessionDescriptionType.Answer -> RTCSdpType.ANSWER + SessionDescriptionType.Rollback -> RTCSdpType.ROLLBACK + } +} + +internal fun RTCSessionDescription.asCommon(): SessionDescription { + return SessionDescription(sdpType.asCommon(), sdp) +} + +private fun RTCSdpType.asCommon(): SessionDescriptionType { + return when (this) { + RTCSdpType.OFFER -> SessionDescriptionType.Offer + RTCSdpType.PR_ANSWER -> SessionDescriptionType.Pranswer + RTCSdpType.ANSWER -> SessionDescriptionType.Answer + RTCSdpType.ROLLBACK -> SessionDescriptionType.Rollback + } +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/VideoStreamTrack.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/VideoStreamTrack.kt new file mode 100644 index 00000000..eef12e56 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/VideoStreamTrack.kt @@ -0,0 +1,10 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.media.video.VideoTrackSink + + +actual interface VideoStreamTrack : MediaStreamTrack { + actual suspend fun switchCamera(deviceId: String?) + fun addSink(sink: VideoTrackSink) + fun removeSink(sink: VideoTrackSink) +} diff --git a/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/WebRtc.kt b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/WebRtc.kt new file mode 100644 index 00000000..3a9d03a7 --- /dev/null +++ b/webrtc-kmp/src/jvmMain/kotlin/com/shepeliev/webrtckmp/WebRtc.kt @@ -0,0 +1,103 @@ +@file:JvmName("WebRtcKmpJVM") + +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.PeerConnectionFactory +import dev.onvoid.webrtc.logging.Logging +import dev.onvoid.webrtc.media.DeviceChangeListener +import dev.onvoid.webrtc.media.MediaDevices +import dev.onvoid.webrtc.media.audio.AudioDeviceModule +import dev.onvoid.webrtc.media.audio.AudioProcessing + +object WebRtc { + + private var _peerConnectionFactory: PeerConnectionFactory? = null + internal val peerConnectionFactory: PeerConnectionFactory + get() { + if (_peerConnectionFactory == null) initialize() + return checkNotNull(_peerConnectionFactory) + } + + private var _audioDeviceModule: AudioDeviceModule? = null + internal val audioDeviceModule: AudioDeviceModule + get() { + if (_audioDeviceModule == null) initialize() + return checkNotNull(_audioDeviceModule) + } + + private val builder by lazy { + WebRtcBuilder() + } + + fun configureBuilder(block: WebRtcBuilder.() -> Unit = {}) { + block(builder) + } + + private fun initialize() { + initLogging() + initializePeerConnectionFactory() + } + + private fun initializePeerConnectionFactory() { + with(builder) { + val audioModule = audioModule ?: AudioDeviceModule().apply { + MediaDevices.getDefaultAudioRenderDevice()?.let { + setPlayoutDevice(it) + initPlayout() + } + } + _audioDeviceModule = audioModule + _peerConnectionFactory = PeerConnectionFactory( + audioModule, + audioProcessing, + ) + } + } + + private fun initLogging() { + with(builder) { + loggingSeverity?.let { + Logging.addLogSink(it) { _, message -> + println(message) + } + } + } + } + + fun addDeviceChangeListener(listener: MediaDeviceListener) { + MediaDevicesImpl.addDeviceChangeListener(listener) + } + + fun removeDeviceChangeListener(listener: MediaDeviceListener) { + MediaDevicesImpl.removeDeviceChangeListener(listener) + } + + fun setAudioOutputDevice(device: MediaDeviceInfo) { + MediaDevices.getAudioRenderDevices().firstOrNull { + it.descriptor == device.deviceId + }?.let { + audioDeviceModule.setPlayoutDevice(it) + audioDeviceModule.initPlayout() + } + } + + fun getDefaultAudioOutput(): MediaDeviceInfo? { + return MediaDevices.getDefaultAudioRenderDevice()?.let { + MediaDeviceInfo( + deviceId = it.descriptor, + label = it.name, + kind = MediaDeviceKind.AudioOutput, + ) + } + } + + fun disposePeerConnectionFactory() { + peerConnectionFactory.dispose() + } +} + +class WebRtcBuilder( + var loggingSeverity: Logging.Severity? = null, + val audioModule: AudioDeviceModule? = null, + val audioProcessing: AudioProcessing? = null, +) \ No newline at end of file diff --git a/webrtc-kmp/src/jvmTest/kotlin/com/shepeliev/webrtckmp/IceServerTests.kt b/webrtc-kmp/src/jvmTest/kotlin/com/shepeliev/webrtckmp/IceServerTests.kt new file mode 100644 index 00000000..6ce177db --- /dev/null +++ b/webrtc-kmp/src/jvmTest/kotlin/com/shepeliev/webrtckmp/IceServerTests.kt @@ -0,0 +1,73 @@ +package com.shepeliev.webrtckmp + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.forEach +import kotlinx.coroutines.flow.onEach +import org.junit.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class IceServerTests { + + @Test + fun shouldGatherCandidates() = runTest { + val pc1 = PeerConnection( + rtcConfiguration = RtcConfiguration( + iceServers = listOf( + IceServer( + urls = listOf( + "stun:stun.l.google.com:19302", + "stun:stun1.l.google.com:19302", + "stun:stun2.l.google.com:19302", + "stun:stun3.l.google.com:19302", + "stun:stun4.l.google.com:19302", + ), + ) + ) + ) + ) + val pc2 = PeerConnection( + rtcConfiguration = RtcConfiguration( + iceServers = listOf( + IceServer( + urls = listOf( + "stun:stun.l.google.com:19302", + "stun:stun1.l.google.com:19302", + "stun:stun2.l.google.com:19302", + "stun:stun3.l.google.com:19302", + "stun:stun4.l.google.com:19302", + ), + ) + ) + ) + ) + + val mediaDevices = MediaDevices.getUserMedia { + audio() + } + + mediaDevices.tracks.forEach { + pc1.addTrack(it) + } + + val offer = pc1.createOffer(OfferAnswerOptions()) + pc1.setLocalDescription(offer) + + pc2.setRemoteDescription(offer) + + val answer = pc2.createAnswer(OfferAnswerOptions()) + pc2.setLocalDescription(answer) + + pc1.setRemoteDescription(answer) + + val event = pc1.peerConnectionEvent.first { it is PeerConnectionEvent.NewIceCandidate } + + assertTrue(event is PeerConnectionEvent.NewIceCandidate) + + pc1.close() + pc2.close() + } + +} \ No newline at end of file diff --git a/webrtc-kmp/src/jvmTest/kotlin/com/shepeliev/webrtckmp/MediaDevicesTests.kt b/webrtc-kmp/src/jvmTest/kotlin/com/shepeliev/webrtckmp/MediaDevicesTests.kt new file mode 100644 index 00000000..46806ffb --- /dev/null +++ b/webrtc-kmp/src/jvmTest/kotlin/com/shepeliev/webrtckmp/MediaDevicesTests.kt @@ -0,0 +1,29 @@ +package com.shepeliev.webrtckmp + +import dev.onvoid.webrtc.media.video.VideoFrame +import dev.onvoid.webrtc.media.video.VideoTrackSink +import kotlinx.coroutines.suspendCancellableCoroutine +import org.junit.Test +import kotlin.coroutines.resume +import kotlin.test.assertTrue + + +class MediaDevicesTests { + + @Test + fun enumerateDevices() = runTest { + val devices = MediaDevices.enumerateDevices() + assertTrue(devices.isNotEmpty()) + } + + @Test + fun getUserMedia() = runTest { + val mediaStream = MediaDevices.getUserMedia { + audio() + video() + } + + mediaStream.release() + } + +} \ No newline at end of file diff --git a/webrtc-kmp/src/jvmTest/kotlin/com/shepeliev/webrtckmp/PeerConnectionsTests.kt b/webrtc-kmp/src/jvmTest/kotlin/com/shepeliev/webrtckmp/PeerConnectionsTests.kt new file mode 100644 index 00000000..e75bc263 --- /dev/null +++ b/webrtc-kmp/src/jvmTest/kotlin/com/shepeliev/webrtckmp/PeerConnectionsTests.kt @@ -0,0 +1,44 @@ +package com.shepeliev.webrtckmp + +import org.junit.Test +import kotlin.test.assertEquals + +class PeerConnectionsTests { + + @Test + fun testCreatePeerConnection() = runTest { + val pc = PeerConnection() + + pc.close() + } + + @Test + fun createOffer() = runTest { + val pc = PeerConnection() + val offer: SessionDescription = pc.createOffer(OfferAnswerOptions()) + + assertEquals(SessionDescriptionType.Offer, offer.type) + + pc.close() + } + + @Test + fun createAnswer() = runTest { + val pc1 = PeerConnection() + val pc2 = PeerConnection() + + val offer = pc1.createOffer(OfferAnswerOptions()) + pc1.setLocalDescription(offer) + + pc2.setRemoteDescription(offer) + val answer = pc2.createAnswer(OfferAnswerOptions()) + pc2.setLocalDescription(answer) + pc1.setRemoteDescription(answer) + + assertEquals(SessionDescriptionType.Answer, answer.type) + + pc1.close() + pc2.close() + } + +} \ No newline at end of file diff --git a/webrtc-kmp/src/jvmTest/kotlin/com/shepeliev/webrtckmp/RtcCertificatePemTests.kt b/webrtc-kmp/src/jvmTest/kotlin/com/shepeliev/webrtckmp/RtcCertificatePemTests.kt new file mode 100644 index 00000000..66ebbd42 --- /dev/null +++ b/webrtc-kmp/src/jvmTest/kotlin/com/shepeliev/webrtckmp/RtcCertificatePemTests.kt @@ -0,0 +1,22 @@ +package com.shepeliev.webrtckmp + +import org.junit.Test +import kotlin.test.assertTrue + +class RtcCertificatePemTests { + + @Test + fun generateEcdsaPem() = runTest { + val cert = RtcCertificatePem.generateCertificate(KeyType.ECDSA, expires = System.currentTimeMillis() + 6000) + assertTrue(cert.certificate.isNotEmpty()) + assertTrue(cert.privateKey.isNotEmpty()) + } + + @Test + fun generateRsaPem() = runTest { + val cert = RtcCertificatePem.generateCertificate(KeyType.RSA, expires = System.currentTimeMillis() + 6000) + assertTrue(cert.certificate.isNotEmpty()) + assertTrue(cert.privateKey.isNotEmpty()) + } + +} \ No newline at end of file diff --git a/webrtc-kmp/src/jvmTest/kotlin/com/shepeliev/webrtckmp/TestUtils.kt b/webrtc-kmp/src/jvmTest/kotlin/com/shepeliev/webrtckmp/TestUtils.kt new file mode 100644 index 00000000..90009785 --- /dev/null +++ b/webrtc-kmp/src/jvmTest/kotlin/com/shepeliev/webrtckmp/TestUtils.kt @@ -0,0 +1,17 @@ +package com.shepeliev.webrtckmp + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout + +actual fun runTest( + timeout: Long, + block: suspend CoroutineScope.() -> Unit +) { + runBlocking { + withTimeout(timeout) { + coroutineScope { block() } + } + } +} diff --git a/webrtc-kmp/webrtc_kmp.podspec b/webrtc-kmp/webrtc_kmp.podspec index 3510487f..3bb2d221 100644 --- a/webrtc-kmp/webrtc_kmp.podspec +++ b/webrtc-kmp/webrtc_kmp.podspec @@ -11,6 +11,17 @@ Pod::Spec.new do |spec| spec.ios.deployment_target = '10.0' spec.dependency 'WebRTC-SDK', '114.5735.02' + if !Dir.exist?('build/cocoapods/framework/webrtc_kmp.framework') || Dir.empty?('build/cocoapods/framework/webrtc_kmp.framework') + raise " + + Kotlin framework 'webrtc_kmp' doesn't exist yet, so a proper Xcode project can't be generated. + 'pod install' should be executed after running ':generateDummyFramework' Gradle task: + + ./gradlew :webrtc-kmp:generateDummyFramework + + Alternatively, proper pod installation is performed during Gradle sync in the IDE (if Podfile location is set)" + end + spec.pod_target_xcconfig = { 'KOTLIN_PROJECT_PATH' => ':webrtc-kmp', 'PRODUCT_MODULE_NAME' => 'webrtc_kmp',