diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..04cae6d6 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +FISHJAM_ID= +FISHJAM_SDK_API_URL= diff --git a/.github/workflows/mobile_eas_build.yaml b/.github/workflows/mobile_eas_build.yaml index 4a6c40b1..4bb5ddb2 100644 --- a/.github/workflows/mobile_eas_build.yaml +++ b/.github/workflows/mobile_eas_build.yaml @@ -33,7 +33,7 @@ jobs: run: corepack enable - name: 📦 Install dependencies - run: yarn && yarn build + run: yarn && yarn build:non-native - name: 🚀 Build app run: eas build --non-interactive --platform=all --profile production diff --git a/.github/workflows/node.yaml b/.github/workflows/node.yaml index 47a418c7..d133783f 100644 --- a/.github/workflows/node.yaml +++ b/.github/workflows/node.yaml @@ -34,16 +34,16 @@ jobs: run: yarn --immutable - name: Build 📦 - run: yarn build + run: yarn build:non-native - name: Test 🧑‍🔬 - run: yarn test:unit + run: yarn test:unit:non-native - name: Check formatting 🎨 - run: yarn format:check + run: yarn format:check:non-native - name: Run linter 👮 - run: yarn lint:check + run: yarn lint:check:non-native - name: Run typecheck 🚓 run: yarn tsc @@ -53,3 +53,128 @@ jobs: - name: Spellcheck run: yarn spellcheck + + android: + name: Android client and example + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Use Node 🛎️ + uses: actions/setup-node@v4 + with: + node-version: 24.4.1 + cache: "npm" + + - name: Use corepack + run: corepack enable + + - name: Install dependencies ⬇️ + run: yarn --immutable + + - name: Use Java 17 ☕ + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Use Gradle 🐘 + uses: gradle/actions/setup-gradle@v4 + + - name: Install Rust Android targets 🦀 + run: rustup target add aarch64-linux-android x86_64-linux-android + + - name: Install cargo-ndk 🦀 + run: cargo install cargo-ndk --version 4.1.2 --locked + + # The example reads FISHJAM_ID and FISHJAM_SDK_API_URL from .env at build time. + - name: Write placeholder .env 🔑 + run: printf 'FISHJAM_ID=https://example.com/api/v1/connect/ci\nFISHJAM_SDK_API_URL=https://example.com/room-manager\n' > .env + + - name: Build 📦 + run: | + yarn workspace @fishjam-cloud/android-client build + yarn workspace @fishjam-example/android-client build + + - name: Check formatting 🎨 + run: | + yarn workspace @fishjam-cloud/android-client format:check + yarn workspace @fishjam-example/android-client format:check + + - name: Run linter 👮 + run: | + yarn workspace @fishjam-cloud/android-client lint:check + yarn workspace @fishjam-example/android-client lint:check + + - name: Enable KVM for the emulator + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Test 🧑‍🔬 + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + arch: x86_64 + target: google_apis + script: yarn workspace @fishjam-cloud/android-client test + + ios: + name: iOS client and example + runs-on: macos-latest + timeout-minutes: 60 + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Use Node 🛎️ + uses: actions/setup-node@v4 + with: + node-version: 24.4.1 + cache: "npm" + + - name: Use corepack + run: corepack enable + + - name: Install dependencies ⬇️ + run: yarn --immutable + + - name: Use latest Xcode 🔨 + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Install xcodegen, SwiftLint and protoc 🍺 + run: brew install xcodegen swiftlint protobuf swift-protobuf + + - name: Install Rust iOS targets 🦀 + run: rustup target add aarch64-apple-ios aarch64-apple-ios-sim + + # The example reads FISHJAM_ID and FISHJAM_SDK_API_URL from .env at build time. + - name: Write placeholder .env 🔑 + run: printf 'FISHJAM_ID=https://example.com/api/v1/connect/ci\nFISHJAM_SDK_API_URL=https://example.com/room-manager\n' > .env + + - name: Build 📦 + run: | + yarn workspace @fishjam-cloud/ios-client build + yarn workspace @fishjam-example/ios-client build + + - name: Test 🧑‍🔬 + run: yarn workspace @fishjam-cloud/ios-client test + + - name: Check formatting 🎨 + run: | + yarn workspace @fishjam-cloud/ios-client format:check + yarn workspace @fishjam-example/ios-client format:check + + - name: Run linter 👮 + run: | + yarn workspace @fishjam-cloud/ios-client lint:check + yarn workspace @fishjam-example/ios-client lint:check diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a65ecabf..fd66ae19 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -19,7 +19,7 @@ jobs: registry-url: "https://registry.npmjs.org" - run: corepack enable - run: yarn - - run: yarn build + - run: yarn build:non-native - run: yarn pack --out package.tgz working-directory: packages/webrtc-client - run: npm publish package.tgz --provenance --access public ${{ github.event.release.prerelease && '--tag rc' || '' }} @@ -41,7 +41,7 @@ jobs: registry-url: "https://registry.npmjs.org" - run: corepack enable - run: yarn - - run: yarn build + - run: yarn build:non-native - run: yarn pack --out package.tgz working-directory: packages/ts-client - run: npm publish package.tgz --provenance --access public ${{ github.event.release.prerelease && '--tag rc' || '' }} @@ -63,7 +63,7 @@ jobs: registry-url: "https://registry.npmjs.org" - run: corepack enable - run: yarn - - run: yarn build + - run: yarn build:non-native - run: yarn pack --out package.tgz working-directory: packages/react-client - run: npm publish package.tgz --provenance --access public ${{ github.event.release.prerelease && '--tag rc' || '' }} @@ -86,7 +86,7 @@ jobs: - run: corepack enable - run: yarn - run: yarn workspace @fishjam-cloud/react-native-client run check:webrtc-published - - run: yarn build + - run: yarn build:non-native - run: yarn pack --out package.tgz working-directory: packages/mobile-client - run: npm publish package.tgz --provenance --access public ${{ github.event.release.prerelease && '--tag rc' || '' }} @@ -132,7 +132,7 @@ jobs: - run: corepack enable - run: yarn - run: yarn workspace @fishjam-cloud/react-native-custom-video-source run check:webrtc-published - - run: yarn build + - run: yarn build:non-native - run: yarn pack --out package.tgz working-directory: packages/react-native-custom-video-source - run: npm publish package.tgz --provenance --access public ${{ github.event.release.prerelease && '--tag rc' || '' }} @@ -158,7 +158,7 @@ jobs: - run: corepack enable - run: yarn - run: yarn workspace @fishjam-cloud/react-native-vision-camera-source run check:webrtc-published - - run: yarn build + - run: yarn build:non-native - run: yarn pack --out package.tgz working-directory: packages/react-native-vision-camera-source - run: npm publish package.tgz --provenance --access public ${{ github.event.release.prerelease && '--tag rc' || '' }} diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 81454775..bb3edb7e 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -36,10 +36,10 @@ jobs: run: yarn --immutable - name: Build 📦 - run: yarn build + run: yarn build:non-native - name: Run unit tests 🧪 - run: yarn test:unit + run: yarn test:unit:non-native - name: Install Playwright Browsers 🧭 run: npx playwright install --with-deps diff --git a/examples/android-client/.editorconfig b/examples/android-client/.editorconfig new file mode 100644 index 00000000..e38ee6f6 --- /dev/null +++ b/examples/android-client/.editorconfig @@ -0,0 +1,7 @@ +root = true + +[*.{kt,kts}] +indent_size = 4 +max_line_length = 120 +ktlint_code_style = android_studio +ktlint_function_naming_ignore_when_annotated_with = Composable diff --git a/examples/android-client/.gitignore b/examples/android-client/.gitignore new file mode 100644 index 00000000..0523cfe3 --- /dev/null +++ b/examples/android-client/.gitignore @@ -0,0 +1,8 @@ +.gradle/ +build/ +local.properties +*.iml +.idea/ +captures/ +.cxx/ +.kotlin/ diff --git a/examples/android-client/app/build.gradle.kts b/examples/android-client/app/build.gradle.kts new file mode 100644 index 00000000..c7210fa3 --- /dev/null +++ b/examples/android-client/app/build.gradle.kts @@ -0,0 +1,91 @@ +import java.io.File + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ktlint) +} + +fun loadDotEnv(file: File): Map { + if (!file.exists()) { + throw GradleException("Missing ${file.path}. Copy .env.example to .env.") + } + return file.readLines().mapNotNull { raw -> + val line = raw.substringBefore("#").trim() + if (line.isEmpty() || !line.contains("=")) { + return@mapNotNull null + } + val key = line.substringBefore("=").trim() + val value = line.substringAfter("=").trim().removeSurrounding("\"") + key to value + }.toMap() +} + +fun fishjamEnvValue(name: String): String { + val value = fishjamEnv[name]?.takeIf { it.isNotEmpty() } + ?: throw GradleException("$name is missing from .env") + val escaped = value.replace("\\", "\\\\").replace("\"", "\\\"") + return "\"$escaped\"" +} + +val fishjamEnv = loadDotEnv(rootProject.file("../../.env")) + +android { + namespace = "io.fishjam.example" + compileSdk = 35 + + defaultConfig { + applicationId = "io.fishjam.example" + minSdk = 24 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + buildConfigField("String", "FISHJAM_ID", fishjamEnvValue("FISHJAM_ID")) + buildConfigField( + "String", + "FISHJAM_SDK_API_URL", + fishjamEnvValue("FISHJAM_SDK_API_URL") + ) + } + + buildFeatures { + compose = true + buildConfig = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +ktlint { + android.set(true) + outputToConsole.set(true) + ignoreFailures.set(false) +} + +dependencies { + implementation(project(":client")) + + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.activity.compose) + implementation(libs.coroutines.android) + + androidTestImplementation(platform(libs.compose.bom)) + androidTestImplementation(libs.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.test.runner) + debugImplementation(libs.compose.ui.test.manifest) +} diff --git a/examples/android-client/app/src/main/AndroidManifest.xml b/examples/android-client/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..3962bfe6 --- /dev/null +++ b/examples/android-client/app/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + diff --git a/examples/android-client/app/src/main/java/io/fishjam/example/MainActivity.kt b/examples/android-client/app/src/main/java/io/fishjam/example/MainActivity.kt new file mode 100644 index 00000000..e95f7c26 --- /dev/null +++ b/examples/android-client/app/src/main/java/io/fishjam/example/MainActivity.kt @@ -0,0 +1,306 @@ +package io.fishjam.example + +import android.Manifest +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import io.fishjam.client.FishjamClient +import io.fishjam.client.ObjectFit +import io.fishjam.client.PeerStatus +import io.fishjam.client.VideoView +import java.net.HttpURLConnection +import java.net.URL +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject + +private const val FISHJAM_ID = BuildConfig.FISHJAM_ID +private const val ROOM_MANAGER_URL = BuildConfig.FISHJAM_SDK_API_URL +private val plainKeyboard = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false + ) + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + setContent { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + JoinScreen() + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun JoinScreen() { + val context = LocalContext.current + val client = remember { FishjamClient(context, FISHJAM_ID) } + DisposableEffect(client) { + onDispose { client.close() } + } + val scope = rememberCoroutineScope() + val peerStatus by client.peerStatus.collectAsState() + val room by client.roomState.collectAsState() + var roomName by rememberSaveable { mutableStateOf("testtest") } + var peerName by rememberSaveable { mutableStateOf("android") } + var token by rememberSaveable { mutableStateOf("") } + var notice by rememberSaveable { mutableStateOf("") } + var mirrored by rememberSaveable { mutableStateOf(false) } + var cover by rememberSaveable { mutableStateOf(true) } + val objectFit = if (cover) ObjectFit.Cover else ObjectFit.Contain + val focusManager = LocalFocusManager.current + val keyboard = LocalSoftwareKeyboardController.current + val cameraPermission = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) { + scope.launch { + notice = "" + try { + client.startCamera() + } catch (error: Exception) { + notice = error.message ?: "camera failed" + } + } + } else { + notice = "camera permission denied" + } + } + val microphonePermission = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) { + scope.launch { + notice = "" + try { + client.startMicrophone() + } catch (error: Exception) { + notice = error.message ?: "microphone failed" + } + } + } else { + notice = "microphone permission denied" + } + } + + Column( + modifier = + Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing) + .pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + val up = waitForUpOrCancellation(pass = PointerEventPass.Final) + if (up != null && !up.isConsumed) { + focusManager.clearFocus() + keyboard?.hide() + } + } + } + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text("Fishjam example", style = MaterialTheme.typography.headlineSmall) + OutlinedTextField( + value = roomName, + onValueChange = { roomName = it }, + label = { Text("Room name") }, + keyboardOptions = plainKeyboard, + modifier = Modifier.fillMaxWidth().testTag("room") + ) + OutlinedTextField( + value = peerName, + onValueChange = { peerName = it }, + label = { Text("Peer name") }, + keyboardOptions = plainKeyboard, + modifier = Modifier.fillMaxWidth().testTag("peer") + ) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { + scope.launch { + notice = "" + try { + token = fetchPeerToken(roomName.trim(), peerName.trim()) + } catch (error: Exception) { + notice = error.message ?: "token request failed" + } + } + }, + enabled = roomName.isNotBlank() && peerName.isNotBlank(), + modifier = Modifier.testTag("fetch") + ) { + Text("Fetch token") + } + } + OutlinedTextField( + value = token, + onValueChange = { token = it }, + label = { Text("Token") }, + keyboardOptions = plainKeyboard, + modifier = Modifier.fillMaxWidth().testTag("token") + ) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { + scope.launch { + notice = "" + try { + client.joinRoom(token.trim()) + } catch (error: Exception) { + notice = error.message ?: "join failed" + } + } + }, + enabled = token.isNotBlank() && peerStatus != PeerStatus.Connected, + modifier = Modifier.testTag("join") + ) { + Text("Join") + } + Button( + onClick = { + notice = "" + client.leaveRoom() + }, + enabled = peerStatus != PeerStatus.Idle, + modifier = Modifier.testTag("leave") + ) { + Text("Leave") + } + Button( + onClick = { cameraPermission.launch(Manifest.permission.CAMERA) }, + modifier = Modifier.testTag("camera") + ) { + Text("Camera") + } + Button( + onClick = { microphonePermission.launch(Manifest.permission.RECORD_AUDIO) }, + modifier = Modifier.testTag("microphone") + ) { + Text("Microphone") + } + Button( + onClick = { mirrored = !mirrored }, + modifier = Modifier.testTag("mirror") + ) { + Text(if (mirrored) "Mirror on" else "Mirror off") + } + Button( + onClick = { cover = false }, + modifier = Modifier.testTag("contain") + ) { + Text("Contain") + } + Button( + onClick = { cover = true }, + modifier = Modifier.testTag("cover") + ) { + Text("Cover") + } + } + room.localPeer?.cameraTrack?.videoTrack?.let { video -> + VideoView( + track = video, + modifier = Modifier.fillMaxWidth().height(200.dp).testTag("local-video"), + mirror = !mirrored, + objectFit = objectFit + ) + } + room.remotePeers.forEach { peer -> + val video = peer.cameraTrack?.videoTrack ?: return@forEach + VideoView( + track = video, + modifier = Modifier.fillMaxWidth().height(200.dp).testTag("remote-video"), + mirror = mirrored, + objectFit = objectFit + ) + } + Text( + text = notice.ifEmpty { peerStatus.name.lowercase() }, + modifier = Modifier.testTag("status") + ) + } +} + +internal suspend fun fetchPeerToken(roomName: String, peerName: String): String = withContext(Dispatchers.IO) { + val url = + Uri + .parse(ROOM_MANAGER_URL) + .buildUpon() + .appendQueryParameter("roomName", roomName) + .appendQueryParameter("peerName", peerName) + .appendQueryParameter("roomType", "conference") + .build() + .toString() + val connection = URL(url).openConnection() as HttpURLConnection + try { + connection.requestMethod = "GET" + val stream = if (connection.responseCode in 200..299) connection.inputStream else connection.errorStream + val body = stream.bufferedReader().readText() + if (connection.responseCode !in 200..299) { + error("token request failed: ${connection.responseCode} $body") + } + JSONObject(body).getString("peerToken") + } finally { + connection.disconnect() + } +} diff --git a/examples/android-client/build.gradle.kts b/examples/android-client/build.gradle.kts new file mode 100644 index 00000000..7fb86103 --- /dev/null +++ b/examples/android-client/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.protobuf) apply false + alias(libs.plugins.ktlint) apply false +} diff --git a/examples/android-client/gradle.properties b/examples/android-client/gradle.properties new file mode 100644 index 00000000..5f4cff37 --- /dev/null +++ b/examples/android-client/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/examples/android-client/gradle/wrapper/gradle-wrapper.jar b/examples/android-client/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..a4b76b95 Binary files /dev/null and b/examples/android-client/gradle/wrapper/gradle-wrapper.jar differ diff --git a/examples/android-client/gradle/wrapper/gradle-wrapper.properties b/examples/android-client/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..e2847c82 --- /dev/null +++ b/examples/android-client/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/android-client/gradlew b/examples/android-client/gradlew new file mode 100755 index 00000000..d95bf613 --- /dev/null +++ b/examples/android-client/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/android-client/package.json b/examples/android-client/package.json new file mode 100644 index 00000000..cc1d68e7 --- /dev/null +++ b/examples/android-client/package.json @@ -0,0 +1,14 @@ +{ + "name": "@fishjam-example/android-client", + "version": "0.31.0", + "private": true, + "license": "Apache-2.0", + "scripts": { + "build": "./gradlew :app:assembleDebug --console=plain", + "start": "./gradlew :app:installDebug --console=plain && adb shell am start -n io.fishjam.example/.MainActivity", + "format": "./gradlew :app:ktlintFormat --console=plain", + "format:check": "./gradlew :app:ktlintCheck --console=plain", + "lint": "./gradlew :app:ktlintCheck --console=plain", + "lint:check": "./gradlew :app:ktlintCheck --console=plain" + } +} diff --git a/examples/android-client/settings.gradle.kts b/examples/android-client/settings.gradle.kts new file mode 100644 index 00000000..2634e274 --- /dev/null +++ b/examples/android-client/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven { url = uri("https://www.jitpack.io") } + } + versionCatalogs { + create("libs") { + from(files("../../packages/android-client/gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "android-client-example" +include(":app") +include(":client") +project(":client").projectDir = file("../../packages/android-client/client") diff --git a/examples/ios-client/.gitignore b/examples/ios-client/.gitignore new file mode 100644 index 00000000..f7055894 --- /dev/null +++ b/examples/ios-client/.gitignore @@ -0,0 +1,6 @@ +build/ +DerivedData/ +FishjamExample/FishjamConfig.swift +*.xcodeproj/ +*.xcuserstate +xcuserdata/ diff --git a/examples/ios-client/.swiftlint.yml b/examples/ios-client/.swiftlint.yml new file mode 100644 index 00000000..bf519560 --- /dev/null +++ b/examples/ios-client/.swiftlint.yml @@ -0,0 +1,9 @@ +included: + - FishjamExample +excluded: + - build + - FishjamExample/FishjamConfig.swift +strict: true +line_length: + warning: 120 + error: 160 diff --git a/examples/ios-client/FishjamExample/FishjamExampleApp.swift b/examples/ios-client/FishjamExample/FishjamExampleApp.swift new file mode 100644 index 00000000..0fc14339 --- /dev/null +++ b/examples/ios-client/FishjamExample/FishjamExampleApp.swift @@ -0,0 +1,398 @@ +import AVFoundation +import FishjamClient +import SwiftUI + +private let fishjamID = FishjamConfig.fishjamId +private let roomManagerURL = FishjamConfig.sdkApiUrl + +@main +struct FishjamExampleApp: App { + var body: some Scene { + WindowGroup { + JoinView() + } + } +} + +struct JoinView: View { + @StateObject private var client = FishjamClient(fishjamId: fishjamID) + @State private var roomName = "testtest" + @State private var peerName = "ios" + @State private var token = "" + @State private var notice = "" + @State private var mirrored = false + @State private var cover = true + @FocusState private var focusedField: Field? + + var body: some View { + GeometryReader { geometry in + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text("Fishjam example") + .font(.title2) + TextField("Room name", text: $roomName) + .exampleTextField(focused: $focusedField, field: .room, identifier: "room") + TextField("Peer name", text: $peerName) + .exampleTextField(focused: $focusedField, field: .peer, identifier: "peer") + WrappingHStack(spacing: 8, views: [AnyView(fetchTokenButton)]) + TextField("Token", text: $token) + .exampleTextField(focused: $focusedField, field: .token, identifier: "token") + WrappingHStack(spacing: 8, views: actionButtons) + let objectFit: ObjectFit = cover ? .cover : .contain + if let video = client.roomState.localPeer?.cameraTrack?.videoTrack { + VideoView(track: video, mirror: !mirrored, objectFit: objectFit) + .frame(maxWidth: .infinity) + .frame(height: 200) + .background(Color.black) + .accessibilityIdentifier("local-video") + } + ForEach(client.roomState.remotePeers, id: \.id) { peer in + if let video = peer.cameraTrack?.videoTrack { + VideoView(track: video, mirror: mirrored, objectFit: objectFit) + .frame(maxWidth: .infinity) + .frame(height: 200) + .background(Color.black) + .accessibilityIdentifier("remote-video") + } + } + Text(notice.isEmpty ? client.peerStatus.rawValue : notice) + .accessibilityIdentifier("status") + } + .padding(16) + .frame(maxWidth: .infinity, minHeight: geometry.size.height, alignment: .topLeading) + .background { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { focusedField = nil } + } + } + } + } + + private var fetchTokenButton: some View { + prominentButton( + "Fetch token", + identifier: "fetch", + disabled: roomName.isEmpty || peerName.isEmpty + ) { + notice = "" + Task { + do { + token = try await fetchPeerToken(roomName: roomName, peerName: peerName) + } catch { + notice = error.localizedDescription + } + } + } + } + + private var actionButtons: [AnyView] { + [ + AnyView(joinButton), + AnyView(leaveButton), + AnyView(cameraButton), + AnyView(microphoneButton), + AnyView(mirrorButton), + AnyView(containButton), + AnyView(coverButton) + ] + } + + private var joinButton: some View { + prominentButton( + "Join", + identifier: "join", + disabled: token.isEmpty || client.peerStatus == .connected + ) { + notice = "" + let peerToken = token.trimmingCharacters(in: .whitespacesAndNewlines) + Task { + do { + try await client.joinRoom(peerToken: peerToken) + } catch is CancellationError { + return + } catch { + notice = error.localizedDescription + } + } + } + } + + private var leaveButton: some View { + Button("Leave") { + notice = "" + client.leaveRoom() + } + .buttonStyle(.bordered) + .disabled(client.peerStatus == .idle) + .accessibilityIdentifier("leave") + } + + private var cameraButton: some View { + prominentButton("Camera", identifier: "camera") { + notice = "" + Task { + await startCamera(client: client, notice: $notice) + } + } + } + + private var microphoneButton: some View { + prominentButton("Microphone", identifier: "microphone") { + notice = "" + Task { + await startMicrophone(client: client, notice: $notice) + } + } + } + + private var mirrorButton: some View { + prominentButton(mirrored ? "Mirror on" : "Mirror off", identifier: "mirror") { + mirrored.toggle() + } + } + + private var containButton: some View { + prominentButton("Contain", identifier: "contain") { + cover = false + } + } + + private var coverButton: some View { + prominentButton("Cover", identifier: "cover") { + cover = true + } + } + + private func prominentButton( + _ title: String, + identifier: String, + disabled isDisabled: Bool = false, + action: @escaping () -> Void + ) -> some View { + Button(title, action: action) + .buttonStyle(.borderedProminent) + .disabled(isDisabled) + .accessibilityIdentifier(identifier) + } +} + +private struct WrappingHStack: View { + let spacing: CGFloat + let views: [AnyView] + + @State private var maxWidth: CGFloat = 0 + @State private var sizes: [Int: CGSize] = [:] + + var body: some View { + let frames = currentFrames + framed(buttonStack(frames: frames), height: frames.map(\.maxY).max()) + .background(widthProbe) + .onPreferenceChange(WidthKey.self, perform: updateWidth) + .onPreferenceChange(ViewSizeKey.self, perform: updateSizes) + } + + private func buttonStack(frames: [CGRect]) -> some View { + ZStack(alignment: .topLeading) { + ForEach(Array(views.indices), id: \.self) { index in + HStack(spacing: 0) { + views[index] + } + .fixedSize() + .background(sizeProbe(index: index)) + .offset(originOffset(frames: frames, index: index)) + } + } + .accessibilityElement(children: .contain) + .opacity(frames.isEmpty ? 0 : 1) + } + + @ViewBuilder + private func framed(_ content: Content, height: CGFloat?) -> some View { + if let height = height { + content.frame( + maxWidth: .infinity, + minHeight: height, + maxHeight: height, + alignment: .topLeading + ) + } else { + content.frame(maxWidth: .infinity, alignment: .topLeading) + } + } + + private var currentFrames: [CGRect] { + guard isReady else { + return [] + } + return wrappingFrames( + sizes: sizes, + count: views.count, + maxWidth: maxWidth, + spacing: spacing + ) + } + + private var isReady: Bool { + guard maxWidth > 0, sizes.count == views.count else { + return false + } + return sizes.values.allSatisfy { size in + size.width > 0 && size.height > 0 + } + } + + private func originOffset(frames: [CGRect], index: Int) -> CGSize { + guard frames.indices.contains(index) else { + return .zero + } + return CGSize(width: frames[index].minX, height: frames[index].minY) + } + + private func sizeProbe(index: Int) -> some View { + GeometryReader { proxy in + let size = CGSize( + width: proxy.size.width.rounded(), + height: proxy.size.height.rounded() + ) + Color.clear.preference(key: ViewSizeKey.self, value: [index: size]) + } + } + + private var widthProbe: some View { + GeometryReader { proxy in + Color.clear.preference(key: WidthKey.self, value: proxy.size.width.rounded()) + } + } + + private func updateWidth(_ width: CGFloat) { + let rounded = width.rounded() + guard rounded != maxWidth else { + return + } + maxWidth = rounded + } + + private func updateSizes(_ next: [Int: CGSize]) { + guard next != sizes else { + return + } + sizes = next + } + + private func wrappingFrames( + sizes: [Int: CGSize], + count: Int, + maxWidth: CGFloat, + spacing: CGFloat + ) -> [CGRect] { + var frames: [CGRect] = [] + var originX: CGFloat = 0 + var originY: CGFloat = 0 + var rowHeight: CGFloat = 0 + for index in 0.. 0 && originX + size.width > maxWidth + if wraps { + originX = 0 + originY += rowHeight + spacing + rowHeight = 0 + } + let origin = CGPoint(x: originX, y: originY) + frames.append(CGRect(origin: origin, size: size)) + originX += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + return frames + } +} + +private struct WidthKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +private struct ViewSizeKey: PreferenceKey { + static var defaultValue: [Int: CGSize] = [:] + + static func reduce(value: inout [Int: CGSize], nextValue: () -> [Int: CGSize]) { + value.merge(nextValue(), uniquingKeysWith: { _, new in new }) + } +} + +private enum Field { + case room + case peer + case token +} + +private extension View { + func exampleTextField( + focused: FocusState.Binding, + field: Field, + identifier: String + ) -> some View { + textFieldStyle(.roundedBorder) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused(focused, equals: field) + .accessibilityIdentifier(identifier) + } +} + +private func startCamera(client: FishjamClient, notice: Binding) async { + let granted = await AVCaptureDevice.requestAccess(for: .video) + guard granted else { + notice.wrappedValue = "camera permission denied" + return + } + do { + try await client.startCamera() + } catch { + notice.wrappedValue = error.localizedDescription + } +} + +private func startMicrophone(client: FishjamClient, notice: Binding) async { + let granted = await requestMicrophoneAccess() + guard granted else { + notice.wrappedValue = "microphone permission denied" + return + } + do { + try await client.startMicrophone() + } catch { + notice.wrappedValue = error.localizedDescription + } +} + +private func requestMicrophoneAccess() async -> Bool { + await withCheckedContinuation { continuation in + AVAudioSession.sharedInstance().requestRecordPermission { granted in + continuation.resume(returning: granted) + } + } +} + +func fetchPeerToken(roomName: String, peerName: String) async throws -> String { + var components = URLComponents(string: roomManagerURL) + components?.queryItems = [ + URLQueryItem(name: "roomName", value: roomName), + URLQueryItem(name: "peerName", value: peerName), + URLQueryItem(name: "roomType", value: "conference") + ] + guard let url = components?.url else { + throw URLError(.badURL) + } + let (data, response) = try await URLSession.shared.data(from: url) + guard let http = response as? HTTPURLResponse, http.statusCode == 200, + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let peerToken = json["peerToken"] as? String + else { + throw URLError(.badServerResponse) + } + return peerToken +} diff --git a/examples/ios-client/package.json b/examples/ios-client/package.json new file mode 100644 index 00000000..6fbf6287 --- /dev/null +++ b/examples/ios-client/package.json @@ -0,0 +1,15 @@ +{ + "name": "@fishjam-example/ios-client", + "version": "0.31.0", + "private": true, + "license": "Apache-2.0", + "scripts": { + "prepare:project": "bash ./scripts/generate-config.sh && yarn workspace @fishjam-cloud/ios-client prepare:project && xcodegen generate --quiet", + "build": "yarn prepare:project && xcodebuild build -project FishjamExample.xcodeproj -scheme FishjamExample -destination 'generic/platform=iOS Simulator' -derivedDataPath build/DerivedData -quiet", + "start": "yarn build && xcrun simctl install booted build/DerivedData/Build/Products/Debug-iphonesimulator/FishjamExample.app && xcrun simctl launch booted io.fishjam.example", + "format": "swiftlint --fix --quiet && swiftlint --quiet", + "format:check": "swiftlint --strict --quiet", + "lint": "swiftlint --strict --quiet", + "lint:check": "swiftlint --strict --quiet" + } +} diff --git a/examples/ios-client/project.yml b/examples/ios-client/project.yml new file mode 100644 index 00000000..a289fac2 --- /dev/null +++ b/examples/ios-client/project.yml @@ -0,0 +1,46 @@ +name: FishjamExample +options: + bundleIdPrefix: io.fishjam + deploymentTarget: + iOS: "15.0" + createIntermediateGroups: true +settings: + SWIFT_VERSION: "5.0" + SWIFT_STRICT_CONCURRENCY: minimal + IPHONEOS_DEPLOYMENT_TARGET: "15.0" + CODE_SIGNING_ALLOWED: NO + "CODE_SIGNING_ALLOWED[sdk=iphoneos*]": YES + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: 37994Y34H9 + EXCLUDED_ARCHS[sdk=iphonesimulator*]: x86_64 +projectReferences: + FishjamClient: + path: ../../packages/ios-client/FishjamClient.xcodeproj +targets: + FishjamExample: + type: application + platform: iOS + sources: + - path: FishjamExample + dependencies: + - target: FishjamClient/FishjamClient + embed: true + - framework: ../../packages/ios-client/Vendor/WebRTC.xcframework + embed: true + settings: + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_UILaunchScreen_Generation: YES + INFOPLIST_KEY_CFBundleDisplayName: Fishjam Example + INFOPLIST_KEY_NSCameraUsageDescription: Fishjam uses the camera to send video. + INFOPLIST_KEY_NSMicrophoneUsageDescription: Fishjam uses the microphone to send audio. + PRODUCT_BUNDLE_IDENTIFIER: io.fishjam.example + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: 37994Y34H9 + TARGETED_DEVICE_FAMILY: "1,2" +schemes: + FishjamExample: + build: + targets: + FishjamExample: all + run: + config: Debug diff --git a/examples/ios-client/scripts/generate-config.sh b/examples/ios-client/scripts/generate-config.sh new file mode 100644 index 00000000..aa70cce9 --- /dev/null +++ b/examples/ios-client/scripts/generate-config.sh @@ -0,0 +1,45 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="$(cd "$ROOT/../.." && pwd)/.env" +OUT="$ROOT/FishjamExample/FishjamConfig.swift" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing $ENV_FILE. Copy .env.example to .env." >&2 + exit 1 +fi + +fishjam_id="" +sdk_api_url="" +while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%%#*}" + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [[ -z "$line" ]] && continue + key="${line%%=*}" + value="${line#*=}" + value="${value%\"}" + value="${value#\"}" + case "$key" in + FISHJAM_ID) fishjam_id="$value" ;; + FISHJAM_SDK_API_URL) sdk_api_url="$value" ;; + esac +done < "$ENV_FILE" + +if [[ -z "$fishjam_id" || -z "$sdk_api_url" ]]; then + echo "Set FISHJAM_ID and FISHJAM_SDK_API_URL in $ENV_FILE" >&2 + exit 1 +fi + +escape() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +cat > "$OUT" <("buildFishjamCore") { + group = "build" + description = "Build libfishjam_core.a for every Android ABI with cargo-ndk." + workingDir = fishjamCoreDir + val sdkDir = file(localProperty("sdk.dir") ?: System.getenv("ANDROID_HOME") ?: error("ANDROID_HOME is not set")) + environment("ANDROID_HOME", sdkDir.absolutePath) + environment("ANDROID_NDK_HOME", sdkDir.resolve("ndk/$ndkVersionName").absolutePath) + environment("CARGO_TARGET_DIR", fishjamCoreDir.resolve("target").absolutePath) + val cargoBin = File(System.getProperty("user.home"), ".cargo/bin") + environment("PATH", cargoBin.absolutePath + File.pathSeparator + System.getenv("PATH")) + val targets = fishjamCoreAbis.keys.flatMap { listOf("-t", it) } + commandLine( + listOf(cargoBin.resolve("cargo").absolutePath, "ndk") + targets + listOf("-P", "24", "build", "--release") + ) + inputs.dir(fishjamCoreDir.resolve("src")) + inputs.dir(fishjamCoreDir.resolve("proto")) + inputs.file(fishjamCoreDir.resolve("Cargo.toml")) + inputs.file(fishjamCoreDir.resolve("Cargo.lock")) + outputs.files( + fishjamCoreAbis.values.map { fishjamCoreDir.resolve("target/$it/release/libfishjam_core.a") } + ) +} + +tasks.configureEach { + if (name.startsWith("configureCMake") || name.startsWith("buildCMake")) { + dependsOn(buildFishjamCore) + } +} + +fun localProperty(name: String): String? { + val propertiesFile = rootProject.file("local.properties") + if (!propertiesFile.isFile) return null + val values = Properties() + propertiesFile.inputStream().use { values.load(it) } + return values.getProperty(name) +} diff --git a/packages/android-client/client/consumer-rules.pro b/packages/android-client/client/consumer-rules.pro new file mode 100644 index 00000000..3ff6f45f --- /dev/null +++ b/packages/android-client/client/consumer-rules.pro @@ -0,0 +1,2 @@ +-keep class io.fishjam.client.NativeCallResult { *; } +-keep class io.fishjam.client.NativeCore { *; } diff --git a/packages/android-client/client/src/main/AndroidManifest.xml b/packages/android-client/client/src/main/AndroidManifest.xml new file mode 100644 index 00000000..ab85033f --- /dev/null +++ b/packages/android-client/client/src/main/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/android-client/client/src/main/cpp/CMakeLists.txt b/packages/android-client/client/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000..03d37c38 --- /dev/null +++ b/packages/android-client/client/src/main/cpp/CMakeLists.txt @@ -0,0 +1,22 @@ +cmake_minimum_required(VERSION 3.22.1) +project(fishjam_jni) + +add_library(fishjam_jni SHARED fishjam_jni.cpp) + +if(ANDROID_ABI STREQUAL "arm64-v8a") + set(RUST_TARGET "aarch64-linux-android") +elseif(ANDROID_ABI STREQUAL "x86_64") + set(RUST_TARGET "x86_64-linux-android") +else() + message(FATAL_ERROR "Unsupported ABI ${ANDROID_ABI}") +endif() + +set(CORE_LIB "${FISHJAM_CORE_DIR}/target/${RUST_TARGET}/release/libfishjam_core.a") + +if(NOT EXISTS "${CORE_LIB}") + message(FATAL_ERROR "missing ${CORE_LIB}") +endif() + +target_include_directories(fishjam_jni PRIVATE "${FISHJAM_CORE_DIR}/include") +target_link_options(fishjam_jni PRIVATE "-Wl,-z,max-page-size=16384") +target_link_libraries(fishjam_jni "${CORE_LIB}" android log dl m) diff --git a/packages/android-client/client/src/main/cpp/fishjam_jni.cpp b/packages/android-client/client/src/main/cpp/fishjam_jni.cpp new file mode 100644 index 00000000..62ae25e9 --- /dev/null +++ b/packages/android-client/client/src/main/cpp/fishjam_jni.cpp @@ -0,0 +1,123 @@ +#include +#include +#include + +#include "fishjam.h" + +namespace { + +JavaVM *g_vm = nullptr; + +struct ByteArrayView { + JNIEnv *env; + jbyteArray array; + jbyte *data = nullptr; + jsize len = 0; + + ByteArrayView(JNIEnv *env, jbyteArray array) : env(env), array(array) { + if (array != nullptr) { + len = env->GetArrayLength(array); + if (len > 0) data = env->GetByteArrayElements(array, nullptr); + } + } + + ~ByteArrayView() { + if (data != nullptr) env->ReleaseByteArrayElements(array, data, JNI_ABORT); + } + + const uint8_t *bytes() const { return reinterpret_cast(data); } +}; + +struct NativeHandle { + FishjamClient *client = nullptr; + jobject listener = nullptr; + jmethodID on_event = nullptr; +}; + +void on_event(void *user_data, const uint8_t *event, size_t event_len) { + auto *handle = static_cast(user_data); + if (g_vm == nullptr || handle->listener == nullptr || handle->on_event == nullptr) return; + + JNIEnv *env = nullptr; + bool attached = false; + if (g_vm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) != JNI_OK) { + if (g_vm->AttachCurrentThread(&env, nullptr) != JNI_OK) return; + attached = true; + } + + jbyteArray payload = env->NewByteArray(static_cast(event_len)); + if (payload != nullptr && event_len > 0) { + env->SetByteArrayRegion(payload, 0, static_cast(event_len), reinterpret_cast(event)); + } + env->CallVoidMethod(handle->listener, handle->on_event, payload); + if (env->ExceptionCheck()) env->ExceptionClear(); + env->DeleteLocalRef(payload); + if (attached) g_vm->DetachCurrentThread(); +} + +} // namespace + +extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) { + g_vm = vm; + return JNI_VERSION_1_6; +} + +extern "C" JNIEXPORT jlong JNICALL +Java_io_fishjam_client_NativeCore_nativeCreate(JNIEnv *env, jclass, jbyteArray config, jobject events) { + if (events == nullptr) return 0; + + jobject listener = env->NewGlobalRef(events); + jclass clazz = env->GetObjectClass(events); + jmethodID method = env->GetMethodID(clazz, "onEvent", "([B)V"); + env->DeleteLocalRef(clazz); + if (listener == nullptr || method == nullptr) { + if (listener != nullptr) env->DeleteGlobalRef(listener); + return 0; + } + + auto *handle = new NativeHandle(); + handle->listener = listener; + handle->on_event = method; + + ByteArrayView view(env, config); + handle->client = fishjam_client_new(view.bytes(), static_cast(view.len), on_event, handle); + if (handle->client == nullptr) { + env->DeleteGlobalRef(listener); + delete handle; + return 0; + } + return reinterpret_cast(handle); +} + +extern "C" JNIEXPORT jobject JNICALL +Java_io_fishjam_client_NativeCore_nativeCall(JNIEnv *env, jclass, jlong jhandle, jbyteArray request) { + auto *handle = reinterpret_cast(jhandle); + FishjamBuf out; + memset(&out, 0, sizeof(out)); + + int32_t code; + { + ByteArrayView view(env, request); + code = fishjam_client_call(handle->client, view.bytes(), static_cast(view.len), &out); + } + + jsize out_len = out.data != nullptr ? static_cast(out.len) : 0; + jbyteArray payload = env->NewByteArray(out_len); + if (out_len > 0) { + env->SetByteArrayRegion(payload, 0, out_len, reinterpret_cast(out.data)); + } + fishjam_buf_free(out); + + jclass result_class = env->FindClass("io/fishjam/client/NativeCallResult"); + jmethodID ctor = env->GetMethodID(result_class, "", "(I[B)V"); + return env->NewObject(result_class, ctor, code, payload); +} + +extern "C" JNIEXPORT void JNICALL +Java_io_fishjam_client_NativeCore_nativeDestroy(JNIEnv *env, jclass, jlong jhandle) { + if (jhandle == 0) return; + auto *handle = reinterpret_cast(jhandle); + fishjam_client_free(handle->client); + if (handle->listener != nullptr) env->DeleteGlobalRef(handle->listener); + delete handle; +} diff --git a/packages/android-client/client/src/main/java/io/fishjam/client/FishjamClient.kt b/packages/android-client/client/src/main/java/io/fishjam/client/FishjamClient.kt new file mode 100644 index 00000000..77fc6f6e --- /dev/null +++ b/packages/android-client/client/src/main/java/io/fishjam/client/FishjamClient.kt @@ -0,0 +1,263 @@ +package io.fishjam.client + +import android.content.Context +import fishjam.sdk.SdkApi +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonObject + +class FishjamClient( + context: Context, + fishjamId: String, + debug: Boolean = false +) : AutoCloseable { + private val _peerStatus = MutableStateFlow(PeerStatus.Idle) + val peerStatus: StateFlow = _peerStatus.asStateFlow() + + private val _roomState = MutableStateFlow(RoomState.Empty) + val roomState: StateFlow = _roomState.asStateFlow() + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val closed = AtomicBoolean(false) + private val joinLock = Any() + private var joinResult: CompletableDeferred? = null + + private val session = + MediaSession( + context = context, + call = { request -> + runCatching { send(request) } + }, + onMediaChanged = ::refreshMedia + ) + + private val events = + NativeEvents { payload -> + val event = SdkApi.SdkEvent.parseFrom(payload) + scope.launch { + if (closed.get()) return@launch + dispatch(event) + } + } + + private val handle: Long = + NativeCore.nativeCreate( + SdkApi.ClientConfig + .newBuilder() + .setFishjamId(fishjamId) + .setDebug(debug) + .build() + .toByteArray(), + events + ) + + init { + check(handle != 0L) { "fishjam_client_new returned null" } + } + + suspend fun joinRoom(peerToken: String, peerMetadata: JsonObject = JsonObject(emptyMap())) { + val waiter = CompletableDeferred() + synchronized(joinLock) { joinResult = waiter } + try { + val response = + call( + SdkApi.SdkRequest + .newBuilder() + .setJoinRoom( + SdkApi.JoinRoom + .newBuilder() + .setPeerToken(peerToken) + .setPeerMetadataJson(peerMetadata.toString()) + ).build() + ) + if (response.code != 0) { + throw IllegalStateException(SdkApi.SdkResponse.parseFrom(response.payload).error.message) + } + waiter.await() + } finally { + synchronized(joinLock) { + if (joinResult === waiter) joinResult = null + } + } + } + + fun leaveRoom() { + synchronized(joinLock) { + joinResult?.cancel() + joinResult = null + } + if (handle == 0L || closed.get()) return + send( + SdkApi.SdkRequest + .newBuilder() + .setLeaveRoom(SdkApi.LeaveRoom.getDefaultInstance()) + .build() + ) + } + + private var cameraPublishStarted = false + private var microphonePublishStarted = false + + suspend fun startCamera() { + val track = session.startCamera() + showLocalCamera(track) + if (cameraPublishStarted) return + cameraPublishStarted = true + scope.launch { + publishWhileConnected(track, SdkApi.TrackKind.TRACK_KIND_VIDEO, SdkApi.TrackType.TRACK_TYPE_CAMERA) + } + } + + suspend fun startMicrophone() { + val track = session.startMicrophone() + if (microphonePublishStarted) return + microphonePublishStarted = true + scope.launch { + publishWhileConnected(track, SdkApi.TrackKind.TRACK_KIND_AUDIO, SdkApi.TrackType.TRACK_TYPE_MICROPHONE) + } + } + + private suspend fun publishWhileConnected( + track: org.webrtc.MediaStreamTrack, + kind: SdkApi.TrackKind, + type: SdkApi.TrackType + ) { + while (!closed.get()) { + peerStatus.first { status -> status == PeerStatus.Connected } + if (closed.get()) return + val trackId = publish(kind, type) + session.bind(trackId, track) + peerStatus.first { status -> status != PeerStatus.Connected } + } + } + + private var localCamera: org.webrtc.VideoTrack? = null + + private fun showLocalCamera(track: org.webrtc.VideoTrack) { + localCamera = track + _roomState.value = withLocalCamera(_roomState.value) + } + + private fun withLocalCamera(current: RoomState): RoomState { + val track = localCamera ?: return current + if (current.localPeer?.cameraTrack?.videoTrack != null) return current + val camera = + Track( + trackId = current.localPeer?.cameraTrack?.trackId.orEmpty(), + metadata = TrackMetadata(type = "camera", paused = false, displayName = null), + videoTrack = track, + audioTrack = null + ) + val otherTracks = current.localPeer?.tracks?.filter { it.metadata?.type != "camera" } ?: emptyList() + val local = + (current.localPeer ?: emptyPeer()).copy( + cameraTrack = camera, + tracks = listOf(camera) + otherTracks + ) + return current.copy(localPeer = local) + } + + private fun emptyPeer(): PeerWithTracks = PeerWithTracks( + id = "", + metadata = null, + tracks = emptyList(), + cameraTrack = null, + microphoneTrack = null, + screenShareVideoTrack = null, + screenShareAudioTrack = null, + customVideoTracks = emptyList(), + customAudioTracks = emptyList() + ) + + override fun close() { + if (!closed.compareAndSet(false, true)) return + synchronized(joinLock) { + joinResult?.cancel() + joinResult = null + } + session.close() + scope.cancel() + NativeCore.nativeDestroy(handle) + } + + private fun dispatch(event: SdkApi.SdkEvent) { + when (event.contentCase) { + SdkApi.SdkEvent.ContentCase.PEER_STATUS_CHANGED -> { + val update = peerStatusUpdate(event) ?: return + _peerStatus.value = update.peerStatus + val waiter = synchronized(joinLock) { joinResult } + when { + update.peerStatus == PeerStatus.Connected -> waiter?.complete(Unit) + else -> joinFailure(update)?.let { waiter?.completeExceptionally(it) } + } + } + SdkApi.SdkEvent.ContentCase.ROOM_STATE_CHANGED -> { + if (event.roomStateChanged.hasRoomState()) { + _roomState.value = + withLocalCamera( + roomStateOf(event.roomStateChanged.roomState).attach(session.mediaSnapshot()) + ) + } + } + else -> session.handle(event) + } + } + + private suspend fun publish(kind: SdkApi.TrackKind, type: SdkApi.TrackType): String { + if (peerStatus.value != PeerStatus.Connected) { + peerStatus.first { status -> + status == PeerStatus.Connected || status == PeerStatus.Error || status == PeerStatus.Idle + } + } + if (peerStatus.value != PeerStatus.Connected) { + throw IllegalStateException("Cannot add tracks before being accepted by the server") + } + val response = + call( + SdkApi.SdkRequest + .newBuilder() + .setAddTrack( + SdkApi.AddTrack + .newBuilder() + .setKind(kind) + .setMetadata( + SdkApi.TrackMetadata + .newBuilder() + .setType(type) + .setPaused(false) + ) + ).build() + ) + val parsed = SdkApi.SdkResponse.parseFrom(response.payload) + if (response.code != 0) throw IllegalStateException(parsed.error.message) + return parsed.addTrack.trackId + } + + private fun refreshMedia() { + scope.launch { + if (closed.get()) return@launch + _roomState.value = withLocalCamera(_roomState.value.attach(session.mediaSnapshot())) + } + } + + private fun send(request: SdkApi.SdkRequest) { + val response = call(request) + if (response.code != 0) { + throw IllegalStateException(SdkApi.SdkResponse.parseFrom(response.payload).error.message) + } + } + + private fun call(request: SdkApi.SdkRequest): NativeCallResult { + if (closed.get()) return NativeCallResult(0, ByteArray(0)) + return NativeCore.nativeCall(handle, request.toByteArray()) + } +} diff --git a/packages/android-client/client/src/main/java/io/fishjam/client/MediaSession.kt b/packages/android-client/client/src/main/java/io/fishjam/client/MediaSession.kt new file mode 100644 index 00000000..dc4ae524 --- /dev/null +++ b/packages/android-client/client/src/main/java/io/fishjam/client/MediaSession.kt @@ -0,0 +1,528 @@ +package io.fishjam.client + +import android.content.Context +import android.content.pm.PackageManager +import android.util.Log +import fishjam.sdk.SdkApi +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import org.webrtc.AudioSource +import org.webrtc.AudioTrack +import org.webrtc.Camera1Enumerator +import org.webrtc.Camera2Enumerator +import org.webrtc.CameraVideoCapturer +import org.webrtc.DefaultVideoDecoderFactory +import org.webrtc.DefaultVideoEncoderFactory +import org.webrtc.EglBase +import org.webrtc.IceCandidate +import org.webrtc.MediaConstraints +import org.webrtc.MediaStreamTrack +import org.webrtc.PeerConnection +import org.webrtc.PeerConnectionFactory +import org.webrtc.RtpParameters +import org.webrtc.RtpTransceiver +import org.webrtc.SdpObserver +import org.webrtc.SessionDescription +import org.webrtc.SurfaceTextureHelper +import org.webrtc.VideoCapturer +import org.webrtc.VideoSource +import org.webrtc.VideoTrack +import org.webrtc.audio.JavaAudioDeviceModule + +internal object SharedEgl { + val base: EglBase by lazy { EglBase.create() } +} + +internal class MediaSession( + context: Context, + private val call: (SdkApi.SdkRequest) -> Unit, + private val onMediaChanged: () -> Unit +) { + private val appContext = context.applicationContext + private val executor = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "fishjam-pc") } + private val media = ConcurrentHashMap() + private val fishjamIdByWebrtcId = ConcurrentHashMap() + private val midToTrackId = ConcurrentHashMap() + + private var factory: PeerConnectionFactory? = null + + @Volatile + private var connection: PeerConnection? = null + + @Volatile + private var lastConnectionState = "new" + + @Volatile + private var lastIceState = "new" + private var capturer: VideoCapturer? = null + private var videoSource: VideoSource? = null + private var surface: SurfaceTextureHelper? = null + private var localVideo: VideoTrack? = null + private var audioSource: AudioSource? = null + private var localAudio: AudioTrack? = null + + fun mediaSnapshot(): Map = HashMap(media) + + fun handle(event: SdkApi.SdkEvent) { + executor.execute { + when (event.contentCase) { + SdkApi.SdkEvent.ContentCase.CREATE_PEER_CONNECTION -> openPeerConnection(event.createPeerConnection) + SdkApi.SdkEvent.ContentCase.UPDATE_RECVONLY_TRANSCEIVERS -> + updateRecvonly(event.updateRecvonlyTransceivers) + SdkApi.SdkEvent.ContentCase.ADD_SENDONLY_TRANSCEIVER -> addSendonly(event.addSendonlyTransceiver) + SdkApi.SdkEvent.ContentCase.REMOVE_SEND_TRACK -> removeSend(event.removeSendTrack.trackId) + SdkApi.SdkEvent.ContentCase.CREATE_OFFER -> createOffer() + SdkApi.SdkEvent.ContentCase.SET_LOCAL_DESCRIPTION -> setLocal(event.setLocalDescription) + SdkApi.SdkEvent.ContentCase.SET_REMOTE_DESCRIPTION -> setRemote(event.setRemoteDescription) + SdkApi.SdkEvent.ContentCase.ADD_ICE_CANDIDATE -> addRemoteCandidate(event.addIceCandidate) + SdkApi.SdkEvent.ContentCase.CLOSE_PEER_CONNECTION -> closePeerConnection() + else -> Unit + } + } + } + + fun startCamera(): VideoTrack = await { startCameraOnThread() } + + fun startMicrophone(): AudioTrack = await { startMicrophoneOnThread() } + + fun bind(trackId: String, track: MediaStreamTrack) { + media[trackId] = track + fishjamIdByWebrtcId[track.id()] = trackId + onMediaChanged() + } + + fun close() { + val done = CountDownLatch(1) + executor.execute { + try { + stopCameraOnThread() + stopMicrophoneOnThread() + closePeerConnection() + factory?.dispose() + factory = null + } finally { + done.countDown() + } + } + done.await(2, TimeUnit.SECONDS) + executor.shutdown() + } + + private fun await(block: () -> T): T { + val result = arrayOfNulls(1) + val error = arrayOfNulls(1) + val done = CountDownLatch(1) + executor.execute { + try { + result[0] = block() + } catch (thrown: Throwable) { + error[0] = thrown + } finally { + done.countDown() + } + } + done.await() + error[0]?.let { throw it } + @Suppress("UNCHECKED_CAST") + return result[0] as T + } + + private fun ensureFactory(): PeerConnectionFactory { + factory?.let { return it } + PeerConnectionFactory.initialize( + PeerConnectionFactory.InitializationOptions.builder(appContext).createInitializationOptions() + ) + val encoder = DefaultVideoEncoderFactory(SharedEgl.base.eglBaseContext, true, true) + val decoder = DefaultVideoDecoderFactory(SharedEgl.base.eglBaseContext) + val audioDevice = JavaAudioDeviceModule.builder(appContext).createAudioDeviceModule() + val created = + PeerConnectionFactory + .builder() + .setAudioDeviceModule(audioDevice) + .setVideoEncoderFactory(encoder) + .setVideoDecoderFactory(decoder) + .createPeerConnectionFactory() + audioDevice.release() + factory = created + return created + } + + private fun startCameraOnThread(): VideoTrack { + if (appContext.checkSelfPermission(android.Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { + throw SecurityException("CAMERA permission is required") + } + localVideo?.let { return it } + val enumerator = + if (Camera2Enumerator.isSupported(appContext)) { + Camera2Enumerator(appContext) + } else { + Camera1Enumerator(false) + } + val names = enumerator.deviceNames + if (names.isEmpty()) throw IllegalStateException("No camera found") + val frontCamera = names.firstOrNull { name -> + runCatching { enumerator.isFrontFacing(name) }.getOrDefault(false) + } + val cameraName = frontCamera ?: names.first() + val created = + enumerator.createCapturer(cameraName, EmptyCameraEvents()) + ?: throw IllegalStateException("No camera found") + val helper = + SurfaceTextureHelper.create("FishjamCapture", SharedEgl.base.eglBaseContext) + ?: throw IllegalStateException("Camera preview failed") + val source = ensureFactory().createVideoSource(created.isScreencast) + created.initialize(helper, appContext, source.capturerObserver) + try { + created.startCapture(1280, 720, 30) + } catch (error: Exception) { + created.dispose() + source.dispose() + helper.dispose() + throw error + } + val track = ensureFactory().createVideoTrack(java.util.UUID.randomUUID().toString(), source) + track.setEnabled(true) + capturer = created + videoSource = source + surface = helper + localVideo = track + return track + } + + private fun startMicrophoneOnThread(): AudioTrack { + val granted = + appContext.checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + if (!granted) { + throw SecurityException("RECORD_AUDIO permission is required") + } + localAudio?.let { return it } + val source = ensureFactory().createAudioSource(MediaConstraints()) + val track = ensureFactory().createAudioTrack(java.util.UUID.randomUUID().toString(), source) + track.setEnabled(true) + audioSource = source + localAudio = track + return track + } + + private fun stopCameraOnThread() { + capturer?.let { current -> + runCatching { current.stopCapture() } + current.dispose() + } + capturer = null + localVideo = null + videoSource?.dispose() + videoSource = null + surface?.dispose() + surface = null + } + + private fun stopMicrophoneOnThread() { + localAudio = null + audioSource?.dispose() + audioSource = null + } + + private fun openPeerConnection(request: SdkApi.CreatePeerConnection) { + if (connection != null) return + val iceServers = + request.iceServersList.map { server -> + PeerConnection.IceServer + .builder(server.urlsList) + .setUsername(server.username) + .setPassword(server.credential) + .createIceServer() + } + val config = PeerConnection.RTCConfiguration(iceServers) + config.bundlePolicy = PeerConnection.BundlePolicy.MAXBUNDLE + config.iceTransportsType = PeerConnection.IceTransportsType.ALL + lastConnectionState = "new" + lastIceState = "new" + connection = ensureFactory().createPeerConnection(config, observer) + } + + private fun updateRecvonly(update: SdkApi.UpdateRecvonlyTransceivers) { + val pc = connection ?: return + update.midsToStopList.forEach { mid -> + pc.transceivers.firstOrNull { it.mid == mid }?.stop() + } + repeat(update.videoToAdd) { addRecvonly(MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO) } + repeat(update.audioToAdd) { addRecvonly(MediaStreamTrack.MediaType.MEDIA_TYPE_AUDIO) } + } + + private fun addRecvonly(mediaType: MediaStreamTrack.MediaType) { + connection?.addTransceiver( + mediaType, + RtpTransceiver.RtpTransceiverInit(RtpTransceiver.RtpTransceiverDirection.RECV_ONLY) + ) + } + + private fun addSendonly(request: SdkApi.AddSendonlyTransceiver) { + val pc = connection ?: return + val track = media[request.trackId] ?: return + val encodings = + request.sendEncodingsList.map { encoding -> + val scale = encoding.scaleResolutionDownBy.takeIf { it > 0 }?.toDouble() + RtpParameters.Encoding(encoding.rid, encoding.active, scale).apply { + if (encoding.maxBitrate > 0) maxBitrateBps = encoding.maxBitrate + } + } + pc.addTransceiver( + track, + RtpTransceiver.RtpTransceiverInit( + RtpTransceiver.RtpTransceiverDirection.SEND_ONLY, + listOf(track.id()), + encodings + ) + ) + } + + private fun removeSend(trackId: String) { + val pc = connection ?: return + val track = media.remove(trackId) ?: return + fishjamIdByWebrtcId.remove(track.id()) + pc.transceivers.firstOrNull { it.sender.track()?.id() == track.id() }?.stop() + onMediaChanged() + } + + private fun createOffer() { + val pc = connection ?: return + pc.createOffer( + object : SdpObserver { + override fun onCreateSuccess(description: SessionDescription?) { + val sdp = description?.description ?: return + executor.execute { + call( + SdkApi.SdkRequest + .newBuilder() + .setCreateOfferResult( + SdkApi.CreateOfferResult + .newBuilder() + .setSdp(sdp) + ).build() + ) + } + } + + override fun onSetSuccess() = Unit + + override fun onCreateFailure(error: String?) { + Log.e(TAG, "createOffer failed: $error") + } + + override fun onSetFailure(error: String?) = Unit + }, + MediaConstraints() + ) + } + + private fun setLocal(description: SdkApi.SetLocalDescription) { + val pc = connection ?: return + pc.setLocalDescription( + object : SdpObserver { + override fun onCreateSuccess(description: SessionDescription?) = Unit + + override fun onSetSuccess() { + executor.execute { + val mids = HashMap() + pc.transceivers.forEach { transceiver -> + val mid = transceiver.mid ?: return@forEach + val webrtcId = transceiver.sender.track()?.id() ?: return@forEach + val trackId = fishjamIdByWebrtcId[webrtcId] ?: return@forEach + mids[mid] = trackId + } + call( + SdkApi.SdkRequest + .newBuilder() + .setSetLocalDescriptionResult( + SdkApi.SetLocalDescriptionResult + .newBuilder() + .putAllMidToTrackId(mids) + ).build() + ) + } + } + + override fun onCreateFailure(error: String?) = Unit + + override fun onSetFailure(error: String?) { + Log.e(TAG, "setLocalDescription failed: $error") + } + }, + SessionDescription(sessionType(description.type), description.sdp) + ) + } + + private fun setRemote(description: SdkApi.SetRemoteDescription) { + val pc = connection ?: return + midToTrackId.putAll(description.midToTrackIdMap) + description.midToTrackIdMap.forEach { (mid, trackId) -> + pendingRemote.remove(mid)?.let { track -> rememberRemote(trackId, track) } + } + pc.setRemoteDescription( + object : SdpObserver { + override fun onCreateSuccess(description: SessionDescription?) = Unit + + override fun onSetSuccess() { + executor.execute { + call( + SdkApi.SdkRequest + .newBuilder() + .setSetRemoteDescriptionResult(SdkApi.SetRemoteDescriptionResult.getDefaultInstance()) + .build() + ) + } + } + + override fun onCreateFailure(error: String?) = Unit + + override fun onSetFailure(error: String?) { + Log.e(TAG, "setRemoteDescription failed: $error") + } + }, + SessionDescription(sessionType(description.type), description.sdp) + ) + } + + private val pendingRemote = ConcurrentHashMap() + + private fun addRemoteCandidate(candidate: SdkApi.AddIceCandidate) { + val pc = connection ?: return + pc.addIceCandidate(IceCandidate(candidate.sdpMid, candidate.sdpMLineIndex, candidate.candidate)) + call( + SdkApi.SdkRequest + .newBuilder() + .setAddIceCandidateResult(SdkApi.AddIceCandidateResult.getDefaultInstance()) + .build() + ) + } + + private fun closePeerConnection() { + val pc = connection + connection = null + media.clear() + fishjamIdByWebrtcId.clear() + midToTrackId.clear() + pendingRemote.clear() + onMediaChanged() + pc?.dispose() + } + + private val observer = + object : PeerConnection.Observer { + override fun onSignalingChange(state: PeerConnection.SignalingState) = Unit + + override fun onIceConnectionChange(state: PeerConnection.IceConnectionState) { + reportState(ice = state.name.lowercase()) + } + + override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit + + override fun onIceGatheringChange(state: PeerConnection.IceGatheringState) = Unit + + override fun onIceCandidate(candidate: IceCandidate?) { + if (candidate == null || candidate.sdp.isEmpty()) return + executor.execute { + call( + SdkApi.SdkRequest + .newBuilder() + .setLocalIceCandidate( + SdkApi.LocalIceCandidate + .newBuilder() + .setCandidate(candidate.sdp) + .setSdpMid(candidate.sdpMid ?: "") + .setSdpMLineIndex(candidate.sdpMLineIndex) + .setUsernameFragment("") + ).build() + ) + } + } + + override fun onIceCandidatesRemoved(candidates: Array) = Unit + + override fun onAddStream(stream: org.webrtc.MediaStream) = Unit + + override fun onRemoveStream(stream: org.webrtc.MediaStream) = Unit + + override fun onDataChannel(channel: org.webrtc.DataChannel) = Unit + + override fun onRenegotiationNeeded() = Unit + + override fun onTrack(transceiver: RtpTransceiver) { + val mid = transceiver.mid ?: return + val track = transceiver.receiver.track() ?: return + executor.execute { + val trackId = midToTrackId[mid] + if (trackId == null) { + pendingRemote[mid] = track + } else { + rememberRemote(trackId, track) + } + call( + SdkApi.SdkRequest + .newBuilder() + .setRemoteTrackReady( + SdkApi.RemoteTrackReady + .newBuilder() + .setMid(mid) + ).build() + ) + } + } + + override fun onConnectionChange(state: PeerConnection.PeerConnectionState) { + reportState(connectionState = state.name.lowercase()) + } + } + + private fun rememberRemote(trackId: String, track: MediaStreamTrack) { + media[trackId] = track + onMediaChanged() + } + + private fun reportState(connectionState: String? = null, ice: String? = null) { + if (this.connection == null) return + if (connectionState != null) lastConnectionState = connectionState + if (ice != null) lastIceState = ice + val connectionValue = lastConnectionState + val iceValue = lastIceState + executor.execute { + call( + SdkApi.SdkRequest + .newBuilder() + .setPeerConnectionStateChanged( + SdkApi.PeerConnectionStateChanged + .newBuilder() + .setConnectionState(connectionValue) + .setIceConnectionState(iceValue) + ).build() + ) + } + } + + private fun sessionType(type: SdkApi.SdpType): SessionDescription.Type = when (type) { + SdkApi.SdpType.SDP_TYPE_ANSWER -> SessionDescription.Type.ANSWER + else -> SessionDescription.Type.OFFER + } + + private companion object { + const val TAG = "Fishjam" + } +} + +private class EmptyCameraEvents : CameraVideoCapturer.CameraEventsHandler { + override fun onCameraError(errorDescription: String) = Unit + + override fun onCameraDisconnected() = Unit + + override fun onCameraFreezed(errorDescription: String) = Unit + + override fun onCameraOpening(cameraName: String) = Unit + + override fun onFirstFrameAvailable() = Unit + + override fun onCameraClosed() = Unit +} diff --git a/packages/android-client/client/src/main/java/io/fishjam/client/NativeCore.kt b/packages/android-client/client/src/main/java/io/fishjam/client/NativeCore.kt new file mode 100644 index 00000000..7361f796 --- /dev/null +++ b/packages/android-client/client/src/main/java/io/fishjam/client/NativeCore.kt @@ -0,0 +1,22 @@ +package io.fishjam.client + +class NativeCallResult( + @JvmField val code: Int, + @JvmField val payload: ByteArray +) + +internal fun interface NativeEvents { + fun onEvent(payload: ByteArray) +} + +internal object NativeCore { + init { + System.loadLibrary("fishjam_jni") + } + + @JvmStatic external fun nativeCreate(config: ByteArray, events: NativeEvents): Long + + @JvmStatic external fun nativeCall(handle: Long, request: ByteArray): NativeCallResult + + @JvmStatic external fun nativeDestroy(handle: Long) +} diff --git a/packages/android-client/client/src/main/java/io/fishjam/client/PeerStatusMapping.kt b/packages/android-client/client/src/main/java/io/fishjam/client/PeerStatusMapping.kt new file mode 100644 index 00000000..ef9cbd66 --- /dev/null +++ b/packages/android-client/client/src/main/java/io/fishjam/client/PeerStatusMapping.kt @@ -0,0 +1,50 @@ +package io.fishjam.client + +import fishjam.sdk.SdkApi + +enum class PeerStatus { + Idle, + Connecting, + Connected, + Error +} + +/** Thrown by [FishjamClient.joinRoom] when auth or join fails. The message is the reason. */ +class JoinRoomException( + message: String, + val authReason: String = "", + val joinReason: String = "" +) : Exception(message) + +internal data class PeerStatusUpdate( + val peerStatus: PeerStatus, + val message: String, + val authReason: String, + val joinReason: String +) + +internal fun peerStatusUpdate(event: SdkApi.SdkEvent): PeerStatusUpdate? { + if (!event.hasPeerStatusChanged()) return null + val changed = event.peerStatusChanged + val error = if (changed.hasError()) changed.error else null + return PeerStatusUpdate( + peerStatus = + when (changed.peerStatus) { + SdkApi.PeerStatus.PEER_STATUS_CONNECTING -> PeerStatus.Connecting + SdkApi.PeerStatus.PEER_STATUS_CONNECTED -> PeerStatus.Connected + SdkApi.PeerStatus.PEER_STATUS_ERROR -> PeerStatus.Error + SdkApi.PeerStatus.PEER_STATUS_IDLE, + SdkApi.PeerStatus.PEER_STATUS_UNSPECIFIED, + SdkApi.PeerStatus.UNRECOGNIZED -> PeerStatus.Idle + }, + message = error?.message.orEmpty(), + authReason = error?.authReason.orEmpty(), + joinReason = error?.joinReason.orEmpty() + ) +} + +internal fun joinFailure(update: PeerStatusUpdate): JoinRoomException? { + if (update.peerStatus != PeerStatus.Error) return null + val reason = update.authReason.ifEmpty { update.joinReason }.ifEmpty { update.message } + return JoinRoomException(reason, update.authReason, update.joinReason) +} diff --git a/packages/android-client/client/src/main/java/io/fishjam/client/RoomState.kt b/packages/android-client/client/src/main/java/io/fishjam/client/RoomState.kt new file mode 100644 index 00000000..29498fc6 --- /dev/null +++ b/packages/android-client/client/src/main/java/io/fishjam/client/RoomState.kt @@ -0,0 +1,111 @@ +package io.fishjam.client + +import fishjam.sdk.SdkApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import org.webrtc.AudioTrack +import org.webrtc.MediaStreamTrack +import org.webrtc.VideoTrack + +data class TrackMetadata( + val type: String, + val paused: Boolean, + val displayName: String? +) + +data class Track( + val trackId: String, + val metadata: TrackMetadata?, + val videoTrack: VideoTrack?, + val audioTrack: AudioTrack? +) + +data class PeerWithTracks( + val id: String, + val metadata: JsonObject?, + val tracks: List, + val cameraTrack: Track?, + val microphoneTrack: Track?, + val screenShareVideoTrack: Track?, + val screenShareAudioTrack: Track?, + val customVideoTracks: List, + val customAudioTracks: List +) + +data class RoomState( + val localPeer: PeerWithTracks?, + val remotePeers: List +) { + companion object { + val Empty = RoomState(localPeer = null, remotePeers = emptyList()) + } +} + +internal fun roomStateOf(proto: SdkApi.RoomState): RoomState = RoomState( + localPeer = if (proto.hasLocalPeer()) peerOf(proto.localPeer) else null, + remotePeers = proto.remotePeersList.map(::peerOf) +) + +internal fun RoomState.attach(media: Map): RoomState = copy( + localPeer = localPeer?.attach(media), + remotePeers = remotePeers.map { peer -> peer.attach(media) } +) + +private fun PeerWithTracks.attach(media: Map): PeerWithTracks = copy( + tracks = tracks.map { track -> track.attach(media) }, + cameraTrack = cameraTrack?.attach(media), + microphoneTrack = microphoneTrack?.attach(media), + screenShareVideoTrack = screenShareVideoTrack?.attach(media), + screenShareAudioTrack = screenShareAudioTrack?.attach(media), + customVideoTracks = customVideoTracks.map { track -> track.attach(media) }, + customAudioTracks = customAudioTracks.map { track -> track.attach(media) } +) + +private fun Track.attach(media: Map): Track { + val stream = media[trackId] ?: return this + return when (stream) { + is VideoTrack -> copy(videoTrack = stream) + is AudioTrack -> copy(audioTrack = stream) + else -> this + } +} + +private fun peerOf(proto: SdkApi.PeerState): PeerWithTracks = PeerWithTracks( + id = proto.id, + metadata = metadataObject(proto.metadataJson), + tracks = proto.tracksList.map(::trackOf), + cameraTrack = proto.takeIf { it.hasCameraTrack() }?.cameraTrack?.let(::trackOf), + microphoneTrack = proto.takeIf { it.hasMicrophoneTrack() }?.microphoneTrack?.let(::trackOf), + screenShareVideoTrack = proto.takeIf { it.hasScreenShareVideoTrack() }?.screenShareVideoTrack?.let(::trackOf), + screenShareAudioTrack = proto.takeIf { it.hasScreenShareAudioTrack() }?.screenShareAudioTrack?.let(::trackOf), + customVideoTracks = proto.customVideoTracksList.map(::trackOf), + customAudioTracks = proto.customAudioTracksList.map(::trackOf) +) + +private fun trackOf(proto: SdkApi.TrackState): Track = Track( + trackId = proto.trackId, + metadata = if (proto.hasMetadata()) metadataOf(proto.metadata) else null, + videoTrack = null, + audioTrack = null +) + +private fun metadataOf(proto: SdkApi.TrackMetadata): TrackMetadata = TrackMetadata( + type = trackTypeName(proto.type), + paused = proto.paused, + displayName = proto.displayName.takeIf { proto.hasDisplayName() && it.isNotEmpty() } +) + +private fun metadataObject(json: String): JsonObject? { + if (json.isBlank()) return null + return runCatching { Json.parseToJsonElement(json) as? JsonObject }.getOrNull() +} + +private fun trackTypeName(type: SdkApi.TrackType): String = when (type) { + SdkApi.TrackType.TRACK_TYPE_CAMERA -> "camera" + SdkApi.TrackType.TRACK_TYPE_MICROPHONE -> "microphone" + SdkApi.TrackType.TRACK_TYPE_SCREEN_SHARE_VIDEO -> "screenShareVideo" + SdkApi.TrackType.TRACK_TYPE_SCREEN_SHARE_AUDIO -> "screenShareAudio" + SdkApi.TrackType.TRACK_TYPE_CUSTOM_VIDEO -> "customVideo" + SdkApi.TrackType.TRACK_TYPE_CUSTOM_AUDIO -> "customAudio" + else -> "" +} diff --git a/packages/android-client/client/src/main/java/io/fishjam/client/VideoView.kt b/packages/android-client/client/src/main/java/io/fishjam/client/VideoView.kt new file mode 100644 index 00000000..57637df0 --- /dev/null +++ b/packages/android-client/client/src/main/java/io/fishjam/client/VideoView.kt @@ -0,0 +1,146 @@ +package io.fishjam.client + +import android.graphics.Color +import android.widget.FrameLayout +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import org.webrtc.RendererCommon +import org.webrtc.SurfaceViewRenderer +import org.webrtc.VideoTrack + +enum class ObjectFit { Contain, Cover } + +internal data class VideoDisplayRect(val left: Int, val top: Int, val right: Int, val bottom: Int) + +internal fun videoDisplayRect( + viewWidth: Int, + viewHeight: Int, + frameWidth: Int, + frameHeight: Int, + frameRotation: Int, + objectFit: ObjectFit +): VideoDisplayRect { + if (viewWidth == 0 || viewHeight == 0) { + return VideoDisplayRect(0, 0, 0, 0) + } + if (objectFit == ObjectFit.Cover || frameWidth == 0 || frameHeight == 0) { + return VideoDisplayRect(0, 0, viewWidth, viewHeight) + } + val rotated = frameRotation % 180 != 0 + val frameWide = if (rotated) frameHeight else frameWidth + val frameTall = if (rotated) frameWidth else frameHeight + val frameAspect = frameWide.toFloat() / frameTall + val viewAspect = viewWidth.toFloat() / viewHeight + val width: Int + val height: Int + if (frameAspect > viewAspect) { + width = viewWidth + height = (viewWidth.toLong() * frameTall / frameWide).toInt() + } else { + height = viewHeight + width = (viewHeight.toLong() * frameWide / frameTall).toInt() + } + val left = (viewWidth - width) / 2 + val top = (viewHeight - height) / 2 + return VideoDisplayRect(left, top, left + width, top + height) +} + +@Composable +fun VideoView( + track: VideoTrack, + modifier: Modifier = Modifier, + mirror: Boolean = false, + objectFit: ObjectFit = ObjectFit.Cover +) { + AndroidView( + factory = { context -> + FishjamVideoView(context) + }, + modifier = modifier, + update = { view -> + view.setMirror(mirror) + view.setObjectFit(objectFit) + view.setTrack(track) + }, + onRelease = { view -> view.releaseView() } + ) +} + +private class FishjamVideoView(context: android.content.Context) : + FrameLayout(context), + RendererCommon.RendererEvents { + private val renderer = SurfaceViewRenderer(context) + private var current: VideoTrack? = null + private var objectFit = ObjectFit.Cover + private var frameWidth = 0 + private var frameHeight = 0 + private var frameRotation = 0 + + init { + setBackgroundColor(Color.BLACK) + renderer.init(SharedEgl.base.eglBaseContext, this) + renderer.setEnableHardwareScaler(true) + renderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL) + addView(renderer) + } + + fun setMirror(mirror: Boolean) { + renderer.setMirror(mirror) + } + + fun setObjectFit(objectFit: ObjectFit) { + this.objectFit = objectFit + requestLayout() + } + + fun setTrack(track: VideoTrack) { + if (current === track) return + removeSinkQuietly(current) + current = track + try { + track.addSink(renderer) + } catch (error: IllegalStateException) { + current = null + } + } + + fun releaseView() { + removeSinkQuietly(current) + current = null + renderer.release() + } + + private fun removeSinkQuietly(track: VideoTrack?) { + if (track == null) return + try { + track.removeSink(renderer) + } catch (error: IllegalStateException) { + Unit + } + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + val rect = + videoDisplayRect( + right - left, + bottom - top, + frameWidth, + frameHeight, + frameRotation, + objectFit + ) + renderer.layout(rect.left, rect.top, rect.right, rect.bottom) + } + + override fun onFirstFrameRendered() = Unit + + override fun onFrameResolutionChanged(videoWidth: Int, videoHeight: Int, rotation: Int) { + post { + frameWidth = videoWidth + frameHeight = videoHeight + frameRotation = rotation + requestLayout() + } + } +} diff --git a/packages/android-client/client/src/test/java/io/fishjam/client/PeerStatusMappingTest.kt b/packages/android-client/client/src/test/java/io/fishjam/client/PeerStatusMappingTest.kt new file mode 100644 index 00000000..ff61b382 --- /dev/null +++ b/packages/android-client/client/src/test/java/io/fishjam/client/PeerStatusMappingTest.kt @@ -0,0 +1,72 @@ +package io.fishjam.client + +import fishjam.sdk.SdkApi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PeerStatusMappingTest { + @Test + fun mapsConnectingConnectedAndIdle() { + assertEquals(PeerStatus.Connecting, statusOf(SdkApi.PeerStatus.PEER_STATUS_CONNECTING).peerStatus) + assertEquals(PeerStatus.Connected, statusOf(SdkApi.PeerStatus.PEER_STATUS_CONNECTED).peerStatus) + assertEquals(PeerStatus.Idle, statusOf(SdkApi.PeerStatus.PEER_STATUS_IDLE).peerStatus) + } + + @Test + fun mapsAuthErrorReason() { + val update = + statusOf( + SdkApi.PeerStatus.PEER_STATUS_ERROR, + SdkApi.Error + .newBuilder() + .setMessage("invalid token") + .setAuthReason("invalid token") + .build() + ) + + assertEquals(PeerStatus.Error, update.peerStatus) + assertEquals("invalid token", update.authReason) + assertEquals("", update.joinReason) + assertEquals("invalid token", joinFailure(update)?.message) + } + + @Test + fun mapsJoinErrorReason() { + val update = + statusOf( + SdkApi.PeerStatus.PEER_STATUS_ERROR, + SdkApi.Error + .newBuilder() + .setMessage("reached peers limit") + .setJoinReason("reached peers limit") + .build() + ) + + assertEquals("reached peers limit", update.joinReason) + assertEquals("", update.authReason) + assertEquals("reached peers limit", joinFailure(update)?.message) + } + + @Test + fun ignoresEventsThatAreNotPeerStatus() { + val event = + SdkApi.SdkEvent + .newBuilder() + .setClosePeerConnection(SdkApi.ClosePeerConnection.getDefaultInstance()) + .build() + + assertNull(peerStatusUpdate(event)) + assertNull(joinFailure(statusOf(SdkApi.PeerStatus.PEER_STATUS_CONNECTED))) + } + + private fun statusOf(status: SdkApi.PeerStatus, error: SdkApi.Error? = null): PeerStatusUpdate { + val changed = + SdkApi.PeerStatusChanged + .newBuilder() + .setPeerStatus(status) + .apply { if (error != null) setError(error) } + val event = SdkApi.SdkEvent.newBuilder().setPeerStatusChanged(changed).build() + return peerStatusUpdate(event)!! + } +} diff --git a/packages/android-client/client/src/test/java/io/fishjam/client/RoomStateMappingTest.kt b/packages/android-client/client/src/test/java/io/fishjam/client/RoomStateMappingTest.kt new file mode 100644 index 00000000..37ee7795 --- /dev/null +++ b/packages/android-client/client/src/test/java/io/fishjam/client/RoomStateMappingTest.kt @@ -0,0 +1,53 @@ +package io.fishjam.client + +import fishjam.sdk.SdkApi +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class RoomStateMappingTest { + @Test + fun mapsLocalMicrophoneAndRemoteCamera() { + val room = + roomStateOf( + SdkApi.RoomState + .newBuilder() + .setLocalPeer( + peer("local", """{"peer":{"name":"me"}}""") + .setMicrophoneTrack(track("local:mic", SdkApi.TrackType.TRACK_TYPE_MICROPHONE)) + .build() + ).addRemotePeers( + peer("remote", "") + .setCameraTrack(track("remote:cam", SdkApi.TrackType.TRACK_TYPE_CAMERA)) + .build() + ).build() + ) + + assertEquals("local", room.localPeer?.id) + val metadata = room.localPeer?.metadata + assertEquals(JsonPrimitive("me"), (metadata?.get("peer") as JsonObject)["name"]) + assertEquals("microphone", room.localPeer?.microphoneTrack?.metadata?.type) + assertEquals(false, room.localPeer?.microphoneTrack?.metadata?.paused) + assertNull(room.localPeer?.cameraTrack) + assertEquals("remote", room.remotePeers.single().id) + assertEquals("camera", room.remotePeers.single().cameraTrack?.metadata?.type) + assertNull(room.remotePeers.single().metadata) + } +} + +private fun peer(id: String, metadataJson: String): SdkApi.PeerState.Builder = SdkApi.PeerState + .newBuilder() + .setId(id) + .setMetadataJson(metadataJson) + +private fun track(id: String, type: SdkApi.TrackType): SdkApi.TrackState = SdkApi.TrackState + .newBuilder() + .setTrackId(id) + .setMetadata( + SdkApi.TrackMetadata + .newBuilder() + .setType(type) + .setPaused(false) + ).build() diff --git a/packages/android-client/client/src/test/java/io/fishjam/client/VideoLayoutTest.kt b/packages/android-client/client/src/test/java/io/fishjam/client/VideoLayoutTest.kt new file mode 100644 index 00000000..9c9d137c --- /dev/null +++ b/packages/android-client/client/src/test/java/io/fishjam/client/VideoLayoutTest.kt @@ -0,0 +1,30 @@ +package io.fishjam.client + +import org.junit.Assert.assertEquals +import org.junit.Test + +class VideoLayoutTest { + @Test + fun coverFillsTheView() { + assertEquals( + VideoDisplayRect(0, 0, 1000, 200), + videoDisplayRect(1000, 200, 720, 1280, 0, ObjectFit.Cover) + ) + } + + @Test + fun containFitsAPortraitFrameInAWideView() { + assertEquals( + VideoDisplayRect(444, 0, 556, 200), + videoDisplayRect(1000, 200, 720, 1280, 0, ObjectFit.Contain) + ) + } + + @Test + fun containTreatsAQuarterTurnAsSwappedSides() { + assertEquals( + VideoDisplayRect(0, 44, 200, 156), + videoDisplayRect(200, 200, 720, 1280, 90, ObjectFit.Contain) + ) + } +} diff --git a/packages/android-client/gradle.properties b/packages/android-client/gradle.properties new file mode 100644 index 00000000..5f4cff37 --- /dev/null +++ b/packages/android-client/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/packages/android-client/gradle/libs.versions.toml b/packages/android-client/gradle/libs.versions.toml new file mode 100644 index 00000000..f547a6c6 --- /dev/null +++ b/packages/android-client/gradle/libs.versions.toml @@ -0,0 +1,41 @@ +[versions] +agp = "8.7.3" +kotlin = "2.0.21" +ktlint = "12.1.2" +junit = "4.13.2" +composeBom = "2024.10.01" +activity = "1.9.3" +protobuf = "0.9.4" +protobufRuntime = "3.25.5" +coroutines = "1.9.0" +serialization = "1.7.3" +webrtc = "v124.0.2.3" +androidxJunit = "1.2.1" +testRunner = "1.6.2" +testCore = "1.6.1" + +[libraries] +junit = { group = "junit", name = "junit", version.ref = "junit" } +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } +compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity" } +protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobufRuntime" } +coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } +serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } +webrtc = { group = "com.github.fishjam-cloud", name = "webrtc", version.ref = "webrtc" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "testRunner" } +androidx-test-core = { group = "androidx.test", name = "core", version.ref = "testCore" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +protobuf = { id = "com.google.protobuf", version.ref = "protobuf" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } diff --git a/packages/android-client/gradle/wrapper/gradle-wrapper.jar b/packages/android-client/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..a4b76b95 Binary files /dev/null and b/packages/android-client/gradle/wrapper/gradle-wrapper.jar differ diff --git a/packages/android-client/gradle/wrapper/gradle-wrapper.properties b/packages/android-client/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..e2847c82 --- /dev/null +++ b/packages/android-client/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/packages/android-client/gradlew b/packages/android-client/gradlew new file mode 100755 index 00000000..d95bf613 --- /dev/null +++ b/packages/android-client/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/packages/android-client/package.json b/packages/android-client/package.json new file mode 100644 index 00000000..9e796bb5 --- /dev/null +++ b/packages/android-client/package.json @@ -0,0 +1,16 @@ +{ + "name": "@fishjam-cloud/android-client", + "version": "0.31.0", + "private": true, + "license": "Apache-2.0", + "description": "Fishjam client SDK for native Android", + "scripts": { + "build": "./gradlew :client:assembleDebug --console=plain", + "test": "./gradlew :client:testDebugUnitTest :client:connectedDebugAndroidTest --console=plain", + "test:unit": "./gradlew :client:testDebugUnitTest --console=plain", + "format": "./gradlew ktlintFormat --console=plain", + "format:check": "./gradlew ktlintCheck --console=plain", + "lint": "./gradlew ktlintCheck --console=plain", + "lint:check": "./gradlew ktlintCheck --console=plain" + } +} diff --git a/packages/android-client/settings.gradle.kts b/packages/android-client/settings.gradle.kts new file mode 100644 index 00000000..d4af4ce7 --- /dev/null +++ b/packages/android-client/settings.gradle.kts @@ -0,0 +1,19 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven { url = uri("https://www.jitpack.io") } + } +} + +rootProject.name = "android-client" +include(":client") diff --git a/packages/fishjam-core/.gitignore b/packages/fishjam-core/.gitignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/packages/fishjam-core/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/packages/fishjam-core/Cargo.lock b/packages/fishjam-core/Cargo.lock new file mode 100644 index 00000000..d6945b1c --- /dev/null +++ b/packages/fishjam-core/Cargo.lock @@ -0,0 +1,1096 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fishjam-core" +version = "0.31.0" +dependencies = [ + "futures-util", + "log", + "prost", + "prost-build", + "protoc-bin-vendored", + "rustls", + "serde_json", + "tokio", + "tokio-tungstenite", + "uuid", +] + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.119", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8760a25b6ff9c620324822737e468478fa092234190d2e449760344354896ed9" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fa2624782ca04cd44f51554566717377acd240e4c0016d757dd74fccc9324f" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2417e9817fa237dab803ad4dda7357a111656e242959cc6b8f9a1a583367d42" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d189c34636356a46a7ed3188233dc8a88c431278cc54d4a19b096a2d270e985" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171e39f1e846e5f322ced1ac3b8d4cd3a3833ca24b6e5d58b3632574fe6204fa" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873cdcc097593432086661aa432b8078f1cd87bfb02847c332e98ae2c119e966" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeb72df001783b8297847fe8f5f874ee400fd742c843d60583e8c23d96977c7f" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b04652167eca899dda05f32f5481adeaf25c623a98ce2fc146a001cc59a2add7" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "263a3f48f01e7309e857138bd47f785585b4a005e8e56c6d2824ce91195999c3" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/packages/fishjam-core/Cargo.toml b/packages/fishjam-core/Cargo.toml new file mode 100644 index 00000000..fc1255d7 --- /dev/null +++ b/packages/fishjam-core/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "fishjam-core" +version = "0.31.0" +edition = "2021" +license = "Apache-2.0" +description = "Fishjam client core used by the native SDKs" +publish = false + +[lib] +crate-type = ["rlib", "staticlib"] +path = "src/lib.rs" + +[dependencies] +futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } +log = "0.4" +prost = "0.13" +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros", "net"] } +tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } +uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +prost = "0.13" +tokio = { version = "1", features = ["rt-multi-thread", "net", "macros"] } + +[build-dependencies] +prost-build = "0.13" +protoc-bin-vendored = "3" diff --git a/packages/fishjam-core/build.rs b/packages/fishjam-core/build.rs new file mode 100644 index 00000000..1cd30ce6 --- /dev/null +++ b/packages/fishjam-core/build.rs @@ -0,0 +1,20 @@ +fn main() { + let protoc = protoc_bin_vendored::protoc_bin_path().expect("vendored protoc"); + std::env::set_var("PROTOC", protoc); + + println!("cargo:rerun-if-changed=proto"); + println!("cargo:rerun-if-changed=../protobufs/protos"); + + prost_build::Config::new() + .boxed(".fishjam.sdk.SdkResponse.content.room_state") + .boxed(".fishjam.sdk.SdkEvent.content.room_state_changed") + .include_file("protos.rs") + .compile_protos( + &[ + "proto/sdk_api.proto", + "../protobufs/protos/fishjam/peer_notifications.proto", + ], + &["proto/", "../protobufs/protos/"], + ) + .expect("compile protos"); +} diff --git a/packages/fishjam-core/include/fishjam.h b/packages/fishjam-core/include/fishjam.h new file mode 100644 index 00000000..6345dea8 --- /dev/null +++ b/packages/fishjam-core/include/fishjam.h @@ -0,0 +1,33 @@ +#ifndef FISHJAM_H +#define FISHJAM_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct FishjamBuf { + uint8_t *data; + size_t len; +} FishjamBuf; + +typedef void (*FishjamOnEvent)(void *user_data, const uint8_t *event, size_t event_len); + +struct FishjamClient; + +struct FishjamClient *fishjam_client_new(const uint8_t *config, size_t config_len, FishjamOnEvent on_event, + void *user_data); + +void fishjam_client_free(struct FishjamClient *client); + +int32_t fishjam_client_call(struct FishjamClient *client, const uint8_t *req, size_t req_len, struct FishjamBuf *out); + +void fishjam_buf_free(struct FishjamBuf buf); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/packages/fishjam-core/package.json b/packages/fishjam-core/package.json new file mode 100644 index 00000000..61cdc024 --- /dev/null +++ b/packages/fishjam-core/package.json @@ -0,0 +1,15 @@ +{ + "name": "@fishjam-cloud/fishjam-core", + "version": "0.31.0", + "private": true, + "license": "Apache-2.0", + "description": "Fishjam client core used by the native SDKs", + "scripts": { + "build": "cargo build", + "test": "cargo test", + "format": "cargo fmt --all", + "format:check": "cargo fmt --all --check", + "lint": "cargo clippy --all-targets -- -D warnings", + "lint:check": "cargo clippy --all-targets -- -D warnings" + } +} diff --git a/packages/fishjam-core/proto/sdk_api.proto b/packages/fishjam-core/proto/sdk_api.proto new file mode 100644 index 00000000..a267ab32 --- /dev/null +++ b/packages/fishjam-core/proto/sdk_api.proto @@ -0,0 +1,258 @@ +syntax = "proto3"; + +package fishjam.sdk; + +// Bytes passed to fishjam_client_new. +message ClientConfig { + string fishjam_id = 1; + bool debug = 2; +} + +// Bytes passed to fishjam_client_call. +message SdkRequest { + reserved 1; + oneof content { + JoinRoom join_room = 2; + LeaveRoom leave_room = 3; + AddTrack add_track = 4; + RemoveTrack remove_track = 5; + CreateOfferResult create_offer_result = 6; + SetLocalDescriptionResult set_local_description_result = 7; + SetRemoteDescriptionResult set_remote_description_result = 8; + AddIceCandidateResult add_ice_candidate_result = 9; + LocalIceCandidate local_ice_candidate = 10; + RemoteTrackReady remote_track_ready = 11; + PeerConnectionStateChanged peer_connection_state_changed = 12; + } +} + +// Bytes written to FishjamBuf by fishjam_client_call. +message SdkResponse { + reserved 2; + oneof content { + Error error = 1; + Empty empty = 3; + AddTrackResult add_track = 4; + RoomState room_state = 5; + } +} + +// Bytes passed to FishjamOnEvent. The pointer is valid only for that call. +message SdkEvent { + oneof content { + PeerStatusChanged peer_status_changed = 1; + RoomStateChanged room_state_changed = 2; + CreatePeerConnection create_peer_connection = 3; + UpdateRecvonlyTransceivers update_recvonly_transceivers = 4; + AddSendonlyTransceiver add_sendonly_transceiver = 5; + RemoveSendTrack remove_send_track = 6; + CreateOffer create_offer = 7; + SetLocalDescription set_local_description = 8; + SetRemoteDescription set_remote_description = 9; + AddIceCandidate add_ice_candidate = 10; + ClosePeerConnection close_peer_connection = 11; + } +} + +message JoinRoom { + string peer_token = 1; + string peer_metadata_json = 2; +} + +message LeaveRoom {} + +message Empty {} + +message Error { + string message = 1; + string auth_reason = 2; + string join_reason = 3; +} + +enum TrackKind { + TRACK_KIND_UNSPECIFIED = 0; + TRACK_KIND_AUDIO = 1; + TRACK_KIND_VIDEO = 2; +} + +enum TrackType { + TRACK_TYPE_UNSPECIFIED = 0; + TRACK_TYPE_CAMERA = 1; + TRACK_TYPE_MICROPHONE = 2; + TRACK_TYPE_SCREEN_SHARE_VIDEO = 3; + TRACK_TYPE_SCREEN_SHARE_AUDIO = 4; + TRACK_TYPE_CUSTOM_VIDEO = 5; + TRACK_TYPE_CUSTOM_AUDIO = 6; +} + +// Same values as fishjam.media_events.Variant. +enum Variant { + VARIANT_UNSPECIFIED = 0; + VARIANT_LOW = 1; + VARIANT_MEDIUM = 2; + VARIANT_HIGH = 3; +} + +message TrackMetadata { + TrackType type = 1; + bool paused = 2; + optional string display_name = 3; +} + +message AddTrack { + TrackKind kind = 1; + TrackMetadata metadata = 2; +} + +message RemoveTrack { + string track_id = 1; +} + +message AddTrackResult { + string track_id = 1; +} + +enum PeerStatus { + PEER_STATUS_UNSPECIFIED = 0; + PEER_STATUS_IDLE = 1; + PEER_STATUS_CONNECTING = 2; + PEER_STATUS_CONNECTED = 3; + PEER_STATUS_ERROR = 4; +} + +message PeerStatusChanged { + PeerStatus peer_status = 1; + Error error = 2; +} + +enum NegotiationStatus { + NEGOTIATION_STATUS_UNSPECIFIED = 0; + NEGOTIATION_STATUS_AWAITING = 1; + NEGOTIATION_STATUS_OFFERED = 2; + NEGOTIATION_STATUS_DONE = 3; +} + +message SimulcastConfig { + bool enabled = 1; + repeated Variant enabled_variants = 2; + repeated Variant disabled_variants = 3; +} + +message TrackState { + string track_id = 1; + TrackMetadata metadata = 2; + SimulcastConfig simulcast_config = 3; + NegotiationStatus negotiation_status = 4; + Variant encoding = 5; + bool ready = 6; +} + +message PeerState { + string id = 1; + string metadata_json = 2; + repeated TrackState tracks = 3; + optional TrackState camera_track = 4; + optional TrackState microphone_track = 5; + optional TrackState screen_share_video_track = 6; + optional TrackState screen_share_audio_track = 7; + repeated TrackState custom_video_tracks = 8; + repeated TrackState custom_audio_tracks = 9; +} + +message RoomState { + optional PeerState local_peer = 1; + repeated PeerState remote_peers = 2; +} + +message RoomStateChanged { + RoomState room_state = 1; +} + +message IceServer { + string credential = 1; + repeated string urls = 2; + string username = 3; +} + +message CreatePeerConnection { + repeated IceServer ice_servers = 1; +} + +message UpdateRecvonlyTransceivers { + int32 video_to_add = 1; + int32 audio_to_add = 2; + repeated string mids_to_stop = 3; +} + +message SendEncoding { + string rid = 1; + bool active = 2; + double scale_resolution_down_by = 3; + uint32 max_bitrate = 4; +} + +message AddSendonlyTransceiver { + string track_id = 1; + TrackKind kind = 2; + repeated SendEncoding send_encodings = 3; +} + +message RemoveSendTrack { + string track_id = 1; +} + +message CreateOffer {} + +message CreateOfferResult { + string sdp = 1; +} + +enum SdpType { + SDP_TYPE_UNSPECIFIED = 0; + SDP_TYPE_OFFER = 1; + SDP_TYPE_ANSWER = 2; +} + +message SetLocalDescription { + SdpType type = 1; + string sdp = 2; +} + +message SetLocalDescriptionResult { + map mid_to_track_id = 1; +} + +message SetRemoteDescription { + SdpType type = 1; + string sdp = 2; + map mid_to_track_id = 3; +} + +message SetRemoteDescriptionResult {} + +message AddIceCandidate { + string candidate = 1; + string sdp_mid = 2; + int32 sdp_m_line_index = 3; + string username_fragment = 4; +} + +message AddIceCandidateResult {} + +message LocalIceCandidate { + string candidate = 1; + string sdp_mid = 2; + int32 sdp_m_line_index = 3; + string username_fragment = 4; +} + +message RemoteTrackReady { + string mid = 1; +} + +message PeerConnectionStateChanged { + string connection_state = 1; + string ice_connection_state = 2; +} + +message ClosePeerConnection {} diff --git a/packages/fishjam-core/src/abi.rs b/packages/fishjam-core/src/abi.rs new file mode 100644 index 00000000..fe7f5e12 --- /dev/null +++ b/packages/fishjam-core/src/abi.rs @@ -0,0 +1,207 @@ +use std::os::raw::c_void; + +use prost::Message; + +use crate::client::FishjamClient; +use crate::sdk::{ + sdk_request, sdk_response, ClientConfig, Empty, Error, SdkEvent, SdkRequest, SdkResponse, +}; + +/// Return codes for [`fishjam_client_call`]. +const OK: i32 = 0; +const NULL_ARGUMENT: i32 = 1; +const DECODE_ERROR: i32 = 2; +const ILLEGAL_CALL: i32 = 3; + +pub type FishjamOnEvent = Option; + +#[repr(C)] +pub struct FishjamBuf { + pub data: *mut u8, + pub len: usize, +} + +struct UserData(*mut c_void); + +// The SDK owns `user_data` and promises it can be used from any thread. +unsafe impl Send for UserData {} +unsafe impl Sync for UserData {} + +impl UserData { + fn get(&self) -> *mut c_void { + self.0 + } +} + +/// # Safety +/// `config` is either null with `config_len == 0`, or points at `config_len` bytes. +/// `on_event` may be null. `user_data` is passed back unchanged and is not dereferenced here. +#[no_mangle] +pub unsafe extern "C" fn fishjam_client_new( + config: *const u8, + config_len: usize, + on_event: FishjamOnEvent, + user_data: *mut c_void, +) -> *mut FishjamClient { + let parsed = if config.is_null() || config_len == 0 { + ClientConfig::default() + } else { + match ClientConfig::decode(unsafe { std::slice::from_raw_parts(config, config_len) }) { + Ok(config) => config, + Err(_) => return std::ptr::null_mut(), + } + }; + + let user_data = UserData(user_data); + let client = FishjamClient::new(parsed, move |event: SdkEvent| { + if let Some(callback) = on_event { + let bytes = event.encode_to_vec(); + unsafe { callback(user_data.get(), bytes.as_ptr(), bytes.len()) }; + } + }); + Box::into_raw(Box::new(client)) +} + +/// # Safety +/// `client` is either null or a pointer returned by [`fishjam_client_new`] and not yet freed. +#[no_mangle] +pub unsafe extern "C" fn fishjam_client_free(client: *mut FishjamClient) { + if !client.is_null() { + drop(unsafe { Box::from_raw(client) }); + } +} + +/// # Safety +/// `client` is a live client. `req` points at `req_len` bytes. `out` is a live `FishjamBuf` +/// the caller owns; its `data` must be released with [`fishjam_buf_free`]. +#[no_mangle] +pub unsafe extern "C" fn fishjam_client_call( + client: *mut FishjamClient, + req: *const u8, + req_len: usize, + out: *mut FishjamBuf, +) -> i32 { + if client.is_null() || out.is_null() { + return NULL_ARGUMENT; + } + let client = unsafe { &*client }; + + let request = if req.is_null() { + return write_response(out, error_response("empty request"), DECODE_ERROR); + } else { + match SdkRequest::decode(unsafe { std::slice::from_raw_parts(req, req_len) }) { + Ok(request) => request, + Err(_) => return write_response(out, error_response("invalid request"), DECODE_ERROR), + } + }; + + let result = match request.content { + Some(sdk_request::Content::JoinRoom(join)) => client + .join_room(&join.peer_token, &join.peer_metadata_json) + .map(|_| empty()), + Some(sdk_request::Content::LeaveRoom(_)) => { + client.leave_room(); + Ok(empty()) + } + Some(sdk_request::Content::AddTrack(add)) => client + .add_track(add.kind(), add.metadata.unwrap_or_default()) + .map(|track_id| { + sdk_response::Content::AddTrack(crate::sdk::AddTrackResult { track_id }) + }), + Some(sdk_request::Content::RemoveTrack(remove)) => { + client.remove_track(&remove.track_id).map(|_| empty()) + } + Some(sdk_request::Content::CreateOfferResult(result)) => { + client.create_offer_result(&result.sdp); + Ok(empty()) + } + Some(sdk_request::Content::SetLocalDescriptionResult(result)) => { + client.set_local_description_result(result.mid_to_track_id); + Ok(empty()) + } + Some(sdk_request::Content::SetRemoteDescriptionResult(_)) => { + client.set_remote_description_result(); + Ok(empty()) + } + Some(sdk_request::Content::AddIceCandidateResult(_)) => { + client.add_ice_candidate_result(); + Ok(empty()) + } + Some(sdk_request::Content::LocalIceCandidate(candidate)) => { + client.local_ice_candidate( + &candidate.candidate, + &candidate.sdp_mid, + candidate.sdp_m_line_index, + &candidate.username_fragment, + ); + Ok(empty()) + } + Some(sdk_request::Content::RemoteTrackReady(ready)) => { + client.remote_track_ready(&ready.mid); + Ok(empty()) + } + Some(sdk_request::Content::PeerConnectionStateChanged(changed)) => { + client.peer_connection_state_changed( + &changed.connection_state, + &changed.ice_connection_state, + ); + Ok(empty()) + } + None => Err(error_message("unsupported request")), + }; + + match result { + Ok(content) => write_response( + out, + SdkResponse { + content: Some(content), + }, + OK, + ), + Err(error) => write_response( + out, + SdkResponse { + content: Some(sdk_response::Content::Error(error)), + }, + ILLEGAL_CALL, + ), + } +} + +/// # Safety +/// `buf.data` is either null or a pointer previously written by [`fishjam_client_call`]. +#[no_mangle] +pub unsafe extern "C" fn fishjam_buf_free(buf: FishjamBuf) { + if buf.data.is_null() { + return; + } + drop(unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(buf.data, buf.len)) }); +} + +fn empty() -> sdk_response::Content { + sdk_response::Content::Empty(Empty {}) +} + +fn error_message(message: &str) -> Error { + Error { + message: message.to_owned(), + ..Default::default() + } +} + +fn error_response(message: &str) -> SdkResponse { + SdkResponse { + content: Some(sdk_response::Content::Error(error_message(message))), + } +} + +fn write_response(out: *mut FishjamBuf, response: SdkResponse, code: i32) -> i32 { + let bytes = response.encode_to_vec(); + let len = bytes.len(); + let data = Box::into_raw(bytes.into_boxed_slice()) as *mut u8; + unsafe { + (*out).data = data; + (*out).len = len; + } + code +} diff --git a/packages/fishjam-core/src/auth.rs b/packages/fishjam-core/src/auth.rs new file mode 100644 index 00000000..8d5239bb --- /dev/null +++ b/packages/fishjam-core/src/auth.rs @@ -0,0 +1,26 @@ +pub const AUTH_ERROR_REASONS: [&str; 5] = [ + "missing token", + "invalid token", + "expired token", + "room not found", + "peer not found", +]; + +pub const JOIN_ERRORS: [&str; 4] = [ + "reached peers limit", + "room not found", + "node not found", + "invalid sdk version", +]; + +pub fn normalize_close_reason(reason: &str) -> String { + reason.trim().to_lowercase() +} + +pub fn is_auth_error(reason: &str) -> bool { + AUTH_ERROR_REASONS.contains(&reason) +} + +pub fn is_join_error(reason: &str) -> bool { + JOIN_ERRORS.contains(&reason) +} diff --git a/packages/fishjam-core/src/client.rs b/packages/fishjam-core/src/client.rs new file mode 100644 index 00000000..4dc9f648 --- /dev/null +++ b/packages/fishjam-core/src/client.rs @@ -0,0 +1,999 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex, MutexGuard, Weak}; + +use prost::Message; + +use crate::auth::{is_auth_error, is_join_error, normalize_close_reason}; +use crate::fishjam::media_events::{peer, server}; +use crate::fishjam::{peer_message, PeerMessage}; +use crate::negotiation::{self, LocalTrack, RemotePeer}; +use crate::sdk::{ + sdk_event, AddIceCandidate, AddSendonlyTransceiver, ClientConfig, ClosePeerConnection, + CreateOffer, CreatePeerConnection, Error, IceServer, NegotiationStatus, PeerStatus, + PeerStatusChanged, RemoveSendTrack, RoomState, RoomStateChanged, SdkEvent, SdpType, + SetLocalDescription, SetRemoteDescription, TrackKind, TrackMetadata, + UpdateRecvonlyTransceivers, +}; +use crate::signaling::{SignalingSocket, SocketConnector, SocketListener, TungsteniteConnector}; +use crate::url::websocket_url; + +/// Sent in `authRequest`, same shape as `${clientType}-${packageVersion}` in ts-client. +pub fn sdk_version() -> String { + format!("mobile-{}", env!("CARGO_PKG_VERSION")) +} + +type EventCallback = Box; + +pub struct FishjamClient { + inner: Arc, +} + +struct Inner { + /// Held while events are delivered, so `on_event` sees them in the order they happened. + emit_lock: Mutex<()>, + state: Mutex, + connector: Box, + on_event: EventCallback, +} + +struct State { + config: ClientConfig, + peer_status: PeerStatus, + socket: Option>, + socket_open: bool, + /// Incremented for every socket, so callbacks from an old socket are ignored. + generation: u64, + peer_token: String, + peer_metadata_json: String, + local_endpoint_id: Option, + local_metadata_json: String, + is_audio_only: bool, + ice_servers: Vec, + peer_connection_open: bool, + ongoing_renegotiation: bool, + recv_video: i32, + recv_audio: i32, + recv_video_mids: Vec, + recv_audio_mids: Vec, + local_tracks: Vec, + remote_peers: Vec, + mid_to_track_id: HashMap, + pending_offer_sdp: String, +} + +impl FishjamClient { + pub fn new(config: ClientConfig, on_event: impl Fn(SdkEvent) + Send + Sync + 'static) -> Self { + Self::with_connector(config, Box::new(TungsteniteConnector), on_event) + } + + pub fn with_connector( + config: ClientConfig, + connector: Box, + on_event: impl Fn(SdkEvent) + Send + Sync + 'static, + ) -> Self { + Self { + inner: Arc::new(Inner { + emit_lock: Mutex::new(()), + state: Mutex::new(State { + config, + peer_status: PeerStatus::Idle, + socket: None, + socket_open: false, + generation: 0, + peer_token: String::new(), + peer_metadata_json: String::new(), + local_endpoint_id: None, + local_metadata_json: String::new(), + is_audio_only: false, + ice_servers: Vec::new(), + peer_connection_open: false, + ongoing_renegotiation: false, + recv_video: 0, + recv_audio: 0, + recv_video_mids: Vec::new(), + recv_audio_mids: Vec::new(), + local_tracks: Vec::new(), + remote_peers: Vec::new(), + mid_to_track_id: HashMap::new(), + pending_offer_sdp: String::new(), + }), + connector, + on_event: Box::new(on_event), + }), + } + } + + pub fn join_room(&self, peer_token: &str, peer_metadata_json: &str) -> Result<(), Error> { + let fishjam_id = self.inner.lock_state().config.fishjam_id.clone(); + let url = websocket_url(&fishjam_id).map_err(|message| Error { + message, + ..Default::default() + })?; + + let weak = Arc::downgrade(&self.inner); + self.inner.process(|state, events| { + if state.socket.is_some() { + state.disconnect(events); + } + state.generation += 1; + state.peer_token = peer_token.to_owned(); + state.peer_metadata_json = peer_metadata_json.to_owned(); + state.set_status(PeerStatus::Connecting, None, events); + + let listener = Arc::new(Listener { + inner: weak, + generation: state.generation, + }); + state.socket = Some(self.inner.connector.connect(&url, listener)); + }); + Ok(()) + } + + pub fn leave_room(&self) { + self.inner.process(|state, events| state.disconnect(events)); + } + + pub fn peer_status(&self) -> PeerStatus { + self.inner.lock_state().peer_status + } + + pub fn room_state(&self) -> RoomState { + let state = self.inner.lock_state(); + state.room_snapshot() + } + + pub fn add_track(&self, kind: TrackKind, metadata: TrackMetadata) -> Result { + let mut result = Err(error("add track failed")); + self.inner.process(|state, events| { + result = state.add_track(kind, metadata.clone(), events); + }); + result + } + + pub fn remove_track(&self, track_id: &str) -> Result<(), Error> { + let mut result = Err(error("remove track failed")); + self.inner.process(|state, events| { + result = state.remove_track(track_id, events); + }); + result + } + + pub fn create_offer_result(&self, sdp: &str) { + let sdp = sdp.to_owned(); + self.inner + .process(|state, events| state.create_offer_result(sdp.clone(), events)); + } + + pub fn set_local_description_result(&self, mid_to_track_id: HashMap) { + self.inner.process(|state, events| { + state.set_local_description_result(mid_to_track_id.clone(), events) + }); + } + + pub fn set_remote_description_result(&self) {} + + pub fn add_ice_candidate_result(&self) {} + + pub fn local_ice_candidate( + &self, + candidate: &str, + sdp_mid: &str, + sdp_m_line_index: i32, + username_fragment: &str, + ) { + if candidate.is_empty() { + return; + } + let candidate = candidate.to_owned(); + let sdp_mid = sdp_mid.to_owned(); + let username_fragment = username_fragment.to_owned(); + self.inner.process(|state, _events| { + state.send_media_event(peer::media_event::Content::Candidate( + crate::fishjam::media_events::Candidate { + candidate, + sdp_m_line_index, + sdp_mid, + username_fragment, + }, + )); + }); + } + + pub fn remote_track_ready(&self, mid: &str) { + let mid = mid.to_owned(); + self.inner + .process(|state, events| state.remote_track_ready(&mid, events)); + } + + pub fn peer_connection_state_changed( + &self, + connection_state: &str, + ice_connection_state: &str, + ) { + let connection_state = connection_state.to_owned(); + let ice_connection_state = ice_connection_state.to_owned(); + self.inner.process(|state, events| { + state.peer_connection_state_changed(&connection_state, &ice_connection_state, events); + }); + } +} + +fn error(message: &str) -> Error { + Error { + message: message.to_owned(), + ..Default::default() + } +} + +impl Drop for FishjamClient { + fn drop(&mut self) { + // Hold the event lock so a callback cannot run after this returns. + self.inner.process(|state, _events| { + if let Some(socket) = state.socket.take() { + socket.close(); + } + state.socket_open = false; + state.generation += 1; + }); + } +} + +impl Inner { + fn lock_state(&self) -> MutexGuard<'_, State> { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn process(&self, f: impl FnOnce(&mut State, &mut Vec)) { + let _emit = self + .emit_lock + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut events = Vec::new(); + { + let mut state = self.lock_state(); + f(&mut state, &mut events); + } + for event in events { + (self.on_event)(event); + } + } + + fn process_socket(&self, generation: u64, f: impl FnOnce(&mut State, &mut Vec)) { + self.process(|state, events| { + if state.generation == generation && state.socket.is_some() { + f(state, events); + } + }); + } +} + +struct Listener { + inner: Weak, + generation: u64, +} + +impl SocketListener for Listener { + fn on_open(&self) { + if let Some(inner) = self.inner.upgrade() { + inner.process_socket(self.generation, |state, _| state.on_socket_open()); + } + } + + fn on_message(&self, data: Vec) { + if let Some(inner) = self.inner.upgrade() { + inner.process_socket(self.generation, |state, events| { + state.on_socket_message(&data, events) + }); + } + } + + fn on_close(&self, reason: String) { + if let Some(inner) = self.inner.upgrade() { + inner.process_socket(self.generation, |state, events| { + state.on_socket_close(&reason, events) + }); + } + } +} + +impl State { + fn set_status(&mut self, status: PeerStatus, error: Option, events: &mut Vec) { + if self.peer_status == status && error.is_none() { + return; + } + self.peer_status = status; + events.push(SdkEvent { + content: Some(sdk_event::Content::PeerStatusChanged(PeerStatusChanged { + peer_status: status as i32, + error, + })), + }); + } + + fn send(&self, content: peer_message::Content) { + if let Some(socket) = &self.socket { + socket.send( + PeerMessage { + content: Some(content), + } + .encode_to_vec(), + ); + } + } + + fn send_media_event(&self, content: peer::media_event::Content) { + self.send(peer_message::Content::PeerMediaEvent(peer::MediaEvent { + content: Some(content), + })); + } + + /// Same as `client.disconnect()`: send `disconnect` when the socket is open, then close it. + fn disconnect(&mut self, events: &mut Vec) { + if self.socket_open { + self.send_media_event(peer::media_event::Content::Disconnect( + peer::media_event::Disconnect {}, + )); + } + if let Some(socket) = self.socket.take() { + socket.close(); + } + self.socket_open = false; + self.generation += 1; + let had_session = self.local_endpoint_id.is_some() || self.peer_connection_open; + self.reset_media(events); + if had_session { + self.emit_room(events); + } + self.set_status(PeerStatus::Idle, None, events); + } + + fn on_socket_open(&mut self) { + self.socket_open = true; + self.send(peer_message::Content::AuthRequest( + peer_message::AuthRequest { + token: self.peer_token.clone(), + sdk_version: sdk_version(), + }, + )); + } + + fn on_socket_message(&mut self, data: &[u8], events: &mut Vec) { + let message = match PeerMessage::decode(data) { + Ok(message) => message, + Err(error) => { + log::warn!("Received invalid control message, error: {error}"); + return; + } + }; + match message.content { + Some(peer_message::Content::Authenticated(authenticated)) => { + self.on_authenticated(authenticated) + } + Some(peer_message::Content::AuthRequest(_)) => { + log::warn!("Received unexpected control message: authRequest"); + } + Some(peer_message::Content::ServerMediaEvent(event)) => { + if let Some(content) = event.content { + self.on_server_event(content, events); + } + } + _ => {} + } + } + + fn on_authenticated(&mut self, authenticated: peer_message::Authenticated) { + self.is_audio_only = authenticated.room_type() == peer_message::RoomType::AudioOnly; + if let Some(deprecation) = &authenticated.sdk_deprecation { + match deprecation.status() { + peer_message::sdk_deprecation::Status::Unsupported => { + log::error!("{}", deprecation.message) + } + peer_message::sdk_deprecation::Status::Deprecated => { + log::warn!("{}", deprecation.message) + } + _ => {} + } + } + self.send_media_event(peer::media_event::Content::Connect( + peer::media_event::Connect { + metadata_json: self.peer_metadata_json.clone(), + }, + )); + } + + fn on_server_event( + &mut self, + content: server::media_event::Content, + events: &mut Vec, + ) { + if self.local_endpoint_id.is_none() { + match content { + server::media_event::Content::Connected(connected) => { + self.on_connected(connected, events) + } + server::media_event::Content::Error(server_error) => { + self.set_status( + PeerStatus::Error, + Some(error(&server_error.message)), + events, + ); + } + _ => {} + } + return; + } + match content { + server::media_event::Content::Connected(connected) => { + self.on_connected(connected, events) + } + server::media_event::Content::OfferData(offer) => self.on_offer_data(offer, events), + server::media_event::Content::SdpAnswer(answer) => self.on_sdp_answer(answer, events), + server::media_event::Content::Candidate(candidate) => { + self.emit( + sdk_event::Content::AddIceCandidate(AddIceCandidate { + candidate: candidate.candidate, + sdp_mid: candidate.sdp_mid, + sdp_m_line_index: candidate.sdp_m_line_index, + username_fragment: candidate.username_fragment, + }), + events, + ); + } + server::media_event::Content::EndpointAdded(added) => { + if added.endpoint_id == self.local_id() { + return; + } + self.upsert_remote_peer(added.endpoint_id, added.metadata_json); + self.emit_room(events); + } + server::media_event::Content::EndpointRemoved(removed) => { + self.on_endpoint_removed(&removed.endpoint_id, events); + } + server::media_event::Content::EndpointUpdated(updated) => { + if updated.endpoint_id == self.local_id() { + return; + } + if let Some(peer) = self + .remote_peers + .iter_mut() + .find(|peer| peer.id == updated.endpoint_id) + { + peer.metadata_json = updated.metadata_json; + } else { + return; + } + self.emit_room(events); + } + server::media_event::Content::TracksAdded(added) => { + self.ongoing_renegotiation = true; + if added.endpoint_id == self.local_id() { + return; + } + self.add_remote_tracks(added.endpoint_id, added.track_id_to_track); + self.emit_room(events); + } + server::media_event::Content::TracksRemoved(removed) => { + if removed.endpoint_id == self.local_id() { + return; + } + self.remove_remote_tracks(&removed.track_ids); + self.emit_room(events); + } + server::media_event::Content::TrackUpdated(updated) => { + if updated.endpoint_id == self.local_id() { + return; + } + self.update_remote_track(&updated.track_id, updated.metadata_json); + self.emit_room(events); + } + server::media_event::Content::TrackVariantSwitched(switched) => { + self.set_remote_encoding(&switched.track_id, switched.variant); + self.emit_room(events); + } + server::media_event::Content::Error(server_error) => { + self.set_status( + PeerStatus::Error, + Some(error(&server_error.message)), + events, + ); + } + _ => {} + } + } + + fn on_socket_close(&mut self, reason: &str, events: &mut Vec) { + log::warn!("Socket closed with reason: {reason}"); + self.socket = None; + self.socket_open = false; + + let normalized = normalize_close_reason(reason); + let error = if is_auth_error(&normalized) { + Error { + message: normalized.clone(), + auth_reason: normalized, + ..Default::default() + } + } else if is_join_error(&normalized) { + Error { + message: normalized.clone(), + join_reason: normalized, + ..Default::default() + } + } else { + Error { + message: format!("Socket closed: {reason}"), + ..Default::default() + } + }; + let had_session = self.local_endpoint_id.is_some() || self.peer_connection_open; + self.reset_media(events); + if had_session { + self.emit_room(events); + } + self.set_status(PeerStatus::Error, Some(error), events); + } + + fn local_id(&self) -> String { + self.local_endpoint_id.clone().unwrap_or_default() + } + + fn room_snapshot(&self) -> RoomState { + negotiation::room_snapshot( + self.local_endpoint_id.as_deref(), + &self.local_metadata_json, + &self.local_tracks, + &self.remote_peers, + ) + } + + fn emit(&self, content: sdk_event::Content, events: &mut Vec) { + events.push(SdkEvent { + content: Some(content), + }); + } + + fn emit_room(&self, events: &mut Vec) { + self.emit( + sdk_event::Content::RoomStateChanged(Box::new(RoomStateChanged { + room_state: Some(self.room_snapshot()), + })), + events, + ); + } + + fn reset_media(&mut self, events: &mut Vec) { + if self.peer_connection_open { + self.peer_connection_open = false; + self.emit( + sdk_event::Content::ClosePeerConnection(ClosePeerConnection {}), + events, + ); + } + self.local_endpoint_id = None; + self.local_metadata_json.clear(); + self.local_tracks.clear(); + self.remote_peers.clear(); + self.ice_servers.clear(); + self.ongoing_renegotiation = false; + self.recv_video = 0; + self.recv_audio = 0; + self.recv_video_mids.clear(); + self.recv_audio_mids.clear(); + self.mid_to_track_id.clear(); + self.pending_offer_sdp.clear(); + } + + fn on_connected( + &mut self, + connected: server::media_event::Connected, + events: &mut Vec, + ) { + let endpoint_id = connected.endpoint_id.clone(); + self.local_endpoint_id = Some(endpoint_id.clone()); + self.ice_servers = connected + .ice_servers + .into_iter() + .map(|server| IceServer { + credential: server.credential, + urls: server.urls, + username: server.username, + }) + .collect(); + self.remote_peers.clear(); + self.local_metadata_json.clear(); + for (id, endpoint) in connected.endpoint_id_to_endpoint { + if id == endpoint_id { + self.local_metadata_json = endpoint.metadata_json; + } else if negotiation::is_peer_type(&endpoint.endpoint_type) { + self.remote_peers.push(negotiation::remote_peer( + id, + endpoint.metadata_json, + endpoint.track_id_to_track, + )); + } + } + self.emit_room(events); + self.set_status(PeerStatus::Connected, None, events); + } + + fn on_offer_data(&mut self, offer: server::media_event::OfferData, events: &mut Vec) { + if !self.peer_connection_open { + self.peer_connection_open = true; + self.emit( + sdk_event::Content::CreatePeerConnection(CreatePeerConnection { + ice_servers: self.ice_servers.clone(), + }), + events, + ); + } + if let Some(tracks) = offer.tracks_types { + let video_stop = self.take_recv_mids(TrackKind::Video, tracks.video); + let audio_stop = self.take_recv_mids(TrackKind::Audio, tracks.audio); + let video_to_add = (tracks.video - self.recv_video).max(0); + let audio_to_add = (tracks.audio - self.recv_audio).max(0); + self.recv_video += video_to_add; + self.recv_audio += audio_to_add; + let mut mids_to_stop = video_stop; + mids_to_stop.extend(audio_stop); + self.emit( + sdk_event::Content::UpdateRecvonlyTransceivers(UpdateRecvonlyTransceivers { + video_to_add, + audio_to_add, + mids_to_stop, + }), + events, + ); + } + let queued: Vec<_> = self + .local_tracks + .iter_mut() + .filter(|track| !track.sent_to_connection) + .map(|track| { + track.sent_to_connection = true; + let kind = TrackKind::try_from(track.kind).unwrap_or(TrackKind::Unspecified); + ( + track.id.clone(), + track.kind, + negotiation::send_encodings(kind, &track.metadata), + ) + }) + .collect(); + for (track_id, kind, send_encodings) in queued { + self.emit( + sdk_event::Content::AddSendonlyTransceiver(AddSendonlyTransceiver { + track_id, + kind, + send_encodings, + }), + events, + ); + } + self.emit(sdk_event::Content::CreateOffer(CreateOffer {}), events); + } + + fn on_sdp_answer( + &mut self, + answer: server::media_event::SdpAnswer, + events: &mut Vec, + ) { + self.ongoing_renegotiation = false; + for (mid, track_id) in &answer.mid_to_track_id { + self.mid_to_track_id.insert(mid.clone(), track_id.clone()); + } + self.remember_recv_mids(); + for track in &mut self.local_tracks { + if answer.mid_to_track_id.values().any(|id| id == &track.id) { + track.negotiation = NegotiationStatus::Done as i32; + } + } + self.emit( + sdk_event::Content::SetRemoteDescription(SetRemoteDescription { + r#type: SdpType::Answer as i32, + sdp: answer.sdp, + mid_to_track_id: answer.mid_to_track_id, + }), + events, + ); + self.emit_room(events); + } + + fn add_track( + &mut self, + kind: TrackKind, + metadata: TrackMetadata, + events: &mut Vec, + ) -> Result { + let Some(endpoint_id) = self.local_endpoint_id.clone() else { + return Err(error( + "Cannot add tracks before being accepted by the server", + )); + }; + let pending = self.ongoing_renegotiation; + self.ongoing_renegotiation = true; + let id = format!("{endpoint_id}:{}", uuid::Uuid::new_v4()); + self.local_tracks.push(LocalTrack { + id: id.clone(), + kind: kind as i32, + metadata, + negotiation: NegotiationStatus::Awaiting as i32, + sent_to_connection: false, + }); + self.emit_room(events); + if !pending { + self.send_media_event(peer::media_event::Content::RenegotiateTracks( + peer::media_event::RenegotiateTracks {}, + )); + } + Ok(id) + } + + fn remove_track(&mut self, track_id: &str, events: &mut Vec) -> Result<(), Error> { + if self.local_endpoint_id.is_none() { + return Err(error( + "Cannot remove tracks before being accepted by the server", + )); + } + let Some(index) = self + .local_tracks + .iter() + .position(|track| track.id == track_id) + else { + return Err(error("Cannot find track")); + }; + let track = self.local_tracks.remove(index); + self.ongoing_renegotiation = true; + if track.sent_to_connection { + self.emit( + sdk_event::Content::RemoveSendTrack(RemoveSendTrack { track_id: track.id }), + events, + ); + } + self.send_media_event(peer::media_event::Content::RenegotiateTracks( + peer::media_event::RenegotiateTracks {}, + )); + self.emit_room(events); + Ok(()) + } + + fn create_offer_result(&mut self, sdp: String, events: &mut Vec) { + self.pending_offer_sdp = sdp.clone(); + self.emit( + sdk_event::Content::SetLocalDescription(SetLocalDescription { + r#type: SdpType::Offer as i32, + sdp, + }), + events, + ); + } + + fn set_local_description_result( + &mut self, + mid_to_track_id: HashMap, + events: &mut Vec, + ) { + self.mid_to_track_id.extend(mid_to_track_id.clone()); + self.remember_recv_mids(); + let mut track_id_to_metadata_json = HashMap::new(); + let mut track_id_to_bitrates = HashMap::new(); + for track in &mut self.local_tracks { + track.negotiation = NegotiationStatus::Offered as i32; + let kind = TrackKind::try_from(track.kind).unwrap_or(TrackKind::Unspecified); + track_id_to_metadata_json.insert( + track.id.clone(), + negotiation::metadata_json(&track.metadata), + ); + let mut bitrates = negotiation::variant_bitrates(kind, &track.metadata); + bitrates.track_id = track.id.clone(); + track_id_to_bitrates.insert(track.id.clone(), bitrates); + } + self.send_media_event(peer::media_event::Content::SdpOffer( + peer::media_event::SdpOffer { + sdp: self.pending_offer_sdp.clone(), + track_id_to_metadata_json, + track_id_to_bitrates, + mid_to_track_id, + }, + )); + self.emit_room(events); + } + + fn remote_track_ready(&mut self, mid: &str, events: &mut Vec) { + let Some(track_id) = self.mid_to_track_id.get(mid).cloned() else { + return; + }; + let mut found = false; + for peer in &mut self.remote_peers { + if let Some(track) = peer.tracks.iter_mut().find(|track| track.id == track_id) { + track.ready = true; + found = true; + } + } + if found { + self.emit_room(events); + } + } + + fn peer_connection_state_changed( + &mut self, + connection_state: &str, + ice_connection_state: &str, + events: &mut Vec, + ) { + if connection_state == "failed" { + self.set_status( + PeerStatus::Error, + Some(error("RTCPeerConnection failed")), + events, + ); + } else if ice_connection_state == "failed" { + self.set_status( + PeerStatus::Error, + Some(error("ICE connection failed")), + events, + ); + } + if ice_connection_state == "disconnected" { + self.ongoing_renegotiation = true; + self.send_media_event(peer::media_event::Content::RenegotiateTracks( + peer::media_event::RenegotiateTracks {}, + )); + } + } + + fn remember_recv_mids(&mut self) { + let local: std::collections::HashSet<_> = self + .local_tracks + .iter() + .map(|track| track.id.clone()) + .collect(); + for (mid, track_id) in &self.mid_to_track_id { + if local.contains(track_id) { + continue; + } + let Some(kind) = self.remote_kind(track_id) else { + continue; + }; + let mids = match kind { + TrackKind::Video => &mut self.recv_video_mids, + TrackKind::Audio => &mut self.recv_audio_mids, + _ => continue, + }; + if !mids.iter().any(|known| known == mid) { + mids.push(mid.clone()); + } + } + } + + fn remote_kind(&self, track_id: &str) -> Option { + self.remote_peers.iter().find_map(|peer| { + peer.tracks + .iter() + .find(|track| track.id == track_id) + .and_then(|track| negotiation::media_kind(&track.metadata_json)) + }) + } + + /// The web client stops receive transceivers when the server asks for fewer of them. + /// Without that, a peer who leaves and rejoins is put back on the old transceiver, + /// and the platform never gets a new track to render. + fn take_recv_mids(&mut self, kind: TrackKind, wanted: i32) -> Vec { + let (count, mids) = match kind { + TrackKind::Video => (&mut self.recv_video, &mut self.recv_video_mids), + TrackKind::Audio => (&mut self.recv_audio, &mut self.recv_audio_mids), + _ => return Vec::new(), + }; + let excess = (*count - wanted).max(0) as usize; + if excess == 0 { + return Vec::new(); + } + let present: std::collections::HashSet<_> = self + .remote_peers + .iter() + .flat_map(|peer| peer.tracks.iter().map(|track| track.id.clone())) + .collect(); + let mut stop = Vec::new(); + let mut index = 0; + while index < mids.len() && stop.len() < excess { + let track_id = self.mid_to_track_id.get(&mids[index]).cloned(); + let gone = track_id.as_ref().is_none_or(|id| !present.contains(id)); + if gone { + stop.push(mids.remove(index)); + } else { + index += 1; + } + } + while stop.len() < excess && !mids.is_empty() { + stop.push(mids.pop().expect("mid list is not empty")); + } + for mid in &stop { + self.mid_to_track_id.remove(mid); + } + *count -= stop.len() as i32; + stop + } + + fn on_endpoint_removed(&mut self, endpoint_id: &str, events: &mut Vec) { + if self.local_endpoint_id.as_deref() == Some(endpoint_id) { + self.reset_media(events); + self.emit_room(events); + self.set_status(PeerStatus::Idle, None, events); + return; + } + let before = self.remote_peers.len(); + self.remote_peers.retain(|peer| peer.id != endpoint_id); + if self.remote_peers.len() != before { + self.emit_room(events); + } + } + + fn upsert_remote_peer(&mut self, id: String, metadata_json: String) { + if let Some(peer) = self.remote_peers.iter_mut().find(|peer| peer.id == id) { + peer.metadata_json = metadata_json; + return; + } + self.remote_peers.push(RemotePeer { + id, + metadata_json, + tracks: Vec::new(), + }); + } + + fn add_remote_tracks( + &mut self, + endpoint_id: String, + tracks: HashMap, + ) { + if self.remote_peers.iter().all(|peer| peer.id != endpoint_id) { + self.remote_peers.push(RemotePeer { + id: endpoint_id.clone(), + metadata_json: String::new(), + tracks: Vec::new(), + }); + } + let incoming = negotiation::remote_peer(endpoint_id.clone(), String::new(), tracks); + if let Some(peer) = self + .remote_peers + .iter_mut() + .find(|peer| peer.id == endpoint_id) + { + for track in incoming.tracks { + if let Some(existing) = peer + .tracks + .iter_mut() + .find(|current| current.id == track.id) + { + *existing = track; + } else { + peer.tracks.push(track); + } + } + } + } + + fn remove_remote_tracks(&mut self, track_ids: &[String]) { + for peer in &mut self.remote_peers { + peer.tracks + .retain(|track| !track_ids.iter().any(|id| id == &track.id)); + } + } + + fn update_remote_track(&mut self, track_id: &str, metadata_json: String) { + for peer in &mut self.remote_peers { + if let Some(track) = peer.tracks.iter_mut().find(|track| track.id == track_id) { + track.metadata_json = metadata_json; + return; + } + } + } + + fn set_remote_encoding(&mut self, track_id: &str, variant: i32) { + for peer in &mut self.remote_peers { + if let Some(track) = peer.tracks.iter_mut().find(|track| track.id == track_id) { + track.encoding = variant; + return; + } + } + } +} diff --git a/packages/fishjam-core/src/lib.rs b/packages/fishjam-core/src/lib.rs new file mode 100644 index 00000000..62f791ac --- /dev/null +++ b/packages/fishjam-core/src/lib.rs @@ -0,0 +1,21 @@ +#[allow(clippy::all)] +mod proto { + include!(concat!(env!("OUT_DIR"), "/protos.rs")); +} + +mod abi; +mod auth; +mod client; +mod negotiation; +mod signaling; +mod url; + +pub use abi::{ + fishjam_buf_free, fishjam_client_call, fishjam_client_free, fishjam_client_new, FishjamBuf, + FishjamOnEvent, +}; +pub use client::{sdk_version, FishjamClient}; +pub use proto::fishjam; +pub use proto::fishjam::sdk; +pub use signaling::{SignalingSocket, SocketConnector, SocketListener, TungsteniteConnector}; +pub use url::websocket_url; diff --git a/packages/fishjam-core/src/negotiation.rs b/packages/fishjam-core/src/negotiation.rs new file mode 100644 index 00000000..e3a15fee --- /dev/null +++ b/packages/fishjam-core/src/negotiation.rs @@ -0,0 +1,296 @@ +use std::collections::HashMap; + +use crate::fishjam::media_events::{peer, server, Variant as MediaVariant}; +use crate::sdk::{ + NegotiationStatus, PeerState, RoomState, SendEncoding, SimulcastConfig, TrackKind, + TrackMetadata, TrackState, TrackType, Variant, +}; + +const KBPS: u32 = 1024; + +pub(crate) struct LocalTrack { + pub id: String, + pub kind: i32, + pub metadata: TrackMetadata, + pub negotiation: i32, + pub sent_to_connection: bool, +} + +pub(crate) struct RemoteTrack { + pub id: String, + pub metadata_json: String, + pub simulcast: SimulcastConfig, + pub encoding: i32, + pub ready: bool, +} + +pub(crate) struct RemotePeer { + pub id: String, + pub metadata_json: String, + pub tracks: Vec, +} + +pub(crate) fn is_peer_type(endpoint_type: &str) -> bool { + endpoint_type == "webrtc" || endpoint_type == "exwebrtc" +} + +pub(crate) fn send_encodings(kind: TrackKind, metadata: &TrackMetadata) -> Vec { + if kind != TrackKind::Video { + return Vec::new(); + } + if metadata.r#type() == TrackType::Camera { + return vec![ + encoding("l", 4.0, 150 * KBPS), + encoding("m", 2.0, 500 * KBPS), + encoding("h", 1.0, 1_500 * KBPS), + ]; + } + vec![SendEncoding { + rid: String::new(), + active: true, + scale_resolution_down_by: 0.0, + max_bitrate: 1_500 * KBPS, + }] +} + +pub(crate) fn metadata_json(metadata: &TrackMetadata) -> String { + let mut value = serde_json::Map::new(); + value.insert( + "type".to_owned(), + serde_json::Value::String(track_type_name(metadata.r#type()).to_owned()), + ); + value.insert( + "paused".to_owned(), + serde_json::Value::Bool(metadata.paused), + ); + if let Some(name) = metadata + .display_name + .as_ref() + .filter(|name| !name.is_empty()) + { + value.insert( + "displayName".to_owned(), + serde_json::Value::String(name.clone()), + ); + } + serde_json::Value::Object(value).to_string() +} + +pub(crate) fn variant_bitrates( + kind: TrackKind, + metadata: &TrackMetadata, +) -> peer::media_event::TrackBitrates { + let variant_bitrates = if kind == TrackKind::Audio { + vec![variant_bitrate(MediaVariant::Unspecified, 50_000)] + } else if metadata.r#type() == TrackType::Camera { + vec![ + variant_bitrate(MediaVariant::Low, 150 * KBPS as i32), + variant_bitrate(MediaVariant::Medium, 500 * KBPS as i32), + variant_bitrate(MediaVariant::High, 1_500 * KBPS as i32), + ] + } else { + vec![variant_bitrate( + MediaVariant::Unspecified, + 1_500 * KBPS as i32, + )] + }; + peer::media_event::TrackBitrates { + track_id: String::new(), + variant_bitrates, + } +} + +pub(crate) fn local_simulcast(metadata: &TrackMetadata) -> SimulcastConfig { + if metadata.r#type() == TrackType::Camera { + SimulcastConfig { + enabled: true, + enabled_variants: vec![ + Variant::Low as i32, + Variant::Medium as i32, + Variant::High as i32, + ], + disabled_variants: Vec::new(), + } + } else { + SimulcastConfig::default() + } +} + +pub(crate) fn remote_peer( + id: String, + metadata_json: String, + tracks: HashMap, +) -> RemotePeer { + let mut tracks: Vec<_> = tracks + .into_iter() + .map(|(id, track)| RemoteTrack { + id, + metadata_json: track.metadata_json, + simulcast: simulcast_from_server(track.simulcast_config), + encoding: Variant::Unspecified as i32, + ready: false, + }) + .collect(); + tracks.sort_by(|left, right| left.id.cmp(&right.id)); + RemotePeer { + id, + metadata_json, + tracks, + } +} + +pub(crate) fn room_snapshot( + local_endpoint_id: Option<&str>, + local_metadata_json: &str, + local_tracks: &[LocalTrack], + remote_peers: &[RemotePeer], +) -> RoomState { + RoomState { + local_peer: local_endpoint_id.map(|id| { + peer_state( + id, + local_metadata_json, + local_tracks.iter().map(local_track_state).collect(), + ) + }), + remote_peers: remote_peers + .iter() + .map(|peer| { + peer_state( + &peer.id, + &peer.metadata_json, + peer.tracks.iter().map(remote_track_state).collect(), + ) + }) + .collect(), + } +} + +fn encoding(rid: &str, scale: f64, max_bitrate: u32) -> SendEncoding { + SendEncoding { + rid: rid.to_owned(), + active: true, + scale_resolution_down_by: scale, + max_bitrate, + } +} + +fn variant_bitrate(variant: MediaVariant, bitrate: i32) -> peer::media_event::VariantBitrate { + peer::media_event::VariantBitrate { + variant: variant as i32, + bitrate, + } +} + +fn simulcast_from_server( + config: Option, +) -> SimulcastConfig { + let Some(config) = config else { + return SimulcastConfig::default(); + }; + SimulcastConfig { + enabled: config.enabled, + enabled_variants: config.enabled_variants, + disabled_variants: config.disabled_variants, + } +} + +fn local_track_state(track: &LocalTrack) -> TrackState { + TrackState { + track_id: track.id.clone(), + metadata: Some(track.metadata.clone()), + simulcast_config: Some(local_simulcast(&track.metadata)), + negotiation_status: track.negotiation, + encoding: Variant::Unspecified as i32, + ready: false, + } +} + +pub(crate) fn media_kind(metadata_json: &str) -> Option { + match metadata_from_json(metadata_json).r#type() { + TrackType::Camera | TrackType::ScreenShareVideo | TrackType::CustomVideo => { + Some(TrackKind::Video) + } + TrackType::Microphone | TrackType::ScreenShareAudio | TrackType::CustomAudio => { + Some(TrackKind::Audio) + } + _ => None, + } +} + +fn remote_track_state(track: &RemoteTrack) -> TrackState { + TrackState { + track_id: track.id.clone(), + metadata: Some(metadata_from_json(&track.metadata_json)), + simulcast_config: Some(track.simulcast.clone()), + negotiation_status: NegotiationStatus::Done as i32, + encoding: track.encoding, + ready: track.ready, + } +} + +fn peer_state(id: &str, metadata_json: &str, tracks: Vec) -> PeerState { + let mut peer = PeerState { + id: id.to_owned(), + metadata_json: metadata_json.to_owned(), + tracks: tracks.clone(), + ..Default::default() + }; + for track in tracks { + match track.metadata.as_ref().map(|metadata| metadata.r#type()) { + Some(TrackType::Camera) => peer.camera_track = Some(track), + Some(TrackType::Microphone) => peer.microphone_track = Some(track), + Some(TrackType::ScreenShareVideo) => peer.screen_share_video_track = Some(track), + Some(TrackType::ScreenShareAudio) => peer.screen_share_audio_track = Some(track), + Some(TrackType::CustomVideo) => peer.custom_video_tracks.push(track), + Some(TrackType::CustomAudio) => peer.custom_audio_tracks.push(track), + _ => {} + } + } + peer +} + +fn metadata_from_json(json: &str) -> TrackMetadata { + let value: serde_json::Value = serde_json::from_str(json).unwrap_or_default(); + TrackMetadata { + r#type: track_type_from_name( + value + .get("type") + .and_then(|value| value.as_str()) + .unwrap_or(""), + ) + .into(), + paused: value + .get("paused") + .and_then(|value| value.as_bool()) + .unwrap_or(false), + display_name: value + .get("displayName") + .and_then(|value| value.as_str()) + .map(str::to_owned), + } +} + +fn track_type_from_name(name: &str) -> TrackType { + match name { + "camera" => TrackType::Camera, + "microphone" => TrackType::Microphone, + "screenShareVideo" => TrackType::ScreenShareVideo, + "screenShareAudio" => TrackType::ScreenShareAudio, + "customVideo" => TrackType::CustomVideo, + "customAudio" => TrackType::CustomAudio, + _ => TrackType::Unspecified, + } +} + +fn track_type_name(track_type: TrackType) -> &'static str { + match track_type { + TrackType::Camera => "camera", + TrackType::Microphone => "microphone", + TrackType::ScreenShareVideo => "screenShareVideo", + TrackType::ScreenShareAudio => "screenShareAudio", + TrackType::CustomVideo => "customVideo", + TrackType::CustomAudio => "customAudio", + TrackType::Unspecified => "", + } +} diff --git a/packages/fishjam-core/src/signaling.rs b/packages/fishjam-core/src/signaling.rs new file mode 100644 index 00000000..0a8f12b9 --- /dev/null +++ b/packages/fishjam-core/src/signaling.rs @@ -0,0 +1,114 @@ +use std::sync::{Arc, LazyLock}; + +use futures_util::{SinkExt, StreamExt}; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::Message; + +/// Receives socket callbacks. Implementations must tolerate calls from any thread. +pub trait SocketListener: Send + Sync { + fn on_open(&self); + fn on_message(&self, data: Vec); + fn on_close(&self, reason: String); +} + +pub trait SignalingSocket: Send { + fn send(&self, data: Vec); + fn close(&self); +} + +/// Opens signaling sockets. `connect` must not call `listener` before it returns. +pub trait SocketConnector: Send + Sync { + fn connect(&self, url: &str, listener: Arc) -> Box; +} + +/// Shared by all clients, so a runtime is never dropped on one of its own threads. +static RUNTIME: LazyLock = LazyLock::new(|| { + // Fails only when the app already installed a provider, which is fine to keep. + let _ = rustls::crypto::ring::default_provider().install_default(); + tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .thread_name("fishjam-core-socket") + .enable_all() + .build() + .expect("tokio runtime") +}); + +pub struct TungsteniteConnector; + +enum Command { + Send(Vec), + Close, +} + +struct TungsteniteSocket { + commands: mpsc::UnboundedSender, +} + +impl SignalingSocket for TungsteniteSocket { + fn send(&self, data: Vec) { + let _ = self.commands.send(Command::Send(data)); + } + + fn close(&self) { + let _ = self.commands.send(Command::Close); + } +} + +impl SocketConnector for TungsteniteConnector { + fn connect(&self, url: &str, listener: Arc) -> Box { + let (commands, receiver) = mpsc::unbounded_channel(); + RUNTIME.spawn(run_socket(url.to_owned(), listener, receiver)); + Box::new(TungsteniteSocket { commands }) + } +} + +async fn run_socket( + url: String, + listener: Arc, + mut commands: mpsc::UnboundedReceiver, +) { + let stream = match tokio_tungstenite::connect_async(url.as_str()).await { + Ok((stream, _)) => stream, + Err(error) => { + log::warn!("Socket connection failed: {error}"); + listener.on_close(error.to_string()); + return; + } + }; + listener.on_open(); + + let (mut write, mut read) = stream.split(); + loop { + tokio::select! { + command = commands.recv() => match command { + Some(Command::Send(data)) => { + if let Err(error) = write.send(Message::Binary(data.into())).await { + listener.on_close(error.to_string()); + return; + } + } + Some(Command::Close) | None => { + let _ = write.send(Message::Close(None)).await; + let _ = write.close().await; + return; + } + }, + message = read.next() => match message { + Some(Ok(Message::Binary(data))) => listener.on_message(data.to_vec()), + Some(Ok(Message::Close(frame))) => { + listener.on_close(frame.map(|frame| frame.reason.to_string()).unwrap_or_default()); + return; + } + Some(Ok(_)) => {} + Some(Err(error)) => { + listener.on_close(error.to_string()); + return; + } + None => { + listener.on_close(String::new()); + return; + } + }, + } + } +} diff --git a/packages/fishjam-core/src/url.rs b/packages/fishjam-core/src/url.rs new file mode 100644 index 00000000..27ec1f34 --- /dev/null +++ b/packages/fishjam-core/src/url.rs @@ -0,0 +1,24 @@ +const WEBSOCKET_PATH: &str = "socket/peer/websocket"; + +/// Same rules as `resolveFishjamUrl`, `httpToWebsocketUrl`, and `getUrl` in the web clients. +pub fn websocket_url(fishjam_id: &str) -> Result { + let fishjam_id = fishjam_id.trim(); + if fishjam_id.is_empty() { + return Err("fishjamId is required".to_owned()); + } + + let url = if fishjam_id.starts_with("http://") || fishjam_id.starts_with("https://") { + fishjam_id.to_owned() + } else { + format!("https://fishjam.io/api/v1/connect/{fishjam_id}") + }; + let url = url + .replacen("https://", "wss://", 1) + .replacen("http://", "ws://", 1); + + if url.ends_with('/') { + Ok(format!("{url}{WEBSOCKET_PATH}")) + } else { + Ok(format!("{url}/{WEBSOCKET_PATH}")) + } +} diff --git a/packages/fishjam-core/tests/abi.rs b/packages/fishjam-core/tests/abi.rs new file mode 100644 index 00000000..bb6cfe67 --- /dev/null +++ b/packages/fishjam-core/tests/abi.rs @@ -0,0 +1,193 @@ +use std::os::raw::c_void; +use std::sync::mpsc; +use std::time::Duration; + +use fishjam_core::fishjam::{peer_message, PeerMessage}; +use fishjam_core::sdk::{ + sdk_event, sdk_request, sdk_response, ClientConfig, JoinRoom, LeaveRoom, PeerStatus, SdkEvent, + SdkRequest, SdkResponse, +}; +use fishjam_core::{ + fishjam_buf_free, fishjam_client_call, fishjam_client_free, fishjam_client_new, FishjamBuf, +}; +use futures_util::{SinkExt, StreamExt}; +use prost::Message; +use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; +use tokio_tungstenite::tungstenite::protocol::CloseFrame; + +unsafe extern "C" fn on_event(user_data: *mut c_void, event: *const u8, len: usize) { + let sender = unsafe { &*(user_data as *const mpsc::Sender) }; + let bytes = unsafe { std::slice::from_raw_parts(event, len) }; + sender.send(SdkEvent::decode(bytes).unwrap()).unwrap(); +} + +fn call( + client: *mut fishjam_core::FishjamClient, + request: sdk_request::Content, +) -> (i32, SdkResponse) { + let bytes = SdkRequest { + content: Some(request), + } + .encode_to_vec(); + let mut out = FishjamBuf { + data: std::ptr::null_mut(), + len: 0, + }; + let code = unsafe { fishjam_client_call(client, bytes.as_ptr(), bytes.len(), &mut out) }; + let response = + SdkResponse::decode(unsafe { std::slice::from_raw_parts(out.data, out.len) }).unwrap(); + unsafe { fishjam_buf_free(out) }; + (code, response) +} + +fn next_status(events: &mpsc::Receiver) -> sdk_event::Content { + loop { + let event = events + .recv_timeout(Duration::from_secs(10)) + .expect("event in time"); + if let Some(content @ sdk_event::Content::PeerStatusChanged(_)) = event.content { + return content; + } + } +} + +/// Accepts one peer, returns the auth token it sent, then closes with `invalid token`. +fn spawn_server() -> (String, mpsc::Receiver) { + let (port_tx, port_rx) = mpsc::channel(); + let (token_tx, token_rx) = mpsc::channel(); + std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async move { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + port_tx.send(listener.local_addr().unwrap().port()).unwrap(); + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap(); + while let Some(Ok(message)) = socket.next().await { + if message.is_binary() { + let peer_message = PeerMessage::decode(message.into_data()).unwrap(); + if let Some(peer_message::Content::AuthRequest(auth)) = peer_message.content { + token_tx.send(auth.token).unwrap(); + socket + .send(tokio_tungstenite::tungstenite::Message::Close(Some( + CloseFrame { + code: CloseCode::Policy, + reason: "invalid token".into(), + }, + ))) + .await + .unwrap(); + } + } + } + }); + }); + let port = port_rx.recv().unwrap(); + (format!("http://127.0.0.1:{port}"), token_rx) +} + +#[test] +fn c_abi_join_room_reports_auth_error_and_leave_room_returns_to_idle() { + let (url, tokens) = spawn_server(); + let config = ClientConfig { + fishjam_id: url, + debug: false, + } + .encode_to_vec(); + let (sender, events) = mpsc::channel::(); + let sender = Box::into_raw(Box::new(sender)); + + let client = unsafe { + fishjam_client_new( + config.as_ptr(), + config.len(), + Some(on_event), + sender as *mut c_void, + ) + }; + assert!(!client.is_null()); + + let (code, response) = call( + client, + sdk_request::Content::JoinRoom(JoinRoom { + peer_token: "bad-token".to_owned(), + peer_metadata_json: "{}".to_owned(), + }), + ); + assert_eq!(code, 0); + assert!(matches!( + response.content, + Some(sdk_response::Content::Empty(_)) + )); + + match next_status(&events) { + sdk_event::Content::PeerStatusChanged(changed) => { + assert_eq!(changed.peer_status(), PeerStatus::Connecting) + } + _ => unreachable!(), + } + assert_eq!( + tokens.recv_timeout(Duration::from_secs(10)).unwrap(), + "bad-token" + ); + match next_status(&events) { + sdk_event::Content::PeerStatusChanged(changed) => { + assert_eq!(changed.peer_status(), PeerStatus::Error); + assert_eq!(changed.error.unwrap().auth_reason, "invalid token"); + } + _ => unreachable!(), + } + + let (code, _) = call(client, sdk_request::Content::LeaveRoom(LeaveRoom {})); + assert_eq!(code, 0); + match next_status(&events) { + sdk_event::Content::PeerStatusChanged(changed) => { + assert_eq!(changed.peer_status(), PeerStatus::Idle) + } + _ => unreachable!(), + } + + unsafe { + fishjam_client_free(client); + drop(Box::from_raw(sender)); + } +} + +#[test] +fn c_abi_rejects_undecodable_request() { + let client = unsafe { fishjam_client_new(std::ptr::null(), 0, None, std::ptr::null_mut()) }; + let garbage = [0xffu8, 0xff, 0xff]; + let mut out = FishjamBuf { + data: std::ptr::null_mut(), + len: 0, + }; + let code = unsafe { fishjam_client_call(client, garbage.as_ptr(), garbage.len(), &mut out) }; + assert_eq!(code, 2); + let response = + SdkResponse::decode(unsafe { std::slice::from_raw_parts(out.data, out.len) }).unwrap(); + assert!(matches!( + response.content, + Some(sdk_response::Content::Error(_)) + )); + unsafe { + fishjam_buf_free(out); + fishjam_client_free(client); + } +} + +#[test] +fn c_abi_join_room_without_fishjam_id_is_illegal() { + let client = unsafe { fishjam_client_new(std::ptr::null(), 0, None, std::ptr::null_mut()) }; + let (code, response) = call( + client, + sdk_request::Content::JoinRoom(JoinRoom { + peer_token: "token".to_owned(), + peer_metadata_json: "{}".to_owned(), + }), + ); + assert_eq!(code, 3); + assert!(matches!( + response.content, + Some(sdk_response::Content::Error(_)) + )); + unsafe { fishjam_client_free(client) }; +} diff --git a/packages/fishjam-core/tests/auth.rs b/packages/fishjam-core/tests/auth.rs new file mode 100644 index 00000000..471ed5cc --- /dev/null +++ b/packages/fishjam-core/tests/auth.rs @@ -0,0 +1,254 @@ +mod common; + +use common::{authenticated, capture_logs, connected, harness, logs}; +use fishjam_core::fishjam::media_events::{peer, server}; +use fishjam_core::fishjam::peer_message; +use fishjam_core::sdk::{sdk_event, PeerStatus}; + +const FISHJAM_ID: &str = "https://example.com/api/v1/connect/abc"; +const METADATA: &str = r#"{"name":"Alice"}"#; + +#[test] +fn successful_join_sends_auth_and_connect_then_becomes_connected() { + let h = harness(FISHJAM_ID); + assert_eq!(h.client.peer_status(), PeerStatus::Idle); + + h.client.join_room("token-123", METADATA).unwrap(); + assert_eq!( + h.socket.state.lock().unwrap().url.as_deref(), + Some("wss://example.com/api/v1/connect/abc/socket/peer/websocket") + ); + assert_eq!(h.client.peer_status(), PeerStatus::Connecting); + assert_eq!(h.events.statuses(), vec![PeerStatus::Connecting]); + + h.socket.open(); + let sent = h.socket.sent(); + assert_eq!(sent.len(), 1); + match &sent[0].content { + Some(peer_message::Content::AuthRequest(auth)) => { + assert_eq!(auth.token, "token-123"); + assert_eq!(auth.sdk_version, "mobile-0.31.0"); + } + other => panic!("expected auth request, got {other:?}"), + } + + h.socket.receive(authenticated(None)); + let peer_events = h.socket.sent_peer_events(); + assert_eq!( + peer_events, + vec![peer::media_event::Content::Connect( + peer::media_event::Connect { + metadata_json: METADATA.to_owned(), + } + )] + ); + assert_eq!(h.client.peer_status(), PeerStatus::Connecting); + + h.socket.receive_server_event(connected("local-id")); + assert_eq!(h.client.peer_status(), PeerStatus::Connected); + assert_eq!(h.events.statuses(), vec![PeerStatus::Connected]); +} + +#[test] +fn sdk_version_is_mobile_and_package_version() { + assert_eq!(fishjam_core::sdk_version(), "mobile-0.31.0"); +} + +#[test] +fn each_auth_error_sets_error_with_auth_reason() { + for reason in [ + "missing token", + "invalid token", + "expired token", + "room not found", + "peer not found", + ] { + let h = harness(FISHJAM_ID); + h.client.join_room("bad", "{}").unwrap(); + h.socket.open(); + h.events.take(); + + h.socket.close(&format!(" {} ", reason.to_uppercase())); + + assert_eq!(h.client.peer_status(), PeerStatus::Error, "{reason}"); + let events = h.events.take(); + let error = events + .iter() + .find_map(|event| match event { + sdk_event::Content::PeerStatusChanged(changed) => changed.error.clone(), + _ => None, + }) + .unwrap_or_else(|| panic!("no error event for {reason}")); + assert_eq!(error.auth_reason, reason); + assert_eq!(error.join_reason, ""); + } +} + +#[test] +fn each_join_error_sets_error_with_join_reason() { + for reason in [ + "reached peers limit", + "node not found", + "invalid sdk version", + ] { + let h = harness(FISHJAM_ID); + h.client.join_room("token", "{}").unwrap(); + h.socket.open(); + h.socket.receive(authenticated(None)); + h.events.take(); + + h.socket.close(reason); + + assert_eq!(h.client.peer_status(), PeerStatus::Error, "{reason}"); + let error = h + .events + .take() + .into_iter() + .find_map(|event| match event { + sdk_event::Content::PeerStatusChanged(changed) => changed.error, + _ => None, + }) + .unwrap_or_else(|| panic!("no error event for {reason}")); + assert_eq!(error.join_reason, reason); + assert_eq!(error.auth_reason, ""); + } +} + +#[test] +fn server_error_media_event_sets_error() { + let h = harness(FISHJAM_ID); + h.client.join_room("token", "{}").unwrap(); + h.socket.open(); + h.socket.receive(authenticated(None)); + h.events.take(); + + h.socket + .receive_server_event(server::media_event::Content::Error( + server::media_event::Error { + message: "boom".to_owned(), + }, + )); + + assert_eq!(h.client.peer_status(), PeerStatus::Error); +} + +#[test] +fn unsupported_sdk_logs_error_and_stays_connected() { + capture_logs(); + let h = harness(FISHJAM_ID); + h.client.join_room("token", "{}").unwrap(); + h.socket.open(); + + h.socket.receive(authenticated(Some(( + peer_message::sdk_deprecation::Status::Unsupported, + "sdk unsupported", + )))); + + assert!(logs().contains(&(log::Level::Error, "sdk unsupported".to_owned()))); + assert!(!h.socket.is_closed()); + assert_eq!(h.socket.sent_peer_events().len(), 1); +} + +#[test] +fn deprecated_sdk_logs_warning() { + capture_logs(); + let h = harness(FISHJAM_ID); + h.client.join_room("token", "{}").unwrap(); + h.socket.open(); + + h.socket.receive(authenticated(Some(( + peer_message::sdk_deprecation::Status::Deprecated, + "sdk deprecated", + )))); + + assert!(logs().contains(&(log::Level::Warn, "sdk deprecated".to_owned()))); + assert!(!h.socket.is_closed()); +} + +#[test] +fn leave_room_sends_disconnect_and_returns_to_idle() { + let h = harness(FISHJAM_ID); + h.client.join_room("token", "{}").unwrap(); + h.socket.open(); + h.socket.receive(authenticated(None)); + h.socket.receive_server_event(connected("local-id")); + h.events.take(); + + h.client.leave_room(); + + assert_eq!( + h.socket.sent_peer_events().last(), + Some(&peer::media_event::Content::Disconnect( + peer::media_event::Disconnect {} + )) + ); + assert!(h.socket.is_closed()); + assert_eq!(h.client.peer_status(), PeerStatus::Idle); + assert_eq!(h.events.statuses(), vec![PeerStatus::Idle]); + + h.socket.close(""); + assert_eq!(h.client.peer_status(), PeerStatus::Idle); + assert!(h.events.take().is_empty()); +} + +#[test] +fn leave_room_before_socket_opens_does_not_send_disconnect() { + let h = harness(FISHJAM_ID); + h.client.join_room("token", "{}").unwrap(); + + h.client.leave_room(); + + assert!(h.socket.sent().is_empty()); + assert!(h.socket.is_closed()); + assert_eq!(h.client.peer_status(), PeerStatus::Idle); +} + +#[test] +fn unexpected_socket_close_sets_error() { + let h = harness(FISHJAM_ID); + h.client.join_room("token", "{}").unwrap(); + h.socket.open(); + h.socket.receive(authenticated(None)); + h.socket.receive_server_event(connected("local-id")); + + h.socket.close("going away"); + + assert_eq!(h.client.peer_status(), PeerStatus::Error); +} + +#[test] +fn join_room_again_closes_previous_socket() { + let h = harness(FISHJAM_ID); + h.client.join_room("token", "{}").unwrap(); + h.socket.open(); + h.socket.receive(authenticated(None)); + h.socket.receive_server_event(connected("local-id")); + h.events.take(); + + h.client.join_room("token-2", "{}").unwrap(); + + assert_eq!(h.socket.state.lock().unwrap().connect_count, 2); + assert_eq!( + h.events.statuses(), + vec![PeerStatus::Idle, PeerStatus::Connecting] + ); +} + +#[test] +fn websocket_url_follows_fishjam_url_rules() { + use fishjam_core::websocket_url; + + assert_eq!( + websocket_url("abc").unwrap(), + "wss://fishjam.io/api/v1/connect/abc/socket/peer/websocket" + ); + assert_eq!( + websocket_url("http://localhost:5002").unwrap(), + "ws://localhost:5002/socket/peer/websocket" + ); + assert_eq!( + websocket_url("https://example.com/api/").unwrap(), + "wss://example.com/api/socket/peer/websocket" + ); + assert!(websocket_url("").is_err()); +} diff --git a/packages/fishjam-core/tests/common/mod.rs b/packages/fishjam-core/tests/common/mod.rs new file mode 100644 index 00000000..fb0b8ccf --- /dev/null +++ b/packages/fishjam-core/tests/common/mod.rs @@ -0,0 +1,234 @@ +#![allow(dead_code)] + +use std::cell::RefCell; +use std::sync::{Arc, Mutex, Once}; + +use fishjam_core::fishjam::media_events::{peer, server}; +use fishjam_core::fishjam::{peer_message, PeerMessage}; +use fishjam_core::sdk::{sdk_event, ClientConfig, PeerStatus, SdkEvent}; +use fishjam_core::{FishjamClient, SignalingSocket, SocketConnector, SocketListener}; +use prost::Message; + +#[derive(Default)] +pub struct MockSocketState { + pub url: Option, + pub listener: Option>, + pub sent: Vec>, + pub closed: bool, + pub connect_count: usize, +} + +#[derive(Clone, Default)] +pub struct MockConnector { + pub state: Arc>, +} + +struct MockSocket { + state: Arc>, +} + +impl SignalingSocket for MockSocket { + fn send(&self, data: Vec) { + self.state.lock().unwrap().sent.push(data); + } + + fn close(&self) { + self.state.lock().unwrap().closed = true; + } +} + +impl SocketConnector for MockConnector { + fn connect(&self, url: &str, listener: Arc) -> Box { + let mut state = self.state.lock().unwrap(); + state.url = Some(url.to_owned()); + state.listener = Some(listener); + state.sent.clear(); + state.closed = false; + state.connect_count += 1; + Box::new(MockSocket { + state: self.state.clone(), + }) + } +} + +impl MockConnector { + fn listener(&self) -> Arc { + self.state + .lock() + .unwrap() + .listener + .clone() + .expect("socket connected") + } + + pub fn open(&self) { + self.listener().on_open(); + } + + pub fn receive(&self, message: PeerMessage) { + self.listener().on_message(message.encode_to_vec()); + } + + pub fn receive_server_event(&self, content: server::media_event::Content) { + self.receive(PeerMessage { + content: Some(peer_message::Content::ServerMediaEvent( + server::MediaEvent { + content: Some(content), + }, + )), + }); + } + + pub fn close(&self, reason: &str) { + self.listener().on_close(reason.to_owned()); + } + + pub fn sent(&self) -> Vec { + self.state + .lock() + .unwrap() + .sent + .iter() + .map(|bytes| PeerMessage::decode(bytes.as_slice()).expect("peer message")) + .collect() + } + + pub fn sent_peer_events(&self) -> Vec { + self.sent() + .into_iter() + .filter_map(|message| match message.content { + Some(peer_message::Content::PeerMediaEvent(event)) => event.content, + _ => None, + }) + .collect() + } + + pub fn is_closed(&self) -> bool { + self.state.lock().unwrap().closed + } +} + +#[derive(Clone, Default)] +pub struct Events { + pub events: Arc>>, +} + +impl Events { + pub fn take(&self) -> Vec { + std::mem::take(&mut *self.events.lock().unwrap()) + .into_iter() + .filter_map(|event| event.content) + .collect() + } + + pub fn statuses(&self) -> Vec { + self.take() + .into_iter() + .filter_map(|event| match event { + sdk_event::Content::PeerStatusChanged(changed) => Some(changed.peer_status()), + _ => None, + }) + .collect() + } +} + +pub struct Harness { + pub client: FishjamClient, + pub socket: MockConnector, + pub events: Events, +} + +pub fn harness(fishjam_id: &str) -> Harness { + let socket = MockConnector::default(); + let events = Events::default(); + let sink = events.clone(); + let client = FishjamClient::with_connector( + ClientConfig { + fishjam_id: fishjam_id.to_owned(), + debug: false, + }, + Box::new(socket.clone()), + move |event| sink.events.lock().unwrap().push(event), + ); + Harness { + client, + socket, + events, + } +} + +pub fn authenticated( + deprecation: Option<(peer_message::sdk_deprecation::Status, &str)>, +) -> PeerMessage { + PeerMessage { + content: Some(peer_message::Content::Authenticated( + peer_message::Authenticated { + room_type: peer_message::RoomType::Conference as i32, + sdk_deprecation: deprecation.map(|(status, message)| { + peer_message::SdkDeprecation { + status: status as i32, + message: message.to_owned(), + } + }), + }, + )), + } +} + +pub fn connected(local_id: &str) -> server::media_event::Content { + let mut endpoints = std::collections::HashMap::new(); + endpoints.insert( + local_id.to_owned(), + server::media_event::Endpoint { + endpoint_type: "webrtc".to_owned(), + metadata_json: r#"{"peer":{"name":"me"},"server":{}}"#.to_owned(), + track_id_to_track: Default::default(), + }, + ); + server::media_event::Content::Connected(server::media_event::Connected { + endpoint_id: local_id.to_owned(), + endpoint_id_to_endpoint: endpoints, + ice_servers: vec![server::media_event::IceServer { + credential: "secret".to_owned(), + urls: vec!["turn:turn.example.com:3478".to_owned()], + username: "user".to_owned(), + }], + }) +} + +thread_local! { + static LOGS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +struct ThreadLogger; + +impl log::Log for ThreadLogger { + fn enabled(&self, _: &log::Metadata) -> bool { + true + } + + fn log(&self, record: &log::Record) { + LOGS.with(|logs| { + logs.borrow_mut() + .push((record.level(), record.args().to_string())) + }); + } + + fn flush(&self) {} +} + +static LOGGER: ThreadLogger = ThreadLogger; +static INIT: Once = Once::new(); + +/// Collects log records written on the current thread. +pub fn capture_logs() { + INIT.call_once(|| { + log::set_logger(&LOGGER).unwrap(); + log::set_max_level(log::LevelFilter::Trace); + }); + LOGS.with(|logs| logs.borrow_mut().clear()); +} + +pub fn logs() -> Vec<(log::Level, String)> { + LOGS.with(|logs| logs.borrow().clone()) +} diff --git a/packages/fishjam-core/tests/live.rs b/packages/fishjam-core/tests/live.rs new file mode 100644 index 00000000..c404cda7 --- /dev/null +++ b/packages/fishjam-core/tests/live.rs @@ -0,0 +1,86 @@ +use std::sync::mpsc; +use std::time::Duration; + +use fishjam_core::sdk::{sdk_event, ClientConfig, PeerStatus}; +use fishjam_core::FishjamClient; + +fn fishjam_id() -> String { + if let Ok(value) = std::env::var("FISHJAM_ID") { + let value = value.trim().trim_matches('"').to_owned(); + if !value.is_empty() { + return value; + } + } + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.env"); + let text = std::fs::read_to_string(&path).unwrap_or_default(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + if key.trim() == "FISHJAM_ID" { + let value = value.trim().trim_matches('"'); + if !value.is_empty() { + return value.to_owned(); + } + } + } + panic!("Set FISHJAM_ID in the environment or in .env"); +} + +/// Needs network access: `cargo test --test live -- --ignored`. +#[test] +#[ignore] +fn sandbox_rejects_bad_token_over_tls() { + let (sender, receiver) = mpsc::channel(); + let client = FishjamClient::new( + ClientConfig { + fishjam_id: fishjam_id(), + debug: false, + }, + move |event| { + if let Some(sdk_event::Content::PeerStatusChanged(changed)) = event.content { + let _ = sender.send(changed); + } + }, + ); + + client.join_room("bad-token", "{}").unwrap(); + + let connecting = receiver.recv_timeout(Duration::from_secs(15)).unwrap(); + assert_eq!(connecting.peer_status(), PeerStatus::Connecting); + let failed = receiver.recv_timeout(Duration::from_secs(15)).unwrap(); + assert_eq!(failed.peer_status(), PeerStatus::Error); + let error = failed.error.unwrap(); + assert_eq!(error.auth_reason, "invalid token", "{error:?}"); +} + +/// Needs network access and `FISHJAM_PEER_TOKEN` from the sandbox room manager. +#[test] +#[ignore] +fn sandbox_accepts_good_token() { + let token = std::env::var("FISHJAM_PEER_TOKEN").expect("FISHJAM_PEER_TOKEN"); + let (sender, receiver) = mpsc::channel(); + let client = FishjamClient::new( + ClientConfig { + fishjam_id: fishjam_id(), + debug: false, + }, + move |event| { + if let Some(sdk_event::Content::PeerStatusChanged(changed)) = event.content { + let _ = sender.send(changed); + } + }, + ); + + client.join_room(&token, r#"{"name":"core"}"#).unwrap(); + + let connecting = receiver.recv_timeout(Duration::from_secs(15)).unwrap(); + assert_eq!(connecting.peer_status(), PeerStatus::Connecting); + let joined = receiver.recv_timeout(Duration::from_secs(15)).unwrap(); + assert_eq!(joined.peer_status(), PeerStatus::Connected, "{joined:?}"); + client.leave_room(); +} diff --git a/packages/fishjam-core/tests/negotiation.rs b/packages/fishjam-core/tests/negotiation.rs new file mode 100644 index 00000000..910342c8 --- /dev/null +++ b/packages/fishjam-core/tests/negotiation.rs @@ -0,0 +1,544 @@ +mod common; + +use std::collections::HashMap; + +use common::{authenticated, connected, harness}; +use fishjam_core::fishjam::media_events::{peer, server}; +use fishjam_core::sdk::{ + sdk_event, NegotiationStatus, SdpType, SendEncoding, TrackKind, TrackMetadata, TrackType, +}; +use fishjam_core::FishjamClient; + +const OFFER_SDP: &str = "v=0\r\noffer"; +const ANSWER_SDP: &str = "v=0\r\nanswer"; + +fn metadata(track_type: TrackType) -> TrackMetadata { + TrackMetadata { + r#type: track_type as i32, + paused: false, + display_name: None, + } +} + +fn pump( + client: &FishjamClient, + events: &common::Events, + mids: HashMap, +) -> Vec { + let mut seen = Vec::new(); + for _ in 0..16 { + let batch = events.take(); + if batch.is_empty() { + break; + } + for event in batch { + let reply_offer = matches!(event, sdk_event::Content::CreateOffer(_)); + let reply_local = matches!(event, sdk_event::Content::SetLocalDescription(_)); + seen.push(event); + if reply_offer { + client.create_offer_result(OFFER_SDP); + } + if reply_local { + client.set_local_description_result(mids.clone()); + } + } + } + seen +} + +fn join(client: &FishjamClient, socket: &common::MockConnector, events: &common::Events) { + client.join_room("token", r#"{"name":"me"}"#).unwrap(); + socket.open(); + socket.receive(authenticated(None)); + socket.receive_server_event(connected("local-id")); + events.take(); +} + +fn offer_data(audio: i32, video: i32) -> server::media_event::Content { + server::media_event::Content::OfferData(server::media_event::OfferData { + tracks_types: Some(server::media_event::offer_data::TrackTypes { audio, video }), + }) +} + +fn send_encodings(events: &[sdk_event::Content], track_id: &str) -> Vec { + events + .iter() + .find_map(|event| match event { + sdk_event::Content::AddSendonlyTransceiver(transceiver) + if transceiver.track_id == track_id => + { + Some(transceiver.send_encodings.clone()) + } + _ => None, + }) + .unwrap_or_else(|| panic!("no transceiver for {track_id}")) +} + +#[test] +fn offer_with_no_local_tracks_sets_the_offer_sdp() { + let h = harness("https://example.com"); + join(&h.client, &h.socket, &h.events); + h.socket.receive_server_event(offer_data(0, 0)); + + let events = pump(&h.client, &h.events, HashMap::new()); + + assert!(events + .iter() + .any(|event| matches!(event, sdk_event::Content::CreatePeerConnection(_)))); + assert!(events + .iter() + .any(|event| matches!(event, sdk_event::Content::CreateOffer(_)))); + let local = events.iter().find_map(|event| match event { + sdk_event::Content::SetLocalDescription(description) => Some(description), + _ => None, + }); + let local = local.expect("set local description"); + assert_eq!(local.r#type(), SdpType::Offer); + assert_eq!(local.sdp, OFFER_SDP); +} + +#[test] +fn audio_track_has_no_simulcast_encodings() { + let h = harness("https://example.com"); + join(&h.client, &h.socket, &h.events); + let track_id = h + .client + .add_track(TrackKind::Audio, metadata(TrackType::Microphone)) + .unwrap(); + h.socket.receive_server_event(offer_data(0, 0)); + + let events = pump( + &h.client, + &h.events, + HashMap::from([("0".into(), track_id.clone())]), + ); + assert!(send_encodings(&events, &track_id).is_empty()); +} + +#[test] +fn video_without_simulcast_has_one_encoding() { + let h = harness("https://example.com"); + join(&h.client, &h.socket, &h.events); + let track_id = h + .client + .add_track(TrackKind::Video, metadata(TrackType::CustomVideo)) + .unwrap(); + h.socket.receive_server_event(offer_data(0, 0)); + + let encodings = send_encodings( + &pump( + &h.client, + &h.events, + HashMap::from([("0".into(), track_id.clone())]), + ), + &track_id, + ); + assert_eq!(encodings.len(), 1); + assert_eq!(encodings[0].max_bitrate, 1_500 * 1024); +} + +#[test] +fn camera_video_uses_low_medium_and_high_encodings() { + let h = harness("https://example.com"); + join(&h.client, &h.socket, &h.events); + let track_id = h + .client + .add_track(TrackKind::Video, metadata(TrackType::Camera)) + .unwrap(); + h.socket.receive_server_event(offer_data(0, 0)); + + let encodings = send_encodings( + &pump( + &h.client, + &h.events, + HashMap::from([("0".into(), track_id.clone())]), + ), + &track_id, + ); + assert_eq!( + encodings + .iter() + .map(|encoding| encoding.rid.as_str()) + .collect::>(), + vec!["l", "m", "h"] + ); + assert_eq!(encodings[0].scale_resolution_down_by, 4.0); + assert_eq!(encodings[1].scale_resolution_down_by, 2.0); + assert_eq!(encodings[2].scale_resolution_down_by, 1.0); + assert_eq!(encodings[0].max_bitrate, 150 * 1024); + assert_eq!(encodings[1].max_bitrate, 500 * 1024); + assert_eq!(encodings[2].max_bitrate, 1_500 * 1024); +} + +#[test] +fn both_audio_and_video_are_in_one_offer() { + let h = harness("https://example.com"); + join(&h.client, &h.socket, &h.events); + let audio = h + .client + .add_track(TrackKind::Audio, metadata(TrackType::Microphone)) + .unwrap(); + let video = h + .client + .add_track(TrackKind::Video, metadata(TrackType::Camera)) + .unwrap(); + h.events.take(); + h.socket.receive_server_event(offer_data(1, 1)); + + let events = pump( + &h.client, + &h.events, + HashMap::from([("0".into(), audio.clone()), ("1".into(), video.clone())]), + ); + let track_ids: Vec<_> = events + .iter() + .filter_map(|event| match event { + sdk_event::Content::AddSendonlyTransceiver(transceiver) => { + Some(transceiver.track_id.clone()) + } + _ => None, + }) + .collect(); + assert_eq!(track_ids, vec![audio, video]); + let update = events.iter().find_map(|event| match event { + sdk_event::Content::UpdateRecvonlyTransceivers(update) => Some(update), + _ => None, + }); + let update = update.expect("recv transceivers"); + assert_eq!(update.audio_to_add, 1); + assert_eq!(update.video_to_add, 1); +} + +#[test] +fn sdp_answer_becomes_set_remote_description() { + let h = harness("https://example.com"); + join(&h.client, &h.socket, &h.events); + let track_id = h + .client + .add_track(TrackKind::Audio, metadata(TrackType::Microphone)) + .unwrap(); + h.socket.receive_server_event(offer_data(0, 0)); + pump( + &h.client, + &h.events, + HashMap::from([("0".into(), track_id.clone())]), + ); + + let mut mid_to_track_id = HashMap::new(); + mid_to_track_id.insert("0".to_owned(), track_id); + h.socket + .receive_server_event(server::media_event::Content::SdpAnswer( + server::media_event::SdpAnswer { + sdp: ANSWER_SDP.to_owned(), + mid_to_track_id: mid_to_track_id.clone(), + }, + )); + + let remote = h.events.take().into_iter().find_map(|event| match event { + sdk_event::Content::SetRemoteDescription(description) => Some(description), + _ => None, + }); + let remote = remote.expect("set remote description"); + assert_eq!(remote.r#type(), SdpType::Answer); + assert_eq!(remote.sdp, ANSWER_SDP); + assert_eq!(remote.mid_to_track_id, mid_to_track_id); +} + +#[test] +fn negotiation_status_moves_from_awaiting_to_offered_to_done() { + let h = harness("https://example.com"); + join(&h.client, &h.socket, &h.events); + let track_id = h + .client + .add_track(TrackKind::Audio, metadata(TrackType::Microphone)) + .unwrap(); + let local = h.client.room_state().local_peer.expect("local peer"); + assert_eq!( + local.microphone_track.unwrap().negotiation_status(), + NegotiationStatus::Awaiting + ); + + h.socket.receive_server_event(offer_data(0, 0)); + pump( + &h.client, + &h.events, + HashMap::from([("0".into(), track_id.clone())]), + ); + let local = h.client.room_state().local_peer.expect("local peer"); + assert_eq!( + local.microphone_track.unwrap().negotiation_status(), + NegotiationStatus::Offered + ); + + h.socket + .receive_server_event(server::media_event::Content::SdpAnswer( + server::media_event::SdpAnswer { + sdp: ANSWER_SDP.to_owned(), + mid_to_track_id: HashMap::from([("0".into(), track_id)]), + }, + )); + h.events.take(); + let local = h.client.room_state().local_peer.expect("local peer"); + assert_eq!( + local.microphone_track.unwrap().negotiation_status(), + NegotiationStatus::Done + ); +} + +#[test] +fn remote_peer_arriving_during_negotiation_updates_room_state_without_another_renegotiate() { + let h = harness("https://example.com"); + join(&h.client, &h.socket, &h.events); + h.client + .add_track(TrackKind::Audio, metadata(TrackType::Microphone)) + .unwrap(); + let sent_before = h.socket.sent_peer_events().len(); + + h.socket + .receive_server_event(server::media_event::Content::EndpointAdded( + server::media_event::EndpointAdded { + endpoint_id: "remote-id".to_owned(), + metadata_json: r#"{"peer":{"name":"bob"},"server":{}}"#.to_owned(), + }, + )); + h.socket + .receive_server_event(server::media_event::Content::TracksAdded( + server::media_event::TracksAdded { + endpoint_id: "remote-id".to_owned(), + track_id_to_track: HashMap::from([( + "remote-id:track".to_owned(), + server::media_event::Track { + metadata_json: r#"{"type":"camera","paused":false}"#.to_owned(), + simulcast_config: None, + }, + )]), + }, + )); + + let room = h.client.room_state(); + assert_eq!(room.remote_peers.len(), 1); + assert_eq!(room.remote_peers[0].id, "remote-id"); + assert!(room.remote_peers[0].camera_track.is_some()); + let renegotiates = h + .socket + .sent_peer_events() + .into_iter() + .skip(sent_before) + .filter(|event| matches!(event, peer::media_event::Content::RenegotiateTracks(_))) + .count(); + assert_eq!(renegotiates, 0); +} + +#[test] +fn removing_a_remote_peer_and_track_updates_room_state() { + let h = harness("https://example.com"); + join(&h.client, &h.socket, &h.events); + h.socket + .receive_server_event(server::media_event::Content::EndpointAdded( + server::media_event::EndpointAdded { + endpoint_id: "remote-id".to_owned(), + metadata_json: "{}".to_owned(), + }, + )); + h.socket + .receive_server_event(server::media_event::Content::TracksAdded( + server::media_event::TracksAdded { + endpoint_id: "remote-id".to_owned(), + track_id_to_track: HashMap::from([ + ( + "remote-id:cam".to_owned(), + server::media_event::Track { + metadata_json: r#"{"type":"camera","paused":false}"#.to_owned(), + simulcast_config: None, + }, + ), + ( + "remote-id:mic".to_owned(), + server::media_event::Track { + metadata_json: r#"{"type":"microphone","paused":false}"#.to_owned(), + simulcast_config: None, + }, + ), + ]), + }, + )); + let sent_before = h.socket.sent_peer_events().len(); + + h.socket + .receive_server_event(server::media_event::Content::TracksRemoved( + server::media_event::TracksRemoved { + endpoint_id: "remote-id".to_owned(), + track_ids: vec!["remote-id:cam".to_owned()], + }, + )); + + let room = h.client.room_state(); + assert_eq!(room.remote_peers.len(), 1); + assert!(room.remote_peers[0].camera_track.is_none()); + assert!(room.remote_peers[0].microphone_track.is_some()); + + h.socket + .receive_server_event(server::media_event::Content::EndpointRemoved( + server::media_event::EndpointRemoved { + endpoint_id: "remote-id".to_owned(), + }, + )); + assert!(h.client.room_state().remote_peers.is_empty()); + let renegotiates = h + .socket + .sent_peer_events() + .into_iter() + .skip(sent_before) + .filter(|event| matches!(event, peer::media_event::Content::RenegotiateTracks(_))) + .count(); + assert_eq!(renegotiates, 0); +} + +#[test] +fn second_local_track_during_negotiation_does_not_send_another_renegotiate() { + let h = harness("https://example.com"); + join(&h.client, &h.socket, &h.events); + h.client + .add_track(TrackKind::Audio, metadata(TrackType::Microphone)) + .unwrap(); + h.client + .add_track(TrackKind::Video, metadata(TrackType::Camera)) + .unwrap(); + + let renegotiates = h + .socket + .sent_peer_events() + .into_iter() + .filter(|event| matches!(event, peer::media_event::Content::RenegotiateTracks(_))) + .count(); + assert_eq!(renegotiates, 1); +} + +#[test] +fn add_track_before_connected_returns_an_error() { + let h = harness("https://example.com"); + let error = h + .client + .add_track(TrackKind::Audio, metadata(TrackType::Microphone)) + .unwrap_err(); + assert!(error.message.contains("accepted")); +} + +#[test] +fn sdp_offer_includes_the_mid_map_from_the_sdk() { + let h = harness("https://example.com"); + join(&h.client, &h.socket, &h.events); + let track_id = h + .client + .add_track(TrackKind::Audio, metadata(TrackType::Microphone)) + .unwrap(); + h.socket.receive_server_event(offer_data(0, 0)); + let mids = HashMap::from([("0".to_owned(), track_id.clone())]); + pump(&h.client, &h.events, mids.clone()); + + let offer = h + .socket + .sent_peer_events() + .into_iter() + .find_map(|event| match event { + peer::media_event::Content::SdpOffer(offer) => Some(offer), + _ => None, + }); + let offer = offer.expect("sdp offer"); + assert_eq!(offer.sdp, OFFER_SDP); + assert_eq!(offer.mid_to_track_id, mids); + assert!(offer.track_id_to_metadata_json.contains_key(&track_id)); + assert!(offer.track_id_to_bitrates.contains_key(&track_id)); +} + +#[test] +fn rejoining_remote_peer_gets_a_new_recv_transceiver() { + let h = harness("https://example.com"); + join(&h.client, &h.socket, &h.events); + h.socket + .receive_server_event(server::media_event::Content::EndpointAdded( + server::media_event::EndpointAdded { + endpoint_id: "remote-id".to_owned(), + metadata_json: "{}".to_owned(), + }, + )); + h.socket + .receive_server_event(server::media_event::Content::TracksAdded( + server::media_event::TracksAdded { + endpoint_id: "remote-id".to_owned(), + track_id_to_track: HashMap::from([( + "remote-id:cam".to_owned(), + server::media_event::Track { + metadata_json: r#"{"type":"camera","paused":false}"#.to_owned(), + simulcast_config: None, + }, + )]), + }, + )); + h.socket.receive_server_event(offer_data(0, 1)); + pump(&h.client, &h.events, HashMap::new()); + h.socket + .receive_server_event(server::media_event::Content::SdpAnswer( + server::media_event::SdpAnswer { + sdp: ANSWER_SDP.to_owned(), + mid_to_track_id: HashMap::from([("0".to_owned(), "remote-id:cam".to_owned())]), + }, + )); + h.events.take(); + + h.socket + .receive_server_event(server::media_event::Content::TracksRemoved( + server::media_event::TracksRemoved { + endpoint_id: "remote-id".to_owned(), + track_ids: vec!["remote-id:cam".to_owned()], + }, + )); + h.socket + .receive_server_event(server::media_event::Content::EndpointRemoved( + server::media_event::EndpointRemoved { + endpoint_id: "remote-id".to_owned(), + }, + )); + h.socket.receive_server_event(offer_data(0, 0)); + let stopped = pump(&h.client, &h.events, HashMap::new()); + let stopped = stopped.iter().find_map(|event| match event { + sdk_event::Content::UpdateRecvonlyTransceivers(update) => Some(update), + _ => None, + }); + let stopped = stopped.expect("stop the old receive transceiver"); + assert_eq!(stopped.mids_to_stop, vec!["0".to_owned()]); + assert_eq!(stopped.video_to_add, 0); + + h.socket + .receive_server_event(server::media_event::Content::EndpointAdded( + server::media_event::EndpointAdded { + endpoint_id: "remote-id-2".to_owned(), + metadata_json: "{}".to_owned(), + }, + )); + h.socket + .receive_server_event(server::media_event::Content::TracksAdded( + server::media_event::TracksAdded { + endpoint_id: "remote-id-2".to_owned(), + track_id_to_track: HashMap::from([( + "remote-id-2:cam".to_owned(), + server::media_event::Track { + metadata_json: r#"{"type":"camera","paused":false}"#.to_owned(), + simulcast_config: None, + }, + )]), + }, + )); + h.socket.receive_server_event(offer_data(0, 1)); + let rejoined = pump(&h.client, &h.events, HashMap::new()); + let rejoined = rejoined.iter().find_map(|event| match event { + sdk_event::Content::UpdateRecvonlyTransceivers(update) => Some(update), + _ => None, + }); + let rejoined = rejoined.expect("add a receive transceiver for the new camera"); + assert_eq!(rejoined.video_to_add, 1); + assert!(rejoined.mids_to_stop.is_empty()); + let room = h.client.room_state(); + assert_eq!(room.remote_peers.len(), 1); + assert!(room.remote_peers[0].camera_track.is_some()); +} diff --git a/packages/ios-client/.gitignore b/packages/ios-client/.gitignore new file mode 100644 index 00000000..bbdb3951 --- /dev/null +++ b/packages/ios-client/.gitignore @@ -0,0 +1,9 @@ +build/ +Core/ +Vendor/ +Pods/ +DerivedData/ +*.xcodeproj/ +*.xcworkspace/ +*.xcuserstate +xcuserdata/ diff --git a/packages/ios-client/.swiftlint.yml b/packages/ios-client/.swiftlint.yml new file mode 100644 index 00000000..12b75a98 --- /dev/null +++ b/packages/ios-client/.swiftlint.yml @@ -0,0 +1,18 @@ +included: + - FishjamClient + - FishjamClientTests +excluded: + - FishjamClient/Generated + - build + - Core + - Vendor + - Pods +strict: true +line_length: + warning: 120 + error: 160 +identifier_name: + excluded: + - a + - b + - id diff --git a/packages/ios-client/FishjamClient/FishjamClient.swift b/packages/ios-client/FishjamClient/FishjamClient.swift new file mode 100644 index 00000000..e289592e --- /dev/null +++ b/packages/ios-client/FishjamClient/FishjamClient.swift @@ -0,0 +1,284 @@ +import FishjamCore +import Foundation +import WebRTC + +private final class EventBox { + weak var client: FishjamClient? +} + +private func fishjamOnEvent( + _ userData: UnsafeMutableRawPointer?, + _ event: UnsafePointer?, + _ eventLength: Int +) { + guard let userData, let event else { + return + } + let data = Data(bytes: event, count: eventLength) + let box = Unmanaged.fromOpaque(userData).takeUnretainedValue() + DispatchQueue.main.async { + box.client?.apply(data) + } +} + +public final class FishjamClient: ObservableObject, @unchecked Sendable { + @Published public private(set) var peerStatus = PeerStatus.idle + @Published public private(set) var roomState = RoomState.empty + + private let handle: OpaquePointer + private let eventBoxPointer: UnsafeMutableRawPointer + private var joinContinuation: CheckedContinuation? + private var statusWaiters: [StatusWaiter] = [] + private var cameraPublishStarted = false + private var microphonePublishStarted = false + private var localCamera: VideoTrack? + private var closed = false + + private lazy var session: MediaSession = MediaSession( + call: { [unowned self] content in + _ = self.call(content) + }, + onMediaChanged: { [unowned self] in + DispatchQueue.main.async { + self.refreshMedia() + } + } + ) + + public init(fishjamId: String, debug: Bool = false) { + var config = Fishjam_Sdk_ClientConfig() + config.fishjamID = fishjamId + config.debug = debug + let bytes = (try? config.serializedData()) ?? Data() + let box = EventBox() + let userData = Unmanaged.passRetained(box).toOpaque() + guard let created = bytes.withUnsafeBytes({ raw in + fishjam_client_new( + raw.bindMemory(to: UInt8.self).baseAddress, + bytes.count, + fishjamOnEvent, + userData + ) + }) else { + fatalError("fishjam_client_new returned null") + } + handle = created + eventBoxPointer = userData + box.client = self + } + + deinit { + closed = true + session.close() + fishjam_client_free(handle) + Unmanaged.fromOpaque(eventBoxPointer).release() + } + + public func joinRoom(peerToken: String, peerMetadata: [String: Any] = [:]) async throws { + let metadata = try JSONSerialization.data(withJSONObject: peerMetadata) + let metadataJSON = String(data: metadata, encoding: .utf8) ?? "{}" + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.main.async { + self.joinContinuation = continuation + let (code, response) = self.call(.joinRoom(Fishjam_Sdk_JoinRoom.with { + $0.peerToken = peerToken + $0.peerMetadataJson = metadataJSON + })) + guard code == 0 else { + self.joinContinuation = nil + continuation.resume(throwing: JoinRoomError( + message: response.error.message, + authReason: response.error.authReason, + joinReason: response.error.joinReason + )) + return + } + } + } + } + + public func leaveRoom() { + DispatchQueue.main.async { + let continuation = self.joinContinuation + self.joinContinuation = nil + continuation?.resume(throwing: CancellationError()) + _ = self.call(.leaveRoom(Fishjam_Sdk_LeaveRoom())) + } + } + + public func startCamera() async throws { + let track = try await session.startCamera() + await MainActor.run { + showLocalCamera(track) + guard !cameraPublishStarted else { return } + cameraPublishStarted = true + publishWhileConnected(track: track, kind: .video, type: .camera) + } + } + + public func startMicrophone() async throws { + let track = try await session.startMicrophone() + await MainActor.run { + guard !microphonePublishStarted else { return } + microphonePublishStarted = true + publishWhileConnected(track: track, kind: .audio, type: .microphone) + } + } + + fileprivate func apply(_ data: Data) { + guard !closed, let event = try? Fishjam_Sdk_SdkEvent(serializedBytes: data) else { + return + } + guard let content = event.content else { return } + switch content { + case .peerStatusChanged: + applyStatus(event) + case .roomStateChanged(let changed): + roomState = withLocalCamera(makeRoomState(changed.roomState).attaching(session.mediaSnapshot())) + default: + session.handle(event) + } + } + + private func applyStatus(_ event: Fishjam_Sdk_SdkEvent) { + guard let update = peerStatusUpdate(event) else { return } + peerStatus = update.peerStatus + resumeStatusWaiters() + guard let continuation = joinContinuation else { return } + if update.peerStatus == .connected { + joinContinuation = nil + continuation.resume() + } else if let error = joinFailure(update) { + joinContinuation = nil + continuation.resume(throwing: error) + } + } + + private func publishWhileConnected( + track: RTCMediaStreamTrack, + kind: Fishjam_Sdk_TrackKind, + type: Fishjam_Sdk_TrackType + ) { + Task { + while !closed { + await waitUntil { $0 == .connected } + if closed { return } + do { + let trackId = try publish(kind: kind, type: type) + session.bind(trackId: trackId, track: track) + } catch { + return + } + await waitUntil { $0 != .connected } + } + } + } + + private func publish(kind: Fishjam_Sdk_TrackKind, type: Fishjam_Sdk_TrackType) throws -> String { + guard peerStatus == .connected else { + throw MediaErrorMessage("Cannot add tracks before being accepted by the server") + } + var metadata = Fishjam_Sdk_TrackMetadata() + metadata.type = type + metadata.paused = false + var add = Fishjam_Sdk_AddTrack() + add.kind = kind + add.metadata = metadata + let (code, response) = call(.addTrack(add)) + guard code == 0 else { + throw MediaErrorMessage(response.error.message) + } + return response.addTrack.trackID + } + + private func showLocalCamera(_ track: VideoTrack) { + localCamera = track + roomState = withLocalCamera(roomState) + } + + private func withLocalCamera(_ current: RoomState) -> RoomState { + guard let track = localCamera, current.localPeer?.cameraTrack?.videoTrack == nil else { + return current + } + let camera = Track( + trackId: current.localPeer?.cameraTrack?.trackId ?? "", + metadata: TrackMetadata(type: "camera", paused: false, displayName: nil), + videoTrack: track, + audioTrack: nil + ) + let otherTracks = current.localPeer?.tracks.filter { $0.metadata?.type != "camera" } ?? [] + var local = current.localPeer ?? .empty + local.cameraTrack = camera + local.tracks = [camera] + otherTracks + return RoomState(localPeer: local, remotePeers: current.remotePeers) + } + + private func refreshMedia() { + guard !closed else { return } + roomState = withLocalCamera(roomState.attaching(session.mediaSnapshot())) + } + + private func waitUntil(_ matches: @escaping (PeerStatus) -> Bool) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + DispatchQueue.main.async { + if self.closed || matches(self.peerStatus) { + continuation.resume() + return + } + self.statusWaiters.append(StatusWaiter(matches: matches, continuation: continuation)) + } + } + } + + private func resumeStatusWaiters() { + var stillWaiting: [StatusWaiter] = [] + for waiter in statusWaiters { + if waiter.matches(peerStatus) { + waiter.continuation.resume() + } else { + stillWaiting.append(waiter) + } + } + statusWaiters = stillWaiting + } + + private func call( + _ content: Fishjam_Sdk_SdkRequest.OneOf_Content + ) -> (Int32, Fishjam_Sdk_SdkResponse) { + if closed { + return (0, Fishjam_Sdk_SdkResponse()) + } + let request = Fishjam_Sdk_SdkRequest.with { $0.content = content } + let bytes = (try? request.serializedData()) ?? Data() + var out = FishjamBuf() + let code = bytes.withUnsafeBytes { raw in + fishjam_client_call( + handle, + raw.bindMemory(to: UInt8.self).baseAddress, + bytes.count, + &out + ) + } + let payload = out.data.map { Data(bytes: $0, count: out.len) } ?? Data() + fishjam_buf_free(out) + let response = (try? Fishjam_Sdk_SdkResponse(serializedBytes: payload)) ?? Fishjam_Sdk_SdkResponse() + return (code, response) + } +} + +private struct StatusWaiter { + let matches: (PeerStatus) -> Bool + let continuation: CheckedContinuation +} + +private struct MediaErrorMessage: LocalizedError { + let message: String + + init(_ message: String) { + self.message = message + } + + var errorDescription: String? { + message + } +} diff --git a/packages/ios-client/FishjamClient/Generated/sdk_api.pb.swift b/packages/ios-client/FishjamClient/Generated/sdk_api.pb.swift new file mode 100644 index 00000000..c2400fa1 --- /dev/null +++ b/packages/ios-client/FishjamClient/Generated/sdk_api.pb.swift @@ -0,0 +1,2933 @@ +// DO NOT EDIT. +// swift-format-ignore-file +// swiftlint:disable all +// +// Generated by the Swift generator plugin for the protocol buffer compiler. +// Source: sdk_api.proto +// +// For information on using the generated types, please see the documentation: +// https://github.com/apple/swift-protobuf/ + +import SwiftProtobuf + +// If the compiler emits an error on this type, it is because this file +// was generated by a version of the `protoc` Swift plug-in that is +// incompatible with the version of SwiftProtobuf to which you are linking. +// Please ensure that you are building against the same version of the API +// that was used to generate this file. +fileprivate nonisolated struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck { + struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {} + typealias Version = _2 +} + +nonisolated enum Fishjam_Sdk_TrackKind: SwiftProtobuf.Enum, Swift.CaseIterable { + typealias RawValue = Int + case unspecified // = 0 + case audio // = 1 + case video // = 2 + case UNRECOGNIZED(Int) + + init() { + self = .unspecified + } + + init?(rawValue: Int) { + switch rawValue { + case 0: self = .unspecified + case 1: self = .audio + case 2: self = .video + default: self = .UNRECOGNIZED(rawValue) + } + } + + var rawValue: Int { + switch self { + case .unspecified: return 0 + case .audio: return 1 + case .video: return 2 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + static let allCases: [Fishjam_Sdk_TrackKind] = [ + .unspecified, + .audio, + .video, + ] + +} + +nonisolated enum Fishjam_Sdk_TrackType: SwiftProtobuf.Enum, Swift.CaseIterable { + typealias RawValue = Int + case unspecified // = 0 + case camera // = 1 + case microphone // = 2 + case screenShareVideo // = 3 + case screenShareAudio // = 4 + case customVideo // = 5 + case customAudio // = 6 + case UNRECOGNIZED(Int) + + init() { + self = .unspecified + } + + init?(rawValue: Int) { + switch rawValue { + case 0: self = .unspecified + case 1: self = .camera + case 2: self = .microphone + case 3: self = .screenShareVideo + case 4: self = .screenShareAudio + case 5: self = .customVideo + case 6: self = .customAudio + default: self = .UNRECOGNIZED(rawValue) + } + } + + var rawValue: Int { + switch self { + case .unspecified: return 0 + case .camera: return 1 + case .microphone: return 2 + case .screenShareVideo: return 3 + case .screenShareAudio: return 4 + case .customVideo: return 5 + case .customAudio: return 6 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + static let allCases: [Fishjam_Sdk_TrackType] = [ + .unspecified, + .camera, + .microphone, + .screenShareVideo, + .screenShareAudio, + .customVideo, + .customAudio, + ] + +} + +/// Same values as fishjam.media_events.Variant. +nonisolated enum Fishjam_Sdk_Variant: SwiftProtobuf.Enum, Swift.CaseIterable { + typealias RawValue = Int + case unspecified // = 0 + case low // = 1 + case medium // = 2 + case high // = 3 + case UNRECOGNIZED(Int) + + init() { + self = .unspecified + } + + init?(rawValue: Int) { + switch rawValue { + case 0: self = .unspecified + case 1: self = .low + case 2: self = .medium + case 3: self = .high + default: self = .UNRECOGNIZED(rawValue) + } + } + + var rawValue: Int { + switch self { + case .unspecified: return 0 + case .low: return 1 + case .medium: return 2 + case .high: return 3 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + static let allCases: [Fishjam_Sdk_Variant] = [ + .unspecified, + .low, + .medium, + .high, + ] + +} + +nonisolated enum Fishjam_Sdk_PeerStatus: SwiftProtobuf.Enum, Swift.CaseIterable { + typealias RawValue = Int + case unspecified // = 0 + case idle // = 1 + case connecting // = 2 + case connected // = 3 + case error // = 4 + case UNRECOGNIZED(Int) + + init() { + self = .unspecified + } + + init?(rawValue: Int) { + switch rawValue { + case 0: self = .unspecified + case 1: self = .idle + case 2: self = .connecting + case 3: self = .connected + case 4: self = .error + default: self = .UNRECOGNIZED(rawValue) + } + } + + var rawValue: Int { + switch self { + case .unspecified: return 0 + case .idle: return 1 + case .connecting: return 2 + case .connected: return 3 + case .error: return 4 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + static let allCases: [Fishjam_Sdk_PeerStatus] = [ + .unspecified, + .idle, + .connecting, + .connected, + .error, + ] + +} + +nonisolated enum Fishjam_Sdk_NegotiationStatus: SwiftProtobuf.Enum, Swift.CaseIterable { + typealias RawValue = Int + case unspecified // = 0 + case awaiting // = 1 + case offered // = 2 + case done // = 3 + case UNRECOGNIZED(Int) + + init() { + self = .unspecified + } + + init?(rawValue: Int) { + switch rawValue { + case 0: self = .unspecified + case 1: self = .awaiting + case 2: self = .offered + case 3: self = .done + default: self = .UNRECOGNIZED(rawValue) + } + } + + var rawValue: Int { + switch self { + case .unspecified: return 0 + case .awaiting: return 1 + case .offered: return 2 + case .done: return 3 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + static let allCases: [Fishjam_Sdk_NegotiationStatus] = [ + .unspecified, + .awaiting, + .offered, + .done, + ] + +} + +nonisolated enum Fishjam_Sdk_SdpType: SwiftProtobuf.Enum, Swift.CaseIterable { + typealias RawValue = Int + case unspecified // = 0 + case offer // = 1 + case answer // = 2 + case UNRECOGNIZED(Int) + + init() { + self = .unspecified + } + + init?(rawValue: Int) { + switch rawValue { + case 0: self = .unspecified + case 1: self = .offer + case 2: self = .answer + default: self = .UNRECOGNIZED(rawValue) + } + } + + var rawValue: Int { + switch self { + case .unspecified: return 0 + case .offer: return 1 + case .answer: return 2 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + static let allCases: [Fishjam_Sdk_SdpType] = [ + .unspecified, + .offer, + .answer, + ] + +} + +/// Bytes passed to fishjam_client_new. +nonisolated struct Fishjam_Sdk_ClientConfig: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var fishjamID: String = String() + + var debug: Bool = false + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +/// Bytes passed to fishjam_client_call. +nonisolated struct Fishjam_Sdk_SdkRequest: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var content: Fishjam_Sdk_SdkRequest.OneOf_Content? = nil + + var joinRoom: Fishjam_Sdk_JoinRoom { + get { + if case .joinRoom(let v)? = content {return v} + return Fishjam_Sdk_JoinRoom() + } + set {content = .joinRoom(newValue)} + } + + var leaveRoom: Fishjam_Sdk_LeaveRoom { + get { + if case .leaveRoom(let v)? = content {return v} + return Fishjam_Sdk_LeaveRoom() + } + set {content = .leaveRoom(newValue)} + } + + var addTrack: Fishjam_Sdk_AddTrack { + get { + if case .addTrack(let v)? = content {return v} + return Fishjam_Sdk_AddTrack() + } + set {content = .addTrack(newValue)} + } + + var removeTrack: Fishjam_Sdk_RemoveTrack { + get { + if case .removeTrack(let v)? = content {return v} + return Fishjam_Sdk_RemoveTrack() + } + set {content = .removeTrack(newValue)} + } + + var createOfferResult: Fishjam_Sdk_CreateOfferResult { + get { + if case .createOfferResult(let v)? = content {return v} + return Fishjam_Sdk_CreateOfferResult() + } + set {content = .createOfferResult(newValue)} + } + + var setLocalDescriptionResult: Fishjam_Sdk_SetLocalDescriptionResult { + get { + if case .setLocalDescriptionResult(let v)? = content {return v} + return Fishjam_Sdk_SetLocalDescriptionResult() + } + set {content = .setLocalDescriptionResult(newValue)} + } + + var setRemoteDescriptionResult: Fishjam_Sdk_SetRemoteDescriptionResult { + get { + if case .setRemoteDescriptionResult(let v)? = content {return v} + return Fishjam_Sdk_SetRemoteDescriptionResult() + } + set {content = .setRemoteDescriptionResult(newValue)} + } + + var addIceCandidateResult: Fishjam_Sdk_AddIceCandidateResult { + get { + if case .addIceCandidateResult(let v)? = content {return v} + return Fishjam_Sdk_AddIceCandidateResult() + } + set {content = .addIceCandidateResult(newValue)} + } + + var localIceCandidate: Fishjam_Sdk_LocalIceCandidate { + get { + if case .localIceCandidate(let v)? = content {return v} + return Fishjam_Sdk_LocalIceCandidate() + } + set {content = .localIceCandidate(newValue)} + } + + var remoteTrackReady: Fishjam_Sdk_RemoteTrackReady { + get { + if case .remoteTrackReady(let v)? = content {return v} + return Fishjam_Sdk_RemoteTrackReady() + } + set {content = .remoteTrackReady(newValue)} + } + + var peerConnectionStateChanged: Fishjam_Sdk_PeerConnectionStateChanged { + get { + if case .peerConnectionStateChanged(let v)? = content {return v} + return Fishjam_Sdk_PeerConnectionStateChanged() + } + set {content = .peerConnectionStateChanged(newValue)} + } + + var unknownFields = SwiftProtobuf.UnknownStorage() + + nonisolated enum OneOf_Content: Equatable, Sendable { + case joinRoom(Fishjam_Sdk_JoinRoom) + case leaveRoom(Fishjam_Sdk_LeaveRoom) + case addTrack(Fishjam_Sdk_AddTrack) + case removeTrack(Fishjam_Sdk_RemoveTrack) + case createOfferResult(Fishjam_Sdk_CreateOfferResult) + case setLocalDescriptionResult(Fishjam_Sdk_SetLocalDescriptionResult) + case setRemoteDescriptionResult(Fishjam_Sdk_SetRemoteDescriptionResult) + case addIceCandidateResult(Fishjam_Sdk_AddIceCandidateResult) + case localIceCandidate(Fishjam_Sdk_LocalIceCandidate) + case remoteTrackReady(Fishjam_Sdk_RemoteTrackReady) + case peerConnectionStateChanged(Fishjam_Sdk_PeerConnectionStateChanged) + + } + + init() {} +} + +/// Bytes written to FishjamBuf by fishjam_client_call. +nonisolated struct Fishjam_Sdk_SdkResponse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var content: Fishjam_Sdk_SdkResponse.OneOf_Content? = nil + + var error: Fishjam_Sdk_Error { + get { + if case .error(let v)? = content {return v} + return Fishjam_Sdk_Error() + } + set {content = .error(newValue)} + } + + var empty: Fishjam_Sdk_Empty { + get { + if case .empty(let v)? = content {return v} + return Fishjam_Sdk_Empty() + } + set {content = .empty(newValue)} + } + + var addTrack: Fishjam_Sdk_AddTrackResult { + get { + if case .addTrack(let v)? = content {return v} + return Fishjam_Sdk_AddTrackResult() + } + set {content = .addTrack(newValue)} + } + + var roomState: Fishjam_Sdk_RoomState { + get { + if case .roomState(let v)? = content {return v} + return Fishjam_Sdk_RoomState() + } + set {content = .roomState(newValue)} + } + + var unknownFields = SwiftProtobuf.UnknownStorage() + + nonisolated enum OneOf_Content: Equatable, Sendable { + case error(Fishjam_Sdk_Error) + case empty(Fishjam_Sdk_Empty) + case addTrack(Fishjam_Sdk_AddTrackResult) + case roomState(Fishjam_Sdk_RoomState) + + } + + init() {} +} + +/// Bytes passed to FishjamOnEvent. The pointer is valid only for that call. +nonisolated struct Fishjam_Sdk_SdkEvent: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var content: Fishjam_Sdk_SdkEvent.OneOf_Content? = nil + + var peerStatusChanged: Fishjam_Sdk_PeerStatusChanged { + get { + if case .peerStatusChanged(let v)? = content {return v} + return Fishjam_Sdk_PeerStatusChanged() + } + set {content = .peerStatusChanged(newValue)} + } + + var roomStateChanged: Fishjam_Sdk_RoomStateChanged { + get { + if case .roomStateChanged(let v)? = content {return v} + return Fishjam_Sdk_RoomStateChanged() + } + set {content = .roomStateChanged(newValue)} + } + + var createPeerConnection: Fishjam_Sdk_CreatePeerConnection { + get { + if case .createPeerConnection(let v)? = content {return v} + return Fishjam_Sdk_CreatePeerConnection() + } + set {content = .createPeerConnection(newValue)} + } + + var updateRecvonlyTransceivers: Fishjam_Sdk_UpdateRecvonlyTransceivers { + get { + if case .updateRecvonlyTransceivers(let v)? = content {return v} + return Fishjam_Sdk_UpdateRecvonlyTransceivers() + } + set {content = .updateRecvonlyTransceivers(newValue)} + } + + var addSendonlyTransceiver: Fishjam_Sdk_AddSendonlyTransceiver { + get { + if case .addSendonlyTransceiver(let v)? = content {return v} + return Fishjam_Sdk_AddSendonlyTransceiver() + } + set {content = .addSendonlyTransceiver(newValue)} + } + + var removeSendTrack: Fishjam_Sdk_RemoveSendTrack { + get { + if case .removeSendTrack(let v)? = content {return v} + return Fishjam_Sdk_RemoveSendTrack() + } + set {content = .removeSendTrack(newValue)} + } + + var createOffer: Fishjam_Sdk_CreateOffer { + get { + if case .createOffer(let v)? = content {return v} + return Fishjam_Sdk_CreateOffer() + } + set {content = .createOffer(newValue)} + } + + var setLocalDescription: Fishjam_Sdk_SetLocalDescription { + get { + if case .setLocalDescription(let v)? = content {return v} + return Fishjam_Sdk_SetLocalDescription() + } + set {content = .setLocalDescription(newValue)} + } + + var setRemoteDescription: Fishjam_Sdk_SetRemoteDescription { + get { + if case .setRemoteDescription(let v)? = content {return v} + return Fishjam_Sdk_SetRemoteDescription() + } + set {content = .setRemoteDescription(newValue)} + } + + var addIceCandidate: Fishjam_Sdk_AddIceCandidate { + get { + if case .addIceCandidate(let v)? = content {return v} + return Fishjam_Sdk_AddIceCandidate() + } + set {content = .addIceCandidate(newValue)} + } + + var closePeerConnection: Fishjam_Sdk_ClosePeerConnection { + get { + if case .closePeerConnection(let v)? = content {return v} + return Fishjam_Sdk_ClosePeerConnection() + } + set {content = .closePeerConnection(newValue)} + } + + var unknownFields = SwiftProtobuf.UnknownStorage() + + nonisolated enum OneOf_Content: Equatable, Sendable { + case peerStatusChanged(Fishjam_Sdk_PeerStatusChanged) + case roomStateChanged(Fishjam_Sdk_RoomStateChanged) + case createPeerConnection(Fishjam_Sdk_CreatePeerConnection) + case updateRecvonlyTransceivers(Fishjam_Sdk_UpdateRecvonlyTransceivers) + case addSendonlyTransceiver(Fishjam_Sdk_AddSendonlyTransceiver) + case removeSendTrack(Fishjam_Sdk_RemoveSendTrack) + case createOffer(Fishjam_Sdk_CreateOffer) + case setLocalDescription(Fishjam_Sdk_SetLocalDescription) + case setRemoteDescription(Fishjam_Sdk_SetRemoteDescription) + case addIceCandidate(Fishjam_Sdk_AddIceCandidate) + case closePeerConnection(Fishjam_Sdk_ClosePeerConnection) + + } + + init() {} +} + +nonisolated struct Fishjam_Sdk_JoinRoom: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var peerToken: String = String() + + var peerMetadataJson: String = String() + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_LeaveRoom: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_Empty: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_Error: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var message: String = String() + + var authReason: String = String() + + var joinReason: String = String() + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_TrackMetadata: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var type: Fishjam_Sdk_TrackType = .unspecified + + var paused: Bool = false + + var displayName: String { + get {_displayName ?? String()} + set {_displayName = newValue} + } + /// Returns true if `displayName` has been explicitly set. + var hasDisplayName: Bool {self._displayName != nil} + /// Clears the value of `displayName`. Subsequent reads from it will return its default value. + mutating func clearDisplayName() {self._displayName = nil} + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + + fileprivate var _displayName: String? = nil +} + +nonisolated struct Fishjam_Sdk_AddTrack: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var kind: Fishjam_Sdk_TrackKind = .unspecified + + var metadata: Fishjam_Sdk_TrackMetadata { + get {_metadata ?? Fishjam_Sdk_TrackMetadata()} + set {_metadata = newValue} + } + /// Returns true if `metadata` has been explicitly set. + var hasMetadata: Bool {self._metadata != nil} + /// Clears the value of `metadata`. Subsequent reads from it will return its default value. + mutating func clearMetadata() {self._metadata = nil} + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + + fileprivate var _metadata: Fishjam_Sdk_TrackMetadata? = nil +} + +nonisolated struct Fishjam_Sdk_RemoveTrack: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var trackID: String = String() + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_AddTrackResult: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var trackID: String = String() + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_PeerStatusChanged: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var peerStatus: Fishjam_Sdk_PeerStatus = .unspecified + + var error: Fishjam_Sdk_Error { + get {_error ?? Fishjam_Sdk_Error()} + set {_error = newValue} + } + /// Returns true if `error` has been explicitly set. + var hasError: Bool {self._error != nil} + /// Clears the value of `error`. Subsequent reads from it will return its default value. + mutating func clearError() {self._error = nil} + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + + fileprivate var _error: Fishjam_Sdk_Error? = nil +} + +nonisolated struct Fishjam_Sdk_SimulcastConfig: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var enabled: Bool = false + + var enabledVariants: [Fishjam_Sdk_Variant] = [] + + var disabledVariants: [Fishjam_Sdk_Variant] = [] + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_TrackState: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var trackID: String = String() + + var metadata: Fishjam_Sdk_TrackMetadata { + get {_metadata ?? Fishjam_Sdk_TrackMetadata()} + set {_metadata = newValue} + } + /// Returns true if `metadata` has been explicitly set. + var hasMetadata: Bool {self._metadata != nil} + /// Clears the value of `metadata`. Subsequent reads from it will return its default value. + mutating func clearMetadata() {self._metadata = nil} + + var simulcastConfig: Fishjam_Sdk_SimulcastConfig { + get {_simulcastConfig ?? Fishjam_Sdk_SimulcastConfig()} + set {_simulcastConfig = newValue} + } + /// Returns true if `simulcastConfig` has been explicitly set. + var hasSimulcastConfig: Bool {self._simulcastConfig != nil} + /// Clears the value of `simulcastConfig`. Subsequent reads from it will return its default value. + mutating func clearSimulcastConfig() {self._simulcastConfig = nil} + + var negotiationStatus: Fishjam_Sdk_NegotiationStatus = .unspecified + + var encoding: Fishjam_Sdk_Variant = .unspecified + + var ready: Bool = false + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + + fileprivate var _metadata: Fishjam_Sdk_TrackMetadata? = nil + fileprivate var _simulcastConfig: Fishjam_Sdk_SimulcastConfig? = nil +} + +nonisolated struct Fishjam_Sdk_PeerState: @unchecked Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var id: String { + get {_storage._id} + set {_uniqueStorage()._id = newValue} + } + + var metadataJson: String { + get {_storage._metadataJson} + set {_uniqueStorage()._metadataJson = newValue} + } + + var tracks: [Fishjam_Sdk_TrackState] { + get {_storage._tracks} + set {_uniqueStorage()._tracks = newValue} + } + + var cameraTrack: Fishjam_Sdk_TrackState { + get {_storage._cameraTrack ?? Fishjam_Sdk_TrackState()} + set {_uniqueStorage()._cameraTrack = newValue} + } + /// Returns true if `cameraTrack` has been explicitly set. + var hasCameraTrack: Bool {_storage._cameraTrack != nil} + /// Clears the value of `cameraTrack`. Subsequent reads from it will return its default value. + mutating func clearCameraTrack() {_uniqueStorage()._cameraTrack = nil} + + var microphoneTrack: Fishjam_Sdk_TrackState { + get {_storage._microphoneTrack ?? Fishjam_Sdk_TrackState()} + set {_uniqueStorage()._microphoneTrack = newValue} + } + /// Returns true if `microphoneTrack` has been explicitly set. + var hasMicrophoneTrack: Bool {_storage._microphoneTrack != nil} + /// Clears the value of `microphoneTrack`. Subsequent reads from it will return its default value. + mutating func clearMicrophoneTrack() {_uniqueStorage()._microphoneTrack = nil} + + var screenShareVideoTrack: Fishjam_Sdk_TrackState { + get {_storage._screenShareVideoTrack ?? Fishjam_Sdk_TrackState()} + set {_uniqueStorage()._screenShareVideoTrack = newValue} + } + /// Returns true if `screenShareVideoTrack` has been explicitly set. + var hasScreenShareVideoTrack: Bool {_storage._screenShareVideoTrack != nil} + /// Clears the value of `screenShareVideoTrack`. Subsequent reads from it will return its default value. + mutating func clearScreenShareVideoTrack() {_uniqueStorage()._screenShareVideoTrack = nil} + + var screenShareAudioTrack: Fishjam_Sdk_TrackState { + get {_storage._screenShareAudioTrack ?? Fishjam_Sdk_TrackState()} + set {_uniqueStorage()._screenShareAudioTrack = newValue} + } + /// Returns true if `screenShareAudioTrack` has been explicitly set. + var hasScreenShareAudioTrack: Bool {_storage._screenShareAudioTrack != nil} + /// Clears the value of `screenShareAudioTrack`. Subsequent reads from it will return its default value. + mutating func clearScreenShareAudioTrack() {_uniqueStorage()._screenShareAudioTrack = nil} + + var customVideoTracks: [Fishjam_Sdk_TrackState] { + get {_storage._customVideoTracks} + set {_uniqueStorage()._customVideoTracks = newValue} + } + + var customAudioTracks: [Fishjam_Sdk_TrackState] { + get {_storage._customAudioTracks} + set {_uniqueStorage()._customAudioTracks = newValue} + } + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + + fileprivate var _storage = _StorageClass.defaultInstance +} + +nonisolated struct Fishjam_Sdk_RoomState: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var localPeer: Fishjam_Sdk_PeerState { + get {_localPeer ?? Fishjam_Sdk_PeerState()} + set {_localPeer = newValue} + } + /// Returns true if `localPeer` has been explicitly set. + var hasLocalPeer: Bool {self._localPeer != nil} + /// Clears the value of `localPeer`. Subsequent reads from it will return its default value. + mutating func clearLocalPeer() {self._localPeer = nil} + + var remotePeers: [Fishjam_Sdk_PeerState] = [] + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + + fileprivate var _localPeer: Fishjam_Sdk_PeerState? = nil +} + +nonisolated struct Fishjam_Sdk_RoomStateChanged: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var roomState: Fishjam_Sdk_RoomState { + get {_roomState ?? Fishjam_Sdk_RoomState()} + set {_roomState = newValue} + } + /// Returns true if `roomState` has been explicitly set. + var hasRoomState: Bool {self._roomState != nil} + /// Clears the value of `roomState`. Subsequent reads from it will return its default value. + mutating func clearRoomState() {self._roomState = nil} + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + + fileprivate var _roomState: Fishjam_Sdk_RoomState? = nil +} + +nonisolated struct Fishjam_Sdk_IceServer: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var credential: String = String() + + var urls: [String] = [] + + var username: String = String() + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_CreatePeerConnection: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var iceServers: [Fishjam_Sdk_IceServer] = [] + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_UpdateRecvonlyTransceivers: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var videoToAdd: Int32 = 0 + + var audioToAdd: Int32 = 0 + + var midsToStop: [String] = [] + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_SendEncoding: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var rid: String = String() + + var active: Bool = false + + var scaleResolutionDownBy: Double = 0 + + var maxBitrate: UInt32 = 0 + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_AddSendonlyTransceiver: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var trackID: String = String() + + var kind: Fishjam_Sdk_TrackKind = .unspecified + + var sendEncodings: [Fishjam_Sdk_SendEncoding] = [] + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_RemoveSendTrack: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var trackID: String = String() + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_CreateOffer: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_CreateOfferResult: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var sdp: String = String() + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_SetLocalDescription: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var type: Fishjam_Sdk_SdpType = .unspecified + + var sdp: String = String() + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_SetLocalDescriptionResult: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var midToTrackID: Dictionary = [:] + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_SetRemoteDescription: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var type: Fishjam_Sdk_SdpType = .unspecified + + var sdp: String = String() + + var midToTrackID: Dictionary = [:] + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_SetRemoteDescriptionResult: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_AddIceCandidate: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var candidate: String = String() + + var sdpMid: String = String() + + var sdpMLineIndex: Int32 = 0 + + var usernameFragment: String = String() + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_AddIceCandidateResult: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_LocalIceCandidate: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var candidate: String = String() + + var sdpMid: String = String() + + var sdpMLineIndex: Int32 = 0 + + var usernameFragment: String = String() + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_RemoteTrackReady: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var mid: String = String() + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_PeerConnectionStateChanged: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var connectionState: String = String() + + var iceConnectionState: String = String() + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_ClosePeerConnection: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +// MARK: - Code below here is support for the SwiftProtobuf runtime. + +fileprivate nonisolated let _protobuf_package = "fishjam.sdk" + +nonisolated extension Fishjam_Sdk_TrackKind: SwiftProtobuf._ProtoNameProviding { + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0TRACK_KIND_UNSPECIFIED\0\u{1}TRACK_KIND_AUDIO\0\u{1}TRACK_KIND_VIDEO\0") +} + +nonisolated extension Fishjam_Sdk_TrackType: SwiftProtobuf._ProtoNameProviding { + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0TRACK_TYPE_UNSPECIFIED\0\u{1}TRACK_TYPE_CAMERA\0\u{1}TRACK_TYPE_MICROPHONE\0\u{1}TRACK_TYPE_SCREEN_SHARE_VIDEO\0\u{1}TRACK_TYPE_SCREEN_SHARE_AUDIO\0\u{1}TRACK_TYPE_CUSTOM_VIDEO\0\u{1}TRACK_TYPE_CUSTOM_AUDIO\0") +} + +nonisolated extension Fishjam_Sdk_Variant: SwiftProtobuf._ProtoNameProviding { + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0VARIANT_UNSPECIFIED\0\u{1}VARIANT_LOW\0\u{1}VARIANT_MEDIUM\0\u{1}VARIANT_HIGH\0") +} + +nonisolated extension Fishjam_Sdk_PeerStatus: SwiftProtobuf._ProtoNameProviding { + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0PEER_STATUS_UNSPECIFIED\0\u{1}PEER_STATUS_IDLE\0\u{1}PEER_STATUS_CONNECTING\0\u{1}PEER_STATUS_CONNECTED\0\u{1}PEER_STATUS_ERROR\0") +} + +nonisolated extension Fishjam_Sdk_NegotiationStatus: SwiftProtobuf._ProtoNameProviding { + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0NEGOTIATION_STATUS_UNSPECIFIED\0\u{1}NEGOTIATION_STATUS_AWAITING\0\u{1}NEGOTIATION_STATUS_OFFERED\0\u{1}NEGOTIATION_STATUS_DONE\0") +} + +nonisolated extension Fishjam_Sdk_SdpType: SwiftProtobuf._ProtoNameProviding { + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0SDP_TYPE_UNSPECIFIED\0\u{1}SDP_TYPE_OFFER\0\u{1}SDP_TYPE_ANSWER\0") +} + +nonisolated extension Fishjam_Sdk_ClientConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".ClientConfig" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}fishjam_id\0\u{1}debug\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.fishjamID) }() + case 2: try { try decoder.decodeSingularBoolField(value: &self.debug) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.fishjamID.isEmpty { + try visitor.visitSingularStringField(value: self.fishjamID, fieldNumber: 1) + } + if self.debug != false { + try visitor.visitSingularBoolField(value: self.debug, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_ClientConfig, rhs: Fishjam_Sdk_ClientConfig) -> Bool { + if lhs.fishjamID != rhs.fishjamID {return false} + if lhs.debug != rhs.debug {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_SdkRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".SdkRequest" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{4}\u{2}join_room\0\u{3}leave_room\0\u{3}add_track\0\u{3}remove_track\0\u{3}create_offer_result\0\u{3}set_local_description_result\0\u{3}set_remote_description_result\0\u{3}add_ice_candidate_result\0\u{3}local_ice_candidate\0\u{3}remote_track_ready\0\u{3}peer_connection_state_changed\0\u{c}\u{1}\u{1}") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 2: try { + var v: Fishjam_Sdk_JoinRoom? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .joinRoom(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .joinRoom(v) + } + }() + case 3: try { + var v: Fishjam_Sdk_LeaveRoom? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .leaveRoom(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .leaveRoom(v) + } + }() + case 4: try { + var v: Fishjam_Sdk_AddTrack? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .addTrack(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .addTrack(v) + } + }() + case 5: try { + var v: Fishjam_Sdk_RemoveTrack? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .removeTrack(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .removeTrack(v) + } + }() + case 6: try { + var v: Fishjam_Sdk_CreateOfferResult? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .createOfferResult(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .createOfferResult(v) + } + }() + case 7: try { + var v: Fishjam_Sdk_SetLocalDescriptionResult? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .setLocalDescriptionResult(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .setLocalDescriptionResult(v) + } + }() + case 8: try { + var v: Fishjam_Sdk_SetRemoteDescriptionResult? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .setRemoteDescriptionResult(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .setRemoteDescriptionResult(v) + } + }() + case 9: try { + var v: Fishjam_Sdk_AddIceCandidateResult? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .addIceCandidateResult(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .addIceCandidateResult(v) + } + }() + case 10: try { + var v: Fishjam_Sdk_LocalIceCandidate? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .localIceCandidate(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .localIceCandidate(v) + } + }() + case 11: try { + var v: Fishjam_Sdk_RemoteTrackReady? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .remoteTrackReady(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .remoteTrackReady(v) + } + }() + case 12: try { + var v: Fishjam_Sdk_PeerConnectionStateChanged? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .peerConnectionStateChanged(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .peerConnectionStateChanged(v) + } + }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + switch self.content { + case .joinRoom?: try { + guard case .joinRoom(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + }() + case .leaveRoom?: try { + guard case .leaveRoom(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 3) + }() + case .addTrack?: try { + guard case .addTrack(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 4) + }() + case .removeTrack?: try { + guard case .removeTrack(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 5) + }() + case .createOfferResult?: try { + guard case .createOfferResult(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 6) + }() + case .setLocalDescriptionResult?: try { + guard case .setLocalDescriptionResult(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 7) + }() + case .setRemoteDescriptionResult?: try { + guard case .setRemoteDescriptionResult(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 8) + }() + case .addIceCandidateResult?: try { + guard case .addIceCandidateResult(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 9) + }() + case .localIceCandidate?: try { + guard case .localIceCandidate(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 10) + }() + case .remoteTrackReady?: try { + guard case .remoteTrackReady(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 11) + }() + case .peerConnectionStateChanged?: try { + guard case .peerConnectionStateChanged(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 12) + }() + case nil: break + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_SdkRequest, rhs: Fishjam_Sdk_SdkRequest) -> Bool { + if lhs.content != rhs.content {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_SdkResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".SdkResponse" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}error\0\u{2}\u{2}empty\0\u{3}add_track\0\u{3}room_state\0\u{c}\u{2}\u{1}") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { + var v: Fishjam_Sdk_Error? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .error(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .error(v) + } + }() + case 3: try { + var v: Fishjam_Sdk_Empty? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .empty(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .empty(v) + } + }() + case 4: try { + var v: Fishjam_Sdk_AddTrackResult? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .addTrack(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .addTrack(v) + } + }() + case 5: try { + var v: Fishjam_Sdk_RoomState? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .roomState(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .roomState(v) + } + }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + switch self.content { + case .error?: try { + guard case .error(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + }() + case .empty?: try { + guard case .empty(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 3) + }() + case .addTrack?: try { + guard case .addTrack(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 4) + }() + case .roomState?: try { + guard case .roomState(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 5) + }() + case nil: break + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_SdkResponse, rhs: Fishjam_Sdk_SdkResponse) -> Bool { + if lhs.content != rhs.content {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_SdkEvent: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".SdkEvent" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}peer_status_changed\0\u{3}room_state_changed\0\u{3}create_peer_connection\0\u{3}update_recvonly_transceivers\0\u{3}add_sendonly_transceiver\0\u{3}remove_send_track\0\u{3}create_offer\0\u{3}set_local_description\0\u{3}set_remote_description\0\u{3}add_ice_candidate\0\u{3}close_peer_connection\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { + var v: Fishjam_Sdk_PeerStatusChanged? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .peerStatusChanged(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .peerStatusChanged(v) + } + }() + case 2: try { + var v: Fishjam_Sdk_RoomStateChanged? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .roomStateChanged(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .roomStateChanged(v) + } + }() + case 3: try { + var v: Fishjam_Sdk_CreatePeerConnection? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .createPeerConnection(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .createPeerConnection(v) + } + }() + case 4: try { + var v: Fishjam_Sdk_UpdateRecvonlyTransceivers? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .updateRecvonlyTransceivers(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .updateRecvonlyTransceivers(v) + } + }() + case 5: try { + var v: Fishjam_Sdk_AddSendonlyTransceiver? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .addSendonlyTransceiver(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .addSendonlyTransceiver(v) + } + }() + case 6: try { + var v: Fishjam_Sdk_RemoveSendTrack? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .removeSendTrack(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .removeSendTrack(v) + } + }() + case 7: try { + var v: Fishjam_Sdk_CreateOffer? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .createOffer(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .createOffer(v) + } + }() + case 8: try { + var v: Fishjam_Sdk_SetLocalDescription? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .setLocalDescription(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .setLocalDescription(v) + } + }() + case 9: try { + var v: Fishjam_Sdk_SetRemoteDescription? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .setRemoteDescription(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .setRemoteDescription(v) + } + }() + case 10: try { + var v: Fishjam_Sdk_AddIceCandidate? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .addIceCandidate(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .addIceCandidate(v) + } + }() + case 11: try { + var v: Fishjam_Sdk_ClosePeerConnection? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .closePeerConnection(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .closePeerConnection(v) + } + }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + switch self.content { + case .peerStatusChanged?: try { + guard case .peerStatusChanged(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + }() + case .roomStateChanged?: try { + guard case .roomStateChanged(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + }() + case .createPeerConnection?: try { + guard case .createPeerConnection(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 3) + }() + case .updateRecvonlyTransceivers?: try { + guard case .updateRecvonlyTransceivers(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 4) + }() + case .addSendonlyTransceiver?: try { + guard case .addSendonlyTransceiver(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 5) + }() + case .removeSendTrack?: try { + guard case .removeSendTrack(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 6) + }() + case .createOffer?: try { + guard case .createOffer(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 7) + }() + case .setLocalDescription?: try { + guard case .setLocalDescription(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 8) + }() + case .setRemoteDescription?: try { + guard case .setRemoteDescription(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 9) + }() + case .addIceCandidate?: try { + guard case .addIceCandidate(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 10) + }() + case .closePeerConnection?: try { + guard case .closePeerConnection(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 11) + }() + case nil: break + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_SdkEvent, rhs: Fishjam_Sdk_SdkEvent) -> Bool { + if lhs.content != rhs.content {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_JoinRoom: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".JoinRoom" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}peer_token\0\u{3}peer_metadata_json\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.peerToken) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.peerMetadataJson) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.peerToken.isEmpty { + try visitor.visitSingularStringField(value: self.peerToken, fieldNumber: 1) + } + if !self.peerMetadataJson.isEmpty { + try visitor.visitSingularStringField(value: self.peerMetadataJson, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_JoinRoom, rhs: Fishjam_Sdk_JoinRoom) -> Bool { + if lhs.peerToken != rhs.peerToken {return false} + if lhs.peerMetadataJson != rhs.peerMetadataJson {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_LeaveRoom: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".LeaveRoom" + static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_LeaveRoom, rhs: Fishjam_Sdk_LeaveRoom) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_Empty: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".Empty" + static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_Empty, rhs: Fishjam_Sdk_Empty) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_Error: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".Error" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}message\0\u{3}auth_reason\0\u{3}join_reason\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.message) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.authReason) }() + case 3: try { try decoder.decodeSingularStringField(value: &self.joinReason) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.message.isEmpty { + try visitor.visitSingularStringField(value: self.message, fieldNumber: 1) + } + if !self.authReason.isEmpty { + try visitor.visitSingularStringField(value: self.authReason, fieldNumber: 2) + } + if !self.joinReason.isEmpty { + try visitor.visitSingularStringField(value: self.joinReason, fieldNumber: 3) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_Error, rhs: Fishjam_Sdk_Error) -> Bool { + if lhs.message != rhs.message {return false} + if lhs.authReason != rhs.authReason {return false} + if lhs.joinReason != rhs.joinReason {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_TrackMetadata: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".TrackMetadata" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}type\0\u{1}paused\0\u{3}display_name\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularEnumField(value: &self.type) }() + case 2: try { try decoder.decodeSingularBoolField(value: &self.paused) }() + case 3: try { try decoder.decodeSingularStringField(value: &self._displayName) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if self.type != .unspecified { + try visitor.visitSingularEnumField(value: self.type, fieldNumber: 1) + } + if self.paused != false { + try visitor.visitSingularBoolField(value: self.paused, fieldNumber: 2) + } + try { if let v = self._displayName { + try visitor.visitSingularStringField(value: v, fieldNumber: 3) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_TrackMetadata, rhs: Fishjam_Sdk_TrackMetadata) -> Bool { + if lhs.type != rhs.type {return false} + if lhs.paused != rhs.paused {return false} + if lhs._displayName != rhs._displayName {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_AddTrack: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".AddTrack" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}kind\0\u{1}metadata\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularEnumField(value: &self.kind) }() + case 2: try { try decoder.decodeSingularMessageField(value: &self._metadata) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if self.kind != .unspecified { + try visitor.visitSingularEnumField(value: self.kind, fieldNumber: 1) + } + try { if let v = self._metadata { + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_AddTrack, rhs: Fishjam_Sdk_AddTrack) -> Bool { + if lhs.kind != rhs.kind {return false} + if lhs._metadata != rhs._metadata {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_RemoveTrack: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".RemoveTrack" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}track_id\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.trackID) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.trackID.isEmpty { + try visitor.visitSingularStringField(value: self.trackID, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_RemoveTrack, rhs: Fishjam_Sdk_RemoveTrack) -> Bool { + if lhs.trackID != rhs.trackID {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_AddTrackResult: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".AddTrackResult" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}track_id\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.trackID) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.trackID.isEmpty { + try visitor.visitSingularStringField(value: self.trackID, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_AddTrackResult, rhs: Fishjam_Sdk_AddTrackResult) -> Bool { + if lhs.trackID != rhs.trackID {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_PeerStatusChanged: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".PeerStatusChanged" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}peer_status\0\u{1}error\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularEnumField(value: &self.peerStatus) }() + case 2: try { try decoder.decodeSingularMessageField(value: &self._error) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if self.peerStatus != .unspecified { + try visitor.visitSingularEnumField(value: self.peerStatus, fieldNumber: 1) + } + try { if let v = self._error { + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_PeerStatusChanged, rhs: Fishjam_Sdk_PeerStatusChanged) -> Bool { + if lhs.peerStatus != rhs.peerStatus {return false} + if lhs._error != rhs._error {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_SimulcastConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".SimulcastConfig" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}enabled\0\u{3}enabled_variants\0\u{3}disabled_variants\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularBoolField(value: &self.enabled) }() + case 2: try { try decoder.decodeRepeatedEnumField(value: &self.enabledVariants) }() + case 3: try { try decoder.decodeRepeatedEnumField(value: &self.disabledVariants) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if self.enabled != false { + try visitor.visitSingularBoolField(value: self.enabled, fieldNumber: 1) + } + if !self.enabledVariants.isEmpty { + try visitor.visitPackedEnumField(value: self.enabledVariants, fieldNumber: 2) + } + if !self.disabledVariants.isEmpty { + try visitor.visitPackedEnumField(value: self.disabledVariants, fieldNumber: 3) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_SimulcastConfig, rhs: Fishjam_Sdk_SimulcastConfig) -> Bool { + if lhs.enabled != rhs.enabled {return false} + if lhs.enabledVariants != rhs.enabledVariants {return false} + if lhs.disabledVariants != rhs.disabledVariants {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_TrackState: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".TrackState" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}track_id\0\u{1}metadata\0\u{3}simulcast_config\0\u{3}negotiation_status\0\u{1}encoding\0\u{1}ready\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.trackID) }() + case 2: try { try decoder.decodeSingularMessageField(value: &self._metadata) }() + case 3: try { try decoder.decodeSingularMessageField(value: &self._simulcastConfig) }() + case 4: try { try decoder.decodeSingularEnumField(value: &self.negotiationStatus) }() + case 5: try { try decoder.decodeSingularEnumField(value: &self.encoding) }() + case 6: try { try decoder.decodeSingularBoolField(value: &self.ready) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if !self.trackID.isEmpty { + try visitor.visitSingularStringField(value: self.trackID, fieldNumber: 1) + } + try { if let v = self._metadata { + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + } }() + try { if let v = self._simulcastConfig { + try visitor.visitSingularMessageField(value: v, fieldNumber: 3) + } }() + if self.negotiationStatus != .unspecified { + try visitor.visitSingularEnumField(value: self.negotiationStatus, fieldNumber: 4) + } + if self.encoding != .unspecified { + try visitor.visitSingularEnumField(value: self.encoding, fieldNumber: 5) + } + if self.ready != false { + try visitor.visitSingularBoolField(value: self.ready, fieldNumber: 6) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_TrackState, rhs: Fishjam_Sdk_TrackState) -> Bool { + if lhs.trackID != rhs.trackID {return false} + if lhs._metadata != rhs._metadata {return false} + if lhs._simulcastConfig != rhs._simulcastConfig {return false} + if lhs.negotiationStatus != rhs.negotiationStatus {return false} + if lhs.encoding != rhs.encoding {return false} + if lhs.ready != rhs.ready {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_PeerState: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".PeerState" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{3}metadata_json\0\u{1}tracks\0\u{3}camera_track\0\u{3}microphone_track\0\u{3}screen_share_video_track\0\u{3}screen_share_audio_track\0\u{3}custom_video_tracks\0\u{3}custom_audio_tracks\0") + + fileprivate class _StorageClass { + var _id: String = String() + var _metadataJson: String = String() + var _tracks: [Fishjam_Sdk_TrackState] = [] + var _cameraTrack: Fishjam_Sdk_TrackState? = nil + var _microphoneTrack: Fishjam_Sdk_TrackState? = nil + var _screenShareVideoTrack: Fishjam_Sdk_TrackState? = nil + var _screenShareAudioTrack: Fishjam_Sdk_TrackState? = nil + var _customVideoTracks: [Fishjam_Sdk_TrackState] = [] + var _customAudioTracks: [Fishjam_Sdk_TrackState] = [] + + // This property is used as the initial default value for new instances of the type. + // The type itself is protecting the reference to its storage via CoW semantics. + // This will force a copy to be made of this reference when the first mutation occurs; + // hence, it is safe to mark this as `nonisolated(unsafe)`. + static nonisolated(unsafe) let defaultInstance = _StorageClass() + + private init() {} + + init(copying source: _StorageClass) { + _id = source._id + _metadataJson = source._metadataJson + _tracks = source._tracks + _cameraTrack = source._cameraTrack + _microphoneTrack = source._microphoneTrack + _screenShareVideoTrack = source._screenShareVideoTrack + _screenShareAudioTrack = source._screenShareAudioTrack + _customVideoTracks = source._customVideoTracks + _customAudioTracks = source._customAudioTracks + } + } + + fileprivate mutating func _uniqueStorage() -> _StorageClass { + if !isKnownUniquelyReferenced(&_storage) { + _storage = _StorageClass(copying: _storage) + } + return _storage + } + + mutating func decodeMessage(decoder: inout D) throws { + _ = _uniqueStorage() + try withExtendedLifetime(_storage) { (_storage: _StorageClass) in + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &_storage._id) }() + case 2: try { try decoder.decodeSingularStringField(value: &_storage._metadataJson) }() + case 3: try { try decoder.decodeRepeatedMessageField(value: &_storage._tracks) }() + case 4: try { try decoder.decodeSingularMessageField(value: &_storage._cameraTrack) }() + case 5: try { try decoder.decodeSingularMessageField(value: &_storage._microphoneTrack) }() + case 6: try { try decoder.decodeSingularMessageField(value: &_storage._screenShareVideoTrack) }() + case 7: try { try decoder.decodeSingularMessageField(value: &_storage._screenShareAudioTrack) }() + case 8: try { try decoder.decodeRepeatedMessageField(value: &_storage._customVideoTracks) }() + case 9: try { try decoder.decodeRepeatedMessageField(value: &_storage._customAudioTracks) }() + default: break + } + } + } + } + + func traverse(visitor: inout V) throws { + try withExtendedLifetime(_storage) { (_storage: _StorageClass) in + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if !_storage._id.isEmpty { + try visitor.visitSingularStringField(value: _storage._id, fieldNumber: 1) + } + if !_storage._metadataJson.isEmpty { + try visitor.visitSingularStringField(value: _storage._metadataJson, fieldNumber: 2) + } + if !_storage._tracks.isEmpty { + try visitor.visitRepeatedMessageField(value: _storage._tracks, fieldNumber: 3) + } + try { if let v = _storage._cameraTrack { + try visitor.visitSingularMessageField(value: v, fieldNumber: 4) + } }() + try { if let v = _storage._microphoneTrack { + try visitor.visitSingularMessageField(value: v, fieldNumber: 5) + } }() + try { if let v = _storage._screenShareVideoTrack { + try visitor.visitSingularMessageField(value: v, fieldNumber: 6) + } }() + try { if let v = _storage._screenShareAudioTrack { + try visitor.visitSingularMessageField(value: v, fieldNumber: 7) + } }() + if !_storage._customVideoTracks.isEmpty { + try visitor.visitRepeatedMessageField(value: _storage._customVideoTracks, fieldNumber: 8) + } + if !_storage._customAudioTracks.isEmpty { + try visitor.visitRepeatedMessageField(value: _storage._customAudioTracks, fieldNumber: 9) + } + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_PeerState, rhs: Fishjam_Sdk_PeerState) -> Bool { + if lhs._storage !== rhs._storage { + let storagesAreEqual: Bool = withExtendedLifetime((lhs._storage, rhs._storage)) { (_args: (_StorageClass, _StorageClass)) in + let _storage = _args.0 + let rhs_storage = _args.1 + if _storage._id != rhs_storage._id {return false} + if _storage._metadataJson != rhs_storage._metadataJson {return false} + if _storage._tracks != rhs_storage._tracks {return false} + if _storage._cameraTrack != rhs_storage._cameraTrack {return false} + if _storage._microphoneTrack != rhs_storage._microphoneTrack {return false} + if _storage._screenShareVideoTrack != rhs_storage._screenShareVideoTrack {return false} + if _storage._screenShareAudioTrack != rhs_storage._screenShareAudioTrack {return false} + if _storage._customVideoTracks != rhs_storage._customVideoTracks {return false} + if _storage._customAudioTracks != rhs_storage._customAudioTracks {return false} + return true + } + if !storagesAreEqual {return false} + } + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_RoomState: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".RoomState" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}local_peer\0\u{3}remote_peers\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularMessageField(value: &self._localPeer) }() + case 2: try { try decoder.decodeRepeatedMessageField(value: &self.remotePeers) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._localPeer { + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + } }() + if !self.remotePeers.isEmpty { + try visitor.visitRepeatedMessageField(value: self.remotePeers, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_RoomState, rhs: Fishjam_Sdk_RoomState) -> Bool { + if lhs._localPeer != rhs._localPeer {return false} + if lhs.remotePeers != rhs.remotePeers {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_RoomStateChanged: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".RoomStateChanged" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}room_state\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularMessageField(value: &self._roomState) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._roomState { + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_RoomStateChanged, rhs: Fishjam_Sdk_RoomStateChanged) -> Bool { + if lhs._roomState != rhs._roomState {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_IceServer: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".IceServer" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}credential\0\u{1}urls\0\u{1}username\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.credential) }() + case 2: try { try decoder.decodeRepeatedStringField(value: &self.urls) }() + case 3: try { try decoder.decodeSingularStringField(value: &self.username) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.credential.isEmpty { + try visitor.visitSingularStringField(value: self.credential, fieldNumber: 1) + } + if !self.urls.isEmpty { + try visitor.visitRepeatedStringField(value: self.urls, fieldNumber: 2) + } + if !self.username.isEmpty { + try visitor.visitSingularStringField(value: self.username, fieldNumber: 3) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_IceServer, rhs: Fishjam_Sdk_IceServer) -> Bool { + if lhs.credential != rhs.credential {return false} + if lhs.urls != rhs.urls {return false} + if lhs.username != rhs.username {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_CreatePeerConnection: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".CreatePeerConnection" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}ice_servers\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeRepeatedMessageField(value: &self.iceServers) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.iceServers.isEmpty { + try visitor.visitRepeatedMessageField(value: self.iceServers, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_CreatePeerConnection, rhs: Fishjam_Sdk_CreatePeerConnection) -> Bool { + if lhs.iceServers != rhs.iceServers {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_UpdateRecvonlyTransceivers: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".UpdateRecvonlyTransceivers" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}video_to_add\0\u{3}audio_to_add\0\u{3}mids_to_stop\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularInt32Field(value: &self.videoToAdd) }() + case 2: try { try decoder.decodeSingularInt32Field(value: &self.audioToAdd) }() + case 3: try { try decoder.decodeRepeatedStringField(value: &self.midsToStop) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if self.videoToAdd != 0 { + try visitor.visitSingularInt32Field(value: self.videoToAdd, fieldNumber: 1) + } + if self.audioToAdd != 0 { + try visitor.visitSingularInt32Field(value: self.audioToAdd, fieldNumber: 2) + } + if !self.midsToStop.isEmpty { + try visitor.visitRepeatedStringField(value: self.midsToStop, fieldNumber: 3) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_UpdateRecvonlyTransceivers, rhs: Fishjam_Sdk_UpdateRecvonlyTransceivers) -> Bool { + if lhs.videoToAdd != rhs.videoToAdd {return false} + if lhs.audioToAdd != rhs.audioToAdd {return false} + if lhs.midsToStop != rhs.midsToStop {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_SendEncoding: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".SendEncoding" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}rid\0\u{1}active\0\u{3}scale_resolution_down_by\0\u{3}max_bitrate\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.rid) }() + case 2: try { try decoder.decodeSingularBoolField(value: &self.active) }() + case 3: try { try decoder.decodeSingularDoubleField(value: &self.scaleResolutionDownBy) }() + case 4: try { try decoder.decodeSingularUInt32Field(value: &self.maxBitrate) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.rid.isEmpty { + try visitor.visitSingularStringField(value: self.rid, fieldNumber: 1) + } + if self.active != false { + try visitor.visitSingularBoolField(value: self.active, fieldNumber: 2) + } + if self.scaleResolutionDownBy.bitPattern != 0 { + try visitor.visitSingularDoubleField(value: self.scaleResolutionDownBy, fieldNumber: 3) + } + if self.maxBitrate != 0 { + try visitor.visitSingularUInt32Field(value: self.maxBitrate, fieldNumber: 4) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_SendEncoding, rhs: Fishjam_Sdk_SendEncoding) -> Bool { + if lhs.rid != rhs.rid {return false} + if lhs.active != rhs.active {return false} + if lhs.scaleResolutionDownBy != rhs.scaleResolutionDownBy {return false} + if lhs.maxBitrate != rhs.maxBitrate {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_AddSendonlyTransceiver: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".AddSendonlyTransceiver" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}track_id\0\u{1}kind\0\u{3}send_encodings\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.trackID) }() + case 2: try { try decoder.decodeSingularEnumField(value: &self.kind) }() + case 3: try { try decoder.decodeRepeatedMessageField(value: &self.sendEncodings) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.trackID.isEmpty { + try visitor.visitSingularStringField(value: self.trackID, fieldNumber: 1) + } + if self.kind != .unspecified { + try visitor.visitSingularEnumField(value: self.kind, fieldNumber: 2) + } + if !self.sendEncodings.isEmpty { + try visitor.visitRepeatedMessageField(value: self.sendEncodings, fieldNumber: 3) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_AddSendonlyTransceiver, rhs: Fishjam_Sdk_AddSendonlyTransceiver) -> Bool { + if lhs.trackID != rhs.trackID {return false} + if lhs.kind != rhs.kind {return false} + if lhs.sendEncodings != rhs.sendEncodings {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_RemoveSendTrack: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".RemoveSendTrack" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}track_id\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.trackID) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.trackID.isEmpty { + try visitor.visitSingularStringField(value: self.trackID, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_RemoveSendTrack, rhs: Fishjam_Sdk_RemoveSendTrack) -> Bool { + if lhs.trackID != rhs.trackID {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_CreateOffer: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".CreateOffer" + static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_CreateOffer, rhs: Fishjam_Sdk_CreateOffer) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_CreateOfferResult: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".CreateOfferResult" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}sdp\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.sdp) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.sdp.isEmpty { + try visitor.visitSingularStringField(value: self.sdp, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_CreateOfferResult, rhs: Fishjam_Sdk_CreateOfferResult) -> Bool { + if lhs.sdp != rhs.sdp {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_SetLocalDescription: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".SetLocalDescription" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}type\0\u{1}sdp\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularEnumField(value: &self.type) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.sdp) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if self.type != .unspecified { + try visitor.visitSingularEnumField(value: self.type, fieldNumber: 1) + } + if !self.sdp.isEmpty { + try visitor.visitSingularStringField(value: self.sdp, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_SetLocalDescription, rhs: Fishjam_Sdk_SetLocalDescription) -> Bool { + if lhs.type != rhs.type {return false} + if lhs.sdp != rhs.sdp {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_SetLocalDescriptionResult: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".SetLocalDescriptionResult" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}mid_to_track_id\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: &self.midToTrackID) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.midToTrackID.isEmpty { + try visitor.visitMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: self.midToTrackID, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_SetLocalDescriptionResult, rhs: Fishjam_Sdk_SetLocalDescriptionResult) -> Bool { + if lhs.midToTrackID != rhs.midToTrackID {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_SetRemoteDescription: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".SetRemoteDescription" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}type\0\u{1}sdp\0\u{3}mid_to_track_id\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularEnumField(value: &self.type) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.sdp) }() + case 3: try { try decoder.decodeMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: &self.midToTrackID) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if self.type != .unspecified { + try visitor.visitSingularEnumField(value: self.type, fieldNumber: 1) + } + if !self.sdp.isEmpty { + try visitor.visitSingularStringField(value: self.sdp, fieldNumber: 2) + } + if !self.midToTrackID.isEmpty { + try visitor.visitMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: self.midToTrackID, fieldNumber: 3) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_SetRemoteDescription, rhs: Fishjam_Sdk_SetRemoteDescription) -> Bool { + if lhs.type != rhs.type {return false} + if lhs.sdp != rhs.sdp {return false} + if lhs.midToTrackID != rhs.midToTrackID {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_SetRemoteDescriptionResult: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".SetRemoteDescriptionResult" + static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_SetRemoteDescriptionResult, rhs: Fishjam_Sdk_SetRemoteDescriptionResult) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_AddIceCandidate: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".AddIceCandidate" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}candidate\0\u{3}sdp_mid\0\u{3}sdp_m_line_index\0\u{3}username_fragment\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.candidate) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.sdpMid) }() + case 3: try { try decoder.decodeSingularInt32Field(value: &self.sdpMLineIndex) }() + case 4: try { try decoder.decodeSingularStringField(value: &self.usernameFragment) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.candidate.isEmpty { + try visitor.visitSingularStringField(value: self.candidate, fieldNumber: 1) + } + if !self.sdpMid.isEmpty { + try visitor.visitSingularStringField(value: self.sdpMid, fieldNumber: 2) + } + if self.sdpMLineIndex != 0 { + try visitor.visitSingularInt32Field(value: self.sdpMLineIndex, fieldNumber: 3) + } + if !self.usernameFragment.isEmpty { + try visitor.visitSingularStringField(value: self.usernameFragment, fieldNumber: 4) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_AddIceCandidate, rhs: Fishjam_Sdk_AddIceCandidate) -> Bool { + if lhs.candidate != rhs.candidate {return false} + if lhs.sdpMid != rhs.sdpMid {return false} + if lhs.sdpMLineIndex != rhs.sdpMLineIndex {return false} + if lhs.usernameFragment != rhs.usernameFragment {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_AddIceCandidateResult: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".AddIceCandidateResult" + static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_AddIceCandidateResult, rhs: Fishjam_Sdk_AddIceCandidateResult) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_LocalIceCandidate: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".LocalIceCandidate" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}candidate\0\u{3}sdp_mid\0\u{3}sdp_m_line_index\0\u{3}username_fragment\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.candidate) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.sdpMid) }() + case 3: try { try decoder.decodeSingularInt32Field(value: &self.sdpMLineIndex) }() + case 4: try { try decoder.decodeSingularStringField(value: &self.usernameFragment) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.candidate.isEmpty { + try visitor.visitSingularStringField(value: self.candidate, fieldNumber: 1) + } + if !self.sdpMid.isEmpty { + try visitor.visitSingularStringField(value: self.sdpMid, fieldNumber: 2) + } + if self.sdpMLineIndex != 0 { + try visitor.visitSingularInt32Field(value: self.sdpMLineIndex, fieldNumber: 3) + } + if !self.usernameFragment.isEmpty { + try visitor.visitSingularStringField(value: self.usernameFragment, fieldNumber: 4) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_LocalIceCandidate, rhs: Fishjam_Sdk_LocalIceCandidate) -> Bool { + if lhs.candidate != rhs.candidate {return false} + if lhs.sdpMid != rhs.sdpMid {return false} + if lhs.sdpMLineIndex != rhs.sdpMLineIndex {return false} + if lhs.usernameFragment != rhs.usernameFragment {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_RemoteTrackReady: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".RemoteTrackReady" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}mid\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.mid) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.mid.isEmpty { + try visitor.visitSingularStringField(value: self.mid, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_RemoteTrackReady, rhs: Fishjam_Sdk_RemoteTrackReady) -> Bool { + if lhs.mid != rhs.mid {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_PeerConnectionStateChanged: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".PeerConnectionStateChanged" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}connection_state\0\u{3}ice_connection_state\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.connectionState) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.iceConnectionState) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.connectionState.isEmpty { + try visitor.visitSingularStringField(value: self.connectionState, fieldNumber: 1) + } + if !self.iceConnectionState.isEmpty { + try visitor.visitSingularStringField(value: self.iceConnectionState, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_PeerConnectionStateChanged, rhs: Fishjam_Sdk_PeerConnectionStateChanged) -> Bool { + if lhs.connectionState != rhs.connectionState {return false} + if lhs.iceConnectionState != rhs.iceConnectionState {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_ClosePeerConnection: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".ClosePeerConnection" + static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_ClosePeerConnection, rhs: Fishjam_Sdk_ClosePeerConnection) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} diff --git a/packages/ios-client/FishjamClient/MediaSession+Capture.swift b/packages/ios-client/FishjamClient/MediaSession+Capture.swift new file mode 100644 index 00000000..2c61daf9 --- /dev/null +++ b/packages/ios-client/FishjamClient/MediaSession+Capture.swift @@ -0,0 +1,57 @@ +import AVFoundation +import CoreMedia +import Foundation +import WebRTC + +extension MediaSession { + func enableAudio() { + #if targetEnvironment(simulator) + // The simulator voice-processing audio unit aborts inside AudioUnitInitialize. + #else + let session = RTCAudioSession.sharedInstance() + session.lockForConfiguration() + do { + try session.setCategory( + AVAudioSession.Category.playAndRecord, + mode: .voiceChat, + options: [.defaultToSpeaker, .allowBluetoothHFP] + ) + try session.setActive(true) + } catch { + NSLog("Fishjam audio session failed: %@", error.localizedDescription) + } + session.unlockForConfiguration() + session.isAudioEnabled = true + #endif + } + + func cameraDevice() -> AVCaptureDevice? { + let webrtcDevices = RTCCameraVideoCapturer.captureDevices() + if let device = webrtcDevices.first(where: { $0.position == .front }) ?? webrtcDevices.first { + return device + } + var types: [AVCaptureDevice.DeviceType] = [.builtInWideAngleCamera] + if #available(iOS 17.0, *) { + types.append(.external) + types.append(.continuityCamera) + } + let discovered = AVCaptureDevice.DiscoverySession( + deviceTypes: types, + mediaType: .video, + position: .unspecified + ).devices + return discovered.first { $0.position == .front } ?? discovered.first + } + + func cameraFormat(from formats: [AVCaptureDevice.Format]) -> (format: AVCaptureDevice.Format, fps: Int)? { + let match = formats.first { format in + let dimensions = CMVideoFormatDescriptionGetDimensions(format.formatDescription) + return dimensions.width == 1280 && dimensions.height == 720 + } + guard let chosen = match ?? formats.last else { + return nil + } + let maxFps = chosen.videoSupportedFrameRateRanges.map { Int($0.maxFrameRate) }.max() ?? 30 + return (chosen, min(30, maxFps)) + } +} diff --git a/packages/ios-client/FishjamClient/MediaSession+Negotiation.swift b/packages/ios-client/FishjamClient/MediaSession+Negotiation.swift new file mode 100644 index 00000000..0321f2de --- /dev/null +++ b/packages/ios-client/FishjamClient/MediaSession+Negotiation.swift @@ -0,0 +1,215 @@ +import Foundation +import WebRTC + +extension MediaSession { + func createOffer() { + guard let connection else { return } + let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil) + connection.offer(for: constraints) { description, error in + if let error { + NSLog("Fishjam createOffer failed: %@", error.localizedDescription) + return + } + guard let description else { return } + self.enqueue { + var result = Fishjam_Sdk_CreateOfferResult() + result.sdp = description.sdp + self.call(.createOfferResult(result)) + } + } + } + + func setLocal(_ description: Fishjam_Sdk_SetLocalDescription) { + guard let connection else { return } + let type: RTCSdpType = description.type == .answer ? .answer : .offer + let session = RTCSessionDescription(type: type, sdp: description.sdp) + connection.setLocalDescription(session) { error in + if let error { + NSLog("Fishjam setLocalDescription failed: %@", error.localizedDescription) + return + } + self.enqueue { self.reportLocalMids(connection) } + } + } + + private func reportLocalMids(_ connection: RTCPeerConnection) { + var mids: [String: String] = [:] + for transceiver in connection.transceivers { + let mid = transceiver.mid + guard !mid.isEmpty, let webrtcId = transceiver.sender.track?.trackId else { + continue + } + mediaLock.lock() + let trackId = fishjamIdByWebrtcId[webrtcId] + mediaLock.unlock() + if let trackId { + mids[mid] = trackId + } + } + var result = Fishjam_Sdk_SetLocalDescriptionResult() + result.midToTrackID = mids + call(.setLocalDescriptionResult(result)) + } + + func setRemote(_ description: Fishjam_Sdk_SetRemoteDescription) { + guard let connection else { return } + midToTrackId.merge(description.midToTrackID) { _, new in new } + for (mid, trackId) in description.midToTrackID { + if let track = pendingRemote.removeValue(forKey: mid) { + rememberRemote(trackId: trackId, track: track) + } + } + let type: RTCSdpType = description.type == .answer ? .answer : .offer + let session = RTCSessionDescription(type: type, sdp: description.sdp) + connection.setRemoteDescription(session) { error in + if let error { + NSLog("Fishjam setRemoteDescription failed: %@", error.localizedDescription) + return + } + self.enqueue { + self.call(.setRemoteDescriptionResult(Fishjam_Sdk_SetRemoteDescriptionResult())) + } + } + } + + func addRemoteCandidate(_ candidate: Fishjam_Sdk_AddIceCandidate) { + guard let connection else { return } + let ice = RTCIceCandidate( + sdp: candidate.candidate, + sdpMLineIndex: candidate.sdpMLineIndex, + sdpMid: candidate.sdpMid + ) + connection.add(ice) { error in + if let error { + NSLog("Fishjam addIceCandidate failed: %@", error.localizedDescription) + } + self.enqueue { + self.call(.addIceCandidateResult(Fishjam_Sdk_AddIceCandidateResult())) + } + } + } + + func closePeerConnection() { + forgetCallMediaOnQueue() + connection?.close() + connection = nil + } + + private func rememberRemote(trackId: String, track: RTCMediaStreamTrack) { + mediaLock.lock() + media[trackId] = track + mediaLock.unlock() + onMediaChanged() + if track is RTCAudioTrack { + enableAudio() + } + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) {} + + func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) {} + + func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) {} + + func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) {} + + func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) { + reportState(connection: peerConnection, ice: iceName(newState)) + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) {} + + func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) { + guard !candidate.sdp.isEmpty else { return } + enqueue { + var message = Fishjam_Sdk_LocalIceCandidate() + message.candidate = candidate.sdp + message.sdpMid = candidate.sdpMid ?? "" + message.sdpMLineIndex = candidate.sdpMLineIndex + self.call(.localIceCandidate(message)) + } + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) {} + + func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) {} + + func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCPeerConnectionState) { + reportState(connection: peerConnection, connectionState: connectionName(newState)) + } + + func peerConnection( + _ peerConnection: RTCPeerConnection, + didStartReceivingOn transceiver: RTCRtpTransceiver + ) { + let mid = transceiver.mid + guard !mid.isEmpty, let track = transceiver.receiver.track else { return } + enqueue { + if let trackId = self.midToTrackId[mid] { + self.rememberRemote(trackId: trackId, track: track) + } else { + self.pendingRemote[mid] = track + } + var ready = Fishjam_Sdk_RemoteTrackReady() + ready.mid = mid + self.call(.remoteTrackReady(ready)) + } + } + + private func reportState( + connection: RTCPeerConnection, + connectionState: String? = nil, + ice: String? = nil + ) { + let connectionValue = connectionState ?? connectionName(connection.connectionState) + let iceValue = ice ?? iceName(connection.iceConnectionState) + enqueue { + var message = Fishjam_Sdk_PeerConnectionStateChanged() + message.connectionState = connectionValue + message.iceConnectionState = iceValue + self.call(.peerConnectionStateChanged(message)) + } + } + + private func connectionName(_ state: RTCPeerConnectionState) -> String { + switch state { + case .new: + return "new" + case .connecting: + return "connecting" + case .connected: + return "connected" + case .disconnected: + return "disconnected" + case .failed: + return "failed" + case .closed: + return "closed" + @unknown default: + return "" + } + } + + private func iceName(_ state: RTCIceConnectionState) -> String { + switch state { + case .new: + return "new" + case .checking: + return "checking" + case .connected: + return "connected" + case .completed: + return "completed" + case .failed: + return "failed" + case .disconnected: + return "disconnected" + case .closed: + return "closed" + case .count: + return "" + @unknown default: + return "" + } + } +} diff --git a/packages/ios-client/FishjamClient/MediaSession.swift b/packages/ios-client/FishjamClient/MediaSession.swift new file mode 100644 index 00000000..fafb0044 --- /dev/null +++ b/packages/ios-client/FishjamClient/MediaSession.swift @@ -0,0 +1,269 @@ +import AVFoundation +import Foundation +import WebRTC + +enum MediaError: LocalizedError { + case cameraPermission + case microphonePermission + case noCamera + + var errorDescription: String? { + switch self { + case .cameraPermission: + return "CAMERA permission is required" + case .microphonePermission: + return "RECORD_AUDIO permission is required" + case .noCamera: + return "No camera found" + } + } +} + +final class MediaSession: NSObject, RTCPeerConnectionDelegate { + private let queue = DispatchQueue(label: "fishjam-pc") + let mediaLock = NSLock() + let call: (Fishjam_Sdk_SdkRequest.OneOf_Content) -> Void + let onMediaChanged: () -> Void + + var media: [String: RTCMediaStreamTrack] = [:] + var fishjamIdByWebrtcId: [String: String] = [:] + var midToTrackId: [String: String] = [:] + var pendingRemote: [String: RTCMediaStreamTrack] = [:] + private var factory: RTCPeerConnectionFactory? + var connection: RTCPeerConnection? + private var capturer: RTCCameraVideoCapturer? + private var localVideo: RTCVideoTrack? + private var localAudio: RTCAudioTrack? + private var stopped = false + + init( + call: @escaping (Fishjam_Sdk_SdkRequest.OneOf_Content) -> Void, + onMediaChanged: @escaping () -> Void + ) { + self.call = call + self.onMediaChanged = onMediaChanged + super.init() + } + + func mediaSnapshot() -> [String: RTCMediaStreamTrack] { + mediaLock.lock() + defer { mediaLock.unlock() } + return media + } + + func handle(_ event: Fishjam_Sdk_SdkEvent) { + enqueue { self.handleOnQueue(event) } + } + + func startCamera() async throws -> VideoTrack { + guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized else { + throw MediaError.cameraPermission + } + return try await withCheckedThrowingContinuation { continuation in + self.enqueue { + do { + continuation.resume(returning: try self.startCameraOnQueue()) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + func startMicrophone() async throws -> AudioTrack { + guard AVAudioSession.sharedInstance().recordPermission == .granted else { + throw MediaError.microphonePermission + } + return try await withCheckedThrowingContinuation { continuation in + self.enqueue { + do { + continuation.resume(returning: try self.startMicrophoneOnQueue()) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + func bind(trackId: String, track: RTCMediaStreamTrack) { + mediaLock.lock() + media[trackId] = track + fishjamIdByWebrtcId[track.trackId] = trackId + mediaLock.unlock() + onMediaChanged() + } + + func close() { + queue.sync { + stopped = true + capturer?.stopCapture() + connection?.close() + connection = nil + localVideo = nil + localAudio = nil + } + } + + func forgetCallMediaOnQueue() { + mediaLock.lock() + media.removeAll() + fishjamIdByWebrtcId.removeAll() + mediaLock.unlock() + midToTrackId.removeAll() + pendingRemote.removeAll() + onMediaChanged() + } + + func enqueue(_ work: @escaping () -> Void) { + queue.async { + guard !self.stopped else { return } + work() + } + } + + private func handleOnQueue(_ event: Fishjam_Sdk_SdkEvent) { + switch event.content { + case .createPeerConnection(let request): + openPeerConnection(request) + case .updateRecvonlyTransceivers(let update): + updateRecvonly(update) + case .addSendonlyTransceiver(let request): + addSendonly(request) + case .removeSendTrack(let request): + removeSend(request.trackID) + case .createOffer: + createOffer() + case .setLocalDescription(let description): + setLocal(description) + case .setRemoteDescription(let description): + setRemote(description) + case .addIceCandidate(let candidate): + addRemoteCandidate(candidate) + case .closePeerConnection: + closePeerConnection() + default: + return + } + } + + private func ensureFactory() -> RTCPeerConnectionFactory { + if let factory { + return factory + } + let audioSession = RTCAudioSession.sharedInstance() + audioSession.useManualAudio = true + audioSession.isAudioEnabled = false + RTCPeerConnectionFactory.initialize() + let encoder = RTCDefaultVideoEncoderFactory() + let decoder = RTCDefaultVideoDecoderFactory() + let created = RTCPeerConnectionFactory(encoderFactory: encoder, decoderFactory: decoder) + factory = created + return created + } + + private func startCameraOnQueue() throws -> VideoTrack { + if let localVideo { + return localVideo + } + guard let device = cameraDevice() else { + throw MediaError.noCamera + } + let formats = RTCCameraVideoCapturer.supportedFormats(for: device) + guard let format = cameraFormat(from: formats) else { + throw MediaError.noCamera + } + let source = ensureFactory().videoSource() + let created = RTCCameraVideoCapturer(delegate: source) + let track = ensureFactory().videoTrack(with: source, trackId: UUID().uuidString) + track.isEnabled = true + created.startCapture(with: device, format: format.format, fps: format.fps) + capturer = created + localVideo = track + return track + } + + private func startMicrophoneOnQueue() throws -> AudioTrack { + if let localAudio { + return localAudio + } + let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil) + let source = ensureFactory().audioSource(with: constraints) + let track = ensureFactory().audioTrack(with: source, trackId: UUID().uuidString) + track.isEnabled = true + localAudio = track + enableAudio() + return track + } + + private func openPeerConnection(_ request: Fishjam_Sdk_CreatePeerConnection) { + guard connection == nil else { return } + let iceServers = request.iceServers.map { server in + RTCIceServer(urlStrings: server.urls, username: server.username, credential: server.credential) + } + let config = RTCConfiguration() + config.iceServers = iceServers + config.bundlePolicy = .maxBundle + config.iceTransportPolicy = .all + config.sdpSemantics = .unifiedPlan + let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil) + connection = ensureFactory().peerConnection(with: config, constraints: constraints, delegate: self) + } + + private func updateRecvonly(_ update: Fishjam_Sdk_UpdateRecvonlyTransceivers) { + guard let connection else { return } + for mid in update.midsToStop { + connection.transceivers.first { $0.mid == mid }?.stopInternal() + } + for _ in 0.. RTCRtpEncodingParameters { + let parameters = RTCRtpEncodingParameters() + parameters.rid = encoding.rid + parameters.isActive = encoding.active + if encoding.scaleResolutionDownBy > 0 { + parameters.scaleResolutionDownBy = NSNumber(value: encoding.scaleResolutionDownBy) + } + if encoding.maxBitrate > 0 { + parameters.maxBitrateBps = NSNumber(value: encoding.maxBitrate) + } + return parameters + } + + private func removeSend(_ trackId: String) { + guard let connection else { return } + mediaLock.lock() + let track = media.removeValue(forKey: trackId) + if let track { + fishjamIdByWebrtcId.removeValue(forKey: track.trackId) + } + mediaLock.unlock() + guard let track else { return } + connection.transceivers.first { $0.sender.track?.trackId == track.trackId }?.stopInternal() + onMediaChanged() + } +} diff --git a/packages/ios-client/FishjamClient/PeerStatusMapping.swift b/packages/ios-client/FishjamClient/PeerStatusMapping.swift new file mode 100644 index 00000000..8a527722 --- /dev/null +++ b/packages/ios-client/FishjamClient/PeerStatusMapping.swift @@ -0,0 +1,59 @@ +import Foundation + +public enum PeerStatus: String { + case idle + case connecting + case connected + case error +} + +public struct JoinRoomError: LocalizedError { + public let message: String + public let authReason: String + public let joinReason: String + + public var errorDescription: String? { + message + } +} + +struct PeerStatusUpdate { + let peerStatus: PeerStatus + let message: String + let authReason: String + let joinReason: String +} + +func peerStatusUpdate(_ event: Fishjam_Sdk_SdkEvent) -> PeerStatusUpdate? { + guard case let .peerStatusChanged(changed)? = event.content else { + return nil + } + let error = changed.hasError ? changed.error : nil + let status: PeerStatus + switch changed.peerStatus { + case .connecting: + status = .connecting + case .connected: + status = .connected + case .error: + status = .error + case .idle, .unspecified, .UNRECOGNIZED: + status = .idle + } + return PeerStatusUpdate( + peerStatus: status, + message: error?.message ?? "", + authReason: error?.authReason ?? "", + joinReason: error?.joinReason ?? "" + ) +} + +func joinFailure(_ update: PeerStatusUpdate) -> JoinRoomError? { + guard update.peerStatus == .error else { + return nil + } + let reason = update.authReason.isEmpty + ? (update.joinReason.isEmpty ? update.message : update.joinReason) + : update.authReason + return JoinRoomError(message: reason, authReason: update.authReason, joinReason: update.joinReason) +} diff --git a/packages/ios-client/FishjamClient/RoomState.swift b/packages/ios-client/FishjamClient/RoomState.swift new file mode 100644 index 00000000..87e9cc2a --- /dev/null +++ b/packages/ios-client/FishjamClient/RoomState.swift @@ -0,0 +1,150 @@ +import Foundation +import WebRTC + +public typealias VideoTrack = RTCVideoTrack +public typealias AudioTrack = RTCAudioTrack + +public struct TrackMetadata { + public var type: String + public var paused: Bool + public var displayName: String? +} + +public struct Track { + public var trackId: String + public var metadata: TrackMetadata? + public var videoTrack: VideoTrack? + public var audioTrack: AudioTrack? +} + +public struct PeerWithTracks { + public var id: String + public var metadata: [String: Any]? + public var tracks: [Track] + public var cameraTrack: Track? + public var microphoneTrack: Track? + public var screenShareVideoTrack: Track? + public var screenShareAudioTrack: Track? + public var customVideoTracks: [Track] + public var customAudioTracks: [Track] + + static let empty = PeerWithTracks( + id: "", + metadata: nil, + tracks: [], + cameraTrack: nil, + microphoneTrack: nil, + screenShareVideoTrack: nil, + screenShareAudioTrack: nil, + customVideoTracks: [], + customAudioTracks: [] + ) +} + +public struct RoomState { + public var localPeer: PeerWithTracks? + public var remotePeers: [PeerWithTracks] + + public static let empty = RoomState(localPeer: nil, remotePeers: []) +} + +func makeRoomState(_ proto: Fishjam_Sdk_RoomState) -> RoomState { + RoomState( + localPeer: proto.hasLocalPeer ? peer(of: proto.localPeer) : nil, + remotePeers: proto.remotePeers.map(peer(of:)) + ) +} + +extension RoomState { + func attaching(_ media: [String: RTCMediaStreamTrack]) -> RoomState { + RoomState( + localPeer: localPeer?.attaching(media), + remotePeers: remotePeers.map { peer in peer.attaching(media) } + ) + } +} + +private extension PeerWithTracks { + func attaching(_ media: [String: RTCMediaStreamTrack]) -> PeerWithTracks { + var copy = self + copy.tracks = tracks.map { track in track.attaching(media) } + copy.cameraTrack = cameraTrack?.attaching(media) + copy.microphoneTrack = microphoneTrack?.attaching(media) + copy.screenShareVideoTrack = screenShareVideoTrack?.attaching(media) + copy.screenShareAudioTrack = screenShareAudioTrack?.attaching(media) + copy.customVideoTracks = customVideoTracks.map { track in track.attaching(media) } + copy.customAudioTracks = customAudioTracks.map { track in track.attaching(media) } + return copy + } +} + +private extension Track { + func attaching(_ media: [String: RTCMediaStreamTrack]) -> Track { + guard let stream = media[trackId] else { + return self + } + var copy = self + if let video = stream as? VideoTrack { + copy.videoTrack = video + copy.audioTrack = nil + } else if let audio = stream as? AudioTrack { + copy.videoTrack = nil + copy.audioTrack = audio + } + return copy + } +} + +private func peer(of proto: Fishjam_Sdk_PeerState) -> PeerWithTracks { + PeerWithTracks( + id: proto.id, + metadata: metadataObject(proto.metadataJson), + tracks: proto.tracks.map(track(of:)), + cameraTrack: proto.hasCameraTrack ? track(of: proto.cameraTrack) : nil, + microphoneTrack: proto.hasMicrophoneTrack ? track(of: proto.microphoneTrack) : nil, + screenShareVideoTrack: proto.hasScreenShareVideoTrack ? track(of: proto.screenShareVideoTrack) : nil, + screenShareAudioTrack: proto.hasScreenShareAudioTrack ? track(of: proto.screenShareAudioTrack) : nil, + customVideoTracks: proto.customVideoTracks.map(track(of:)), + customAudioTracks: proto.customAudioTracks.map(track(of:)) + ) +} + +private func track(of proto: Fishjam_Sdk_TrackState) -> Track { + Track( + trackId: proto.trackID, + metadata: proto.hasMetadata ? metadata(of: proto.metadata) : nil, + videoTrack: nil, + audioTrack: nil + ) +} + +private func metadata(of proto: Fishjam_Sdk_TrackMetadata) -> TrackMetadata { + let displayName = proto.hasDisplayName && !proto.displayName.isEmpty ? proto.displayName : nil + return TrackMetadata(type: trackTypeName(proto.type), paused: proto.paused, displayName: displayName) +} + +private func metadataObject(_ json: String) -> [String: Any]? { + guard !json.isEmpty, let data = json.data(using: .utf8) else { + return nil + } + return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] +} + +private func trackTypeName(_ type: Fishjam_Sdk_TrackType) -> String { + switch type { + case .camera: + return "camera" + case .microphone: + return "microphone" + case .screenShareVideo: + return "screenShareVideo" + case .screenShareAudio: + return "screenShareAudio" + case .customVideo: + return "customVideo" + case .customAudio: + return "customAudio" + case .unspecified, .UNRECOGNIZED: + return "" + } +} diff --git a/packages/ios-client/FishjamClient/VideoView.swift b/packages/ios-client/FishjamClient/VideoView.swift new file mode 100644 index 00000000..30c8e61a --- /dev/null +++ b/packages/ios-client/FishjamClient/VideoView.swift @@ -0,0 +1,64 @@ +import SwiftUI +import WebRTC + +public enum ObjectFit { + case contain + case cover +} + +public struct VideoView: View { + private let track: VideoTrack + private let mirror: Bool + private let objectFit: ObjectFit + + public init(track: VideoTrack, mirror: Bool = false, objectFit: ObjectFit = .cover) { + self.track = track + self.mirror = mirror + self.objectFit = objectFit + } + + public var body: some View { + VideoRenderer(track: track, mirror: mirror, objectFit: objectFit) + } +} + +private struct VideoRenderer: UIViewRepresentable { + let track: VideoTrack + let mirror: Bool + let objectFit: ObjectFit + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context: Context) -> RTCMTLVideoView { + let view = RTCMTLVideoView(frame: .zero) + view.clipsToBounds = true + apply(to: view) + track.add(view) + context.coordinator.track = track + return view + } + + func updateUIView(_ view: RTCMTLVideoView, context: Context) { + apply(to: view) + guard context.coordinator.track !== track else { return } + context.coordinator.track?.remove(view) + track.add(view) + context.coordinator.track = track + } + + static func dismantleUIView(_ view: RTCMTLVideoView, coordinator: Coordinator) { + coordinator.track?.remove(view) + coordinator.track = nil + } + + private func apply(to view: RTCMTLVideoView) { + view.videoContentMode = objectFit == .contain ? .scaleAspectFit : .scaleAspectFill + view.transform = mirror ? CGAffineTransform(scaleX: -1, y: 1) : .identity + } + + final class Coordinator { + var track: VideoTrack? + } +} diff --git a/packages/ios-client/FishjamClientTests/PeerStatusMappingTests.swift b/packages/ios-client/FishjamClientTests/PeerStatusMappingTests.swift new file mode 100644 index 00000000..9b959e1a --- /dev/null +++ b/packages/ios-client/FishjamClientTests/PeerStatusMappingTests.swift @@ -0,0 +1,55 @@ +@testable import FishjamClient +import XCTest + +final class PeerStatusMappingTests: XCTestCase { + func testMapsConnectingConnectedAndIdle() { + XCTAssertEqual(status(of: .connecting).peerStatus, .connecting) + XCTAssertEqual(status(of: .connected).peerStatus, .connected) + XCTAssertEqual(status(of: .idle).peerStatus, .idle) + } + + func testMapsAuthErrorReason() { + var error = Fishjam_Sdk_Error() + error.message = "invalid token" + error.authReason = "invalid token" + let update = status(of: .error, error: error) + + XCTAssertEqual(update.peerStatus, .error) + XCTAssertEqual(update.authReason, "invalid token") + XCTAssertEqual(update.joinReason, "") + XCTAssertEqual(joinFailure(update)?.message, "invalid token") + } + + func testMapsJoinErrorReason() { + var error = Fishjam_Sdk_Error() + error.message = "reached peers limit" + error.joinReason = "reached peers limit" + let update = status(of: .error, error: error) + + XCTAssertEqual(update.joinReason, "reached peers limit") + XCTAssertEqual(update.authReason, "") + XCTAssertEqual(joinFailure(update)?.message, "reached peers limit") + } + + func testIgnoresEventsThatAreNotPeerStatus() { + var event = Fishjam_Sdk_SdkEvent() + event.closePeerConnection = Fishjam_Sdk_ClosePeerConnection() + + XCTAssertNil(peerStatusUpdate(event)) + XCTAssertNil(joinFailure(status(of: .connected))) + } + + private func status( + of peerStatus: Fishjam_Sdk_PeerStatus, + error: Fishjam_Sdk_Error? = nil + ) -> PeerStatusUpdate { + var changed = Fishjam_Sdk_PeerStatusChanged() + changed.peerStatus = peerStatus + if let error { + changed.error = error + } + var event = Fishjam_Sdk_SdkEvent() + event.peerStatusChanged = changed + return peerStatusUpdate(event)! + } +} diff --git a/packages/ios-client/FishjamClientTests/RoomStateMappingTests.swift b/packages/ios-client/FishjamClientTests/RoomStateMappingTests.swift new file mode 100644 index 00000000..de80679e --- /dev/null +++ b/packages/ios-client/FishjamClientTests/RoomStateMappingTests.swift @@ -0,0 +1,42 @@ +@testable import FishjamClient +import XCTest + +final class RoomStateMappingTests: XCTestCase { + func testMapsLocalMicrophoneAndRemoteCamera() { + var local = Fishjam_Sdk_PeerState() + local.id = "local" + local.metadataJson = #"{"peer":{"name":"me"}}"# + local.microphoneTrack = track("local:mic", .microphone) + + var remote = Fishjam_Sdk_PeerState() + remote.id = "remote" + remote.cameraTrack = track("remote:cam", .camera) + + var proto = Fishjam_Sdk_RoomState() + proto.localPeer = local + proto.remotePeers = [remote] + + let room = makeRoomState(proto) + + XCTAssertEqual(room.localPeer?.id, "local") + let peer = room.localPeer?.metadata?["peer"] as? [String: Any] + XCTAssertEqual(peer?["name"] as? String, "me") + XCTAssertEqual(room.localPeer?.microphoneTrack?.metadata?.type, "microphone") + XCTAssertEqual(room.localPeer?.microphoneTrack?.metadata?.paused, false) + XCTAssertNil(room.localPeer?.cameraTrack) + XCTAssertEqual(room.remotePeers.count, 1) + XCTAssertEqual(room.remotePeers.first?.id, "remote") + XCTAssertEqual(room.remotePeers.first?.cameraTrack?.metadata?.type, "camera") + XCTAssertNil(room.remotePeers.first?.metadata) + } + + private func track(_ id: String, _ type: Fishjam_Sdk_TrackType) -> Fishjam_Sdk_TrackState { + var metadata = Fishjam_Sdk_TrackMetadata() + metadata.type = type + metadata.paused = false + var state = Fishjam_Sdk_TrackState() + state.trackID = id + state.metadata = metadata + return state + } +} diff --git a/packages/ios-client/Podfile b/packages/ios-client/Podfile new file mode 100644 index 00000000..fff21f5d --- /dev/null +++ b/packages/ios-client/Podfile @@ -0,0 +1,20 @@ +# scripts/fetch-webrtc.sh downloads this pod. The Xcode project links Vendor/WebRTC.xcframework. +platform :ios, '15.0' + +target 'FishjamClient' do + use_frameworks! + pod 'FishjamWebRTC', '~> 124.0.2.3' + + target 'FishjamClientTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' + config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO' + end + end +end diff --git a/packages/ios-client/Podfile.lock b/packages/ios-client/Podfile.lock new file mode 100644 index 00000000..a5742f86 --- /dev/null +++ b/packages/ios-client/Podfile.lock @@ -0,0 +1,16 @@ +PODS: + - FishjamWebRTC (124.0.2.4) + +DEPENDENCIES: + - FishjamWebRTC (~> 124.0.2.3) + +SPEC REPOS: + trunk: + - FishjamWebRTC + +SPEC CHECKSUMS: + FishjamWebRTC: 1c16e9c036020b1623af40800f271b6fcd320b17 + +PODFILE CHECKSUM: 34b3eab6497baeed4d81ee8bb6db7231e080da0e + +COCOAPODS: 1.16.2 diff --git a/packages/ios-client/package.json b/packages/ios-client/package.json new file mode 100644 index 00000000..45196e1b --- /dev/null +++ b/packages/ios-client/package.json @@ -0,0 +1,16 @@ +{ + "name": "@fishjam-cloud/ios-client", + "version": "0.31.0", + "private": true, + "license": "Apache-2.0", + "description": "Fishjam client SDK for native iOS", + "scripts": { + "prepare:project": "./scripts/build-core.sh && ./scripts/generate-proto.sh && bash ./scripts/fetch-webrtc.sh && xcodegen generate --quiet", + "build": "yarn prepare:project && xcodebuild build -project FishjamClient.xcodeproj -scheme FishjamClient -destination 'generic/platform=iOS Simulator' -derivedDataPath build/DerivedData -quiet", + "test": "yarn prepare:project && xcodebuild test -project FishjamClient.xcodeproj -scheme FishjamClient -destination \"platform=iOS Simulator,name=${IOS_SIMULATOR:-iPhone 17}\" -derivedDataPath build/DerivedData -collect-test-diagnostics never -quiet", + "format": "swiftlint --fix --quiet && swiftlint --quiet", + "format:check": "swiftlint --strict --quiet", + "lint": "swiftlint --strict --quiet", + "lint:check": "swiftlint --strict --quiet" + } +} diff --git a/packages/ios-client/project.yml b/packages/ios-client/project.yml new file mode 100644 index 00000000..45fb25ae --- /dev/null +++ b/packages/ios-client/project.yml @@ -0,0 +1,59 @@ +name: FishjamClient +options: + bundleIdPrefix: io.fishjam + deploymentTarget: + iOS: "15.0" + createIntermediateGroups: true +settings: + SWIFT_VERSION: "5.0" + SWIFT_STRICT_CONCURRENCY: minimal + IPHONEOS_DEPLOYMENT_TARGET: "15.0" + CODE_SIGNING_ALLOWED: NO + "CODE_SIGNING_ALLOWED[sdk=iphoneos*]": YES + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: 37994Y34H9 + EXCLUDED_ARCHS[sdk=iphonesimulator*]: x86_64 +packages: + SwiftProtobuf: + url: https://github.com/apple/swift-protobuf.git + exactVersion: 1.39.0 +targets: + FishjamClient: + type: framework + platform: iOS + sources: + - FishjamClient + dependencies: + - package: SwiftProtobuf + product: SwiftProtobuf + - framework: Core/FishjamCore.xcframework + embed: false + - framework: Vendor/WebRTC.xcframework + embed: false + settings: + DEFINES_MODULE: YES + GENERATE_INFOPLIST_FILE: YES + PRODUCT_BUNDLE_IDENTIFIER: io.fishjam.FishjamClient + ENABLE_TESTABILITY: YES + FishjamClientTests: + type: bundle.unit-test + platform: iOS + sources: + - FishjamClientTests + dependencies: + - target: FishjamClient + - framework: Vendor/WebRTC.xcframework + embed: true + settings: + GENERATE_INFOPLIST_FILE: YES + PRODUCT_BUNDLE_IDENTIFIER: io.fishjam.FishjamClientTests + IPHONEOS_DEPLOYMENT_TARGET: "17.0" +schemes: + FishjamClient: + build: + targets: + FishjamClient: all + FishjamClientTests: [test] + test: + targets: + - FishjamClientTests diff --git a/packages/ios-client/scripts/build-core.sh b/packages/ios-client/scripts/build-core.sh new file mode 100755 index 00000000..b2e03fe5 --- /dev/null +++ b/packages/ios-client/scripts/build-core.sh @@ -0,0 +1,33 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CORE="$(cd "$ROOT/../fishjam-core" && pwd)" +BUILD="$ROOT/build/core" +HEADERS="$BUILD/headers" +OUT="$ROOT/Core/FishjamCore.xcframework" +TARGET_ROOT="$CORE/target" + +rm -rf "$OUT" "$HEADERS" +mkdir -p "$HEADERS" "$ROOT/Core" + +cp "$CORE/include/fishjam.h" "$HEADERS/fishjam.h" +cat > "$HEADERS/module.modulemap" <<'EOF' +module FishjamCore { + header "fishjam.h" + export * +} +EOF + +export IPHONEOS_DEPLOYMENT_TARGET=15.0 +export CARGO_TARGET_DIR="$TARGET_ROOT" + +cargo build --manifest-path "$CORE/Cargo.toml" --release --target aarch64-apple-ios +cargo build --manifest-path "$CORE/Cargo.toml" --release --target aarch64-apple-ios-sim + +xcodebuild -create-xcframework \ + -library "$TARGET_ROOT/aarch64-apple-ios/release/libfishjam_core.a" -headers "$HEADERS" \ + -library "$TARGET_ROOT/aarch64-apple-ios-sim/release/libfishjam_core.a" -headers "$HEADERS" \ + -output "$OUT" + +echo "wrote $OUT" diff --git a/packages/ios-client/scripts/fetch-webrtc.sh b/packages/ios-client/scripts/fetch-webrtc.sh new file mode 100644 index 00000000..f87134c3 --- /dev/null +++ b/packages/ios-client/scripts/fetch-webrtc.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DEST="$ROOT/Vendor/WebRTC.xcframework" +SLICE="$DEST/ios-arm64_x86_64-simulator/WebRTC.framework" +if [[ -d "$SLICE" ]]; then + exit 0 +fi + +mkdir -p "$ROOT/Vendor" +CACHED="$ROOT/Pods/FishjamWebRTC/WebRTC.xcframework" +if [[ -d "$CACHED/ios-arm64_x86_64-simulator/WebRTC.framework" ]]; then + rm -rf "$DEST" + cp -R "$CACHED" "$DEST" + exit 0 +fi + +VERSION="124.0.2.4" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +curl -fsSL \ + "https://github.com/fishjam-cloud/webrtc/releases/download/v${VERSION}/FishjamWebRTC.xcframework.zip" \ + -o "$TMP/webrtc.zip" +unzip -q "$TMP/webrtc.zip" -d "$TMP/out" +FOUND="$(find "$TMP/out" -name WebRTC.xcframework -type d | head -1)" +rm -rf "$DEST" +mv "$FOUND" "$DEST" diff --git a/packages/ios-client/scripts/generate-proto.sh b/packages/ios-client/scripts/generate-proto.sh new file mode 100755 index 00000000..ff02c584 --- /dev/null +++ b/packages/ios-client/scripts/generate-proto.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PROTO_DIR="$(cd "$ROOT/../fishjam-core/proto" && pwd)" +OUT="$ROOT/FishjamClient/Generated" + +mkdir -p "$OUT" +protoc --swift_out="$OUT" --swift_opt=Visibility=Internal -I "$PROTO_DIR" "$PROTO_DIR/sdk_api.proto" diff --git a/yarn.lock b/yarn.lock index fc099bd4..9811c971 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5388,6 +5388,24 @@ __metadata: languageName: node linkType: hard +"@fishjam-cloud/android-client@workspace:packages/android-client": + version: 0.0.0-use.local + resolution: "@fishjam-cloud/android-client@workspace:packages/android-client" + languageName: unknown + linkType: soft + +"@fishjam-cloud/fishjam-core@workspace:packages/fishjam-core": + version: 0.0.0-use.local + resolution: "@fishjam-cloud/fishjam-core@workspace:packages/fishjam-core" + languageName: unknown + linkType: soft + +"@fishjam-cloud/ios-client@workspace:packages/ios-client": + version: 0.0.0-use.local + resolution: "@fishjam-cloud/ios-client@workspace:packages/ios-client" + languageName: unknown + linkType: soft + "@fishjam-cloud/ios-expo-voip@workspace:packages/ios-expo-voip": version: 0.0.0-use.local resolution: "@fishjam-cloud/ios-expo-voip@workspace:packages/ios-expo-voip" @@ -5724,6 +5742,18 @@ __metadata: languageName: unknown linkType: soft +"@fishjam-example/android-client@workspace:examples/android-client": + version: 0.0.0-use.local + resolution: "@fishjam-example/android-client@workspace:examples/android-client" + languageName: unknown + linkType: soft + +"@fishjam-example/ios-client@workspace:examples/ios-client": + version: 0.0.0-use.local + resolution: "@fishjam-example/ios-client@workspace:examples/ios-client" + languageName: unknown + linkType: soft + "@fishjam-example/minimal-react-example@workspace:examples/react-client/minimal-react": version: 0.0.0-use.local resolution: "@fishjam-example/minimal-react-example@workspace:examples/react-client/minimal-react"