From 210b31a7e3553152dfb926e7b6150238ee238c74 Mon Sep 17 00:00:00 2001 From: Angelika Serwa Date: Fri, 16 Oct 2026 01:45:17 +0200 Subject: [PATCH 01/11] Add fishjam-core package with C ABI and dummy add --- package.json | 1 + packages/fishjam-core/.gitignore | 1 + packages/fishjam-core/Cargo.lock | 404 ++++++++++++++++++++++ packages/fishjam-core/Cargo.toml | 21 ++ packages/fishjam-core/build.rs | 10 + packages/fishjam-core/include/fishjam.h | 33 ++ packages/fishjam-core/package.json | 15 + packages/fishjam-core/proto/sdk_api.proto | 267 ++++++++++++++ packages/fishjam-core/src/abi.rs | 130 +++++++ packages/fishjam-core/src/lib.rs | 24 ++ packages/fishjam-core/tests/abi.rs | 37 ++ yarn.lock | 6 + 12 files changed, 949 insertions(+) create mode 100644 packages/fishjam-core/.gitignore create mode 100644 packages/fishjam-core/Cargo.lock create mode 100644 packages/fishjam-core/Cargo.toml create mode 100644 packages/fishjam-core/build.rs create mode 100644 packages/fishjam-core/include/fishjam.h create mode 100644 packages/fishjam-core/package.json create mode 100644 packages/fishjam-core/proto/sdk_api.proto create mode 100644 packages/fishjam-core/src/abi.rs create mode 100644 packages/fishjam-core/src/lib.rs create mode 100644 packages/fishjam-core/tests/abi.rs diff --git a/package.json b/package.json index 7786a1d3..8da2547a 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "packages/react-native-vision-camera-source", "packages/ios-expo-voip", "packages/react-native-webrtc", + "packages/fishjam-core", "examples/react-client/*", "examples/mobile-client/*", "e2e-tests/webrtc-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..1e7b230d --- /dev/null +++ b/packages/fishjam-core/Cargo.lock @@ -0,0 +1,404 @@ +# 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 = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[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", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fishjam-core" +version = "0.31.0" +dependencies = [ + "prost", + "prost-build", + "protoc-bin-vendored", +] + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[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", +] + +[[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 = "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 = "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 = "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 = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[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", + "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", +] + +[[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 = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[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 = "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", +] + +[[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 = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/packages/fishjam-core/Cargo.toml b/packages/fishjam-core/Cargo.toml new file mode 100644 index 00000000..11ef8b5e --- /dev/null +++ b/packages/fishjam-core/Cargo.toml @@ -0,0 +1,21 @@ +[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] +prost = "0.13" + +[dev-dependencies] +prost = "0.13" + +[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..9d59c510 --- /dev/null +++ b/packages/fishjam-core/build.rs @@ -0,0 +1,10 @@ +fn main() { + let protoc = protoc_bin_vendored::protoc_bin_path().expect("vendored protoc"); + std::env::set_var("PROTOC", protoc); + + prost_build::Config::new() + .boxed(".fishjam.sdk.SdkResponse.content.room_state") + .boxed(".fishjam.sdk.SdkEvent.content.room_state_changed") + .compile_protos(&["proto/sdk_api.proto"], &["proto/"]) + .expect("compile sdk_api.proto"); +} 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..2840826a --- /dev/null +++ b/packages/fishjam-core/proto/sdk_api.proto @@ -0,0 +1,267 @@ +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 { + oneof content { + AddRequest add = 1; + 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 { + oneof content { + Error error = 1; + AddResponse add = 2; + 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 AddRequest { + int32 a = 1; + int32 b = 2; +} + +message AddResponse { + int32 sum = 1; +} + +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..9b06a990 --- /dev/null +++ b/packages/fishjam-core/src/abi.rs @@ -0,0 +1,130 @@ +use std::os::raw::c_void; + +use prost::Message; + +use crate::add; +use crate::sdk::{ + sdk_request, sdk_response, AddRequest, AddResponse, ClientConfig, Error, 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, +} + +#[allow(dead_code)] +pub struct FishjamClient { + config: ClientConfig, + on_event: FishjamOnEvent, + user_data: *mut c_void, +} + +/// # 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(), + } + }; + + Box::into_raw(Box::new(FishjamClient { + config: parsed, + on_event, + user_data, + })) +} + +/// # 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; on success 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 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), + } + }; + + match request.content { + Some(sdk_request::Content::Add(AddRequest { a, b })) => { + let response = SdkResponse { + content: Some(sdk_response::Content::Add(AddResponse { sum: add(a, b) })), + }; + write_response(out, response, OK) + } + Some(_) | None => write_response(out, error_response("unsupported request"), 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 error_response(message: &str) -> SdkResponse { + SdkResponse { + content: Some(sdk_response::Content::Error(Error { + message: message.to_owned(), + auth_reason: String::new(), + join_reason: String::new(), + })), + } +} + +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/lib.rs b/packages/fishjam-core/src/lib.rs new file mode 100644 index 00000000..ee9dd7c2 --- /dev/null +++ b/packages/fishjam-core/src/lib.rs @@ -0,0 +1,24 @@ +pub mod sdk { + include!(concat!(env!("OUT_DIR"), "/fishjam.sdk.rs")); +} + +mod abi; + +pub use abi::{ + fishjam_buf_free, fishjam_client_call, fishjam_client_free, fishjam_client_new, FishjamBuf, +}; + +/// Adds two numbers. Temporary stand-in used to prove the C ABI before joinRoom exists. +pub fn add(a: i32, b: i32) -> i32 { + a.wrapping_add(b) +} + +#[cfg(test)] +mod tests { + use super::add; + + #[test] + fn add_sums_two_numbers() { + assert_eq!(add(2, 3), 5); + } +} diff --git a/packages/fishjam-core/tests/abi.rs b/packages/fishjam-core/tests/abi.rs new file mode 100644 index 00000000..fba0e38a --- /dev/null +++ b/packages/fishjam-core/tests/abi.rs @@ -0,0 +1,37 @@ +use fishjam_core::sdk::{sdk_request, sdk_response, AddRequest, SdkRequest, SdkResponse}; +use fishjam_core::{ + fishjam_buf_free, fishjam_client_call, fishjam_client_free, fishjam_client_new, FishjamBuf, +}; +use prost::Message; + +#[test] +fn c_abi_add_sums_two_numbers() { + let client = unsafe { fishjam_client_new(std::ptr::null(), 0, None, std::ptr::null_mut()) }; + assert!(!client.is_null()); + + let request = SdkRequest { + content: Some(sdk_request::Content::Add(AddRequest { a: 2, b: 3 })), + }; + let bytes = 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) }; + assert_eq!(code, 0); + + let response = unsafe { + let slice = std::slice::from_raw_parts(out.data, out.len); + SdkResponse::decode(slice).expect("response") + }; + match response.content { + Some(sdk_response::Content::Add(add)) => assert_eq!(add.sum, 5), + other => panic!("expected add response, got {other:?}"), + } + + unsafe { + fishjam_buf_free(out); + fishjam_client_free(client); + } +} diff --git a/yarn.lock b/yarn.lock index fc099bd4..65ff120e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5388,6 +5388,12 @@ __metadata: languageName: node linkType: hard +"@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-expo-voip@workspace:packages/ios-expo-voip": version: 0.0.0-use.local resolution: "@fishjam-cloud/ios-expo-voip@workspace:packages/ios-expo-voip" From 28576250c75601a8593cca09d933cd454d71c55a Mon Sep 17 00:00:00 2001 From: Angelika Serwa Date: Fri, 16 Oct 2026 01:54:00 +0200 Subject: [PATCH 02/11] Add android-client package and example calling fishjam-core add via JNI --- examples/android-client/.editorconfig | 7 + examples/android-client/.gitignore | 8 + examples/android-client/app/build.gradle.kts | 57 ++++ .../app/src/main/AndroidManifest.xml | 18 ++ .../java/io/fishjam/example/MainActivity.kt | 95 +++++++ examples/android-client/build.gradle.kts | 8 + examples/android-client/gradle.properties | 4 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + examples/android-client/gradlew | 252 ++++++++++++++++++ examples/android-client/package.json | 14 + examples/android-client/settings.gradle.kts | 25 ++ package.json | 2 + packages/android-client/.editorconfig | 7 + packages/android-client/.gitignore | 9 + packages/android-client/build.gradle.kts | 6 + .../android-client/client/build.gradle.kts | 141 ++++++++++ .../android-client/client/consumer-rules.pro | 2 + .../java/io/fishjam/client/FishjamCoreTest.kt | 15 ++ .../client/src/main/AndroidManifest.xml | 2 + .../client/src/main/cpp/CMakeLists.txt | 22 ++ .../client/src/main/cpp/fishjam_jni.cpp | 65 +++++ .../java/io/fishjam/client/FishjamCore.kt | 24 ++ .../main/java/io/fishjam/client/NativeCore.kt | 18 ++ packages/android-client/gradle.properties | 4 + .../android-client/gradle/libs.versions.toml | 37 +++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + packages/android-client/gradlew | 252 ++++++++++++++++++ packages/android-client/package.json | 16 ++ packages/android-client/settings.gradle.kts | 18 ++ yarn.lock | 12 + 32 files changed, 1154 insertions(+) create mode 100644 examples/android-client/.editorconfig create mode 100644 examples/android-client/.gitignore create mode 100644 examples/android-client/app/build.gradle.kts create mode 100644 examples/android-client/app/src/main/AndroidManifest.xml create mode 100644 examples/android-client/app/src/main/java/io/fishjam/example/MainActivity.kt create mode 100644 examples/android-client/build.gradle.kts create mode 100644 examples/android-client/gradle.properties create mode 100644 examples/android-client/gradle/wrapper/gradle-wrapper.jar create mode 100644 examples/android-client/gradle/wrapper/gradle-wrapper.properties create mode 100755 examples/android-client/gradlew create mode 100644 examples/android-client/package.json create mode 100644 examples/android-client/settings.gradle.kts create mode 100644 packages/android-client/.editorconfig create mode 100644 packages/android-client/.gitignore create mode 100644 packages/android-client/build.gradle.kts create mode 100644 packages/android-client/client/build.gradle.kts create mode 100644 packages/android-client/client/consumer-rules.pro create mode 100644 packages/android-client/client/src/androidTest/java/io/fishjam/client/FishjamCoreTest.kt create mode 100644 packages/android-client/client/src/main/AndroidManifest.xml create mode 100644 packages/android-client/client/src/main/cpp/CMakeLists.txt create mode 100644 packages/android-client/client/src/main/cpp/fishjam_jni.cpp create mode 100644 packages/android-client/client/src/main/java/io/fishjam/client/FishjamCore.kt create mode 100644 packages/android-client/client/src/main/java/io/fishjam/client/NativeCore.kt create mode 100644 packages/android-client/gradle.properties create mode 100644 packages/android-client/gradle/libs.versions.toml create mode 100644 packages/android-client/gradle/wrapper/gradle-wrapper.jar create mode 100644 packages/android-client/gradle/wrapper/gradle-wrapper.properties create mode 100755 packages/android-client/gradlew create mode 100644 packages/android-client/package.json create mode 100644 packages/android-client/settings.gradle.kts 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..ce2e5872 --- /dev/null +++ b/examples/android-client/app/build.gradle.kts @@ -0,0 +1,57 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ktlint) +} + +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" + } + + buildFeatures { + compose = 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) + + 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..87876ffa --- /dev/null +++ b/examples/android-client/app/src/main/java/io/fishjam/example/MainActivity.kt @@ -0,0 +1,95 @@ +package io.fishjam.example + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import io.fishjam.client.FishjamCore + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + setContent { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + AddScreen() + } + } + } + } +} + +@Composable +fun AddScreen() { + var first by rememberSaveable { mutableStateOf("2") } + var second by rememberSaveable { mutableStateOf("3") } + var result by rememberSaveable { mutableStateOf("") } + + Column( + modifier = + Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text("Fishjam example", style = MaterialTheme.typography.headlineSmall) + OutlinedTextField( + value = first, + onValueChange = { first = it }, + label = { Text("First number") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth().testTag("first") + ) + OutlinedTextField( + value = second, + onValueChange = { second = it }, + label = { Text("Second number") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth().testTag("second") + ) + Button( + onClick = { + val a = first.trim().toIntOrNull() + val b = second.trim().toIntOrNull() + result = + if (a == null || b == null) { + "Enter two whole numbers" + } else { + "Result: ${FishjamCore.add(a, b)}" + } + }, + modifier = Modifier.fillMaxWidth().testTag("add") + ) { + Text("Add") + } + Text(result, modifier = Modifier.testTag("result")) + } +} 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 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 0 HcmV?d00001 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..59fe1fa4 --- /dev/null +++ b/examples/android-client/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } + 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/package.json b/package.json index 8da2547a..433325fd 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,10 @@ "packages/ios-expo-voip", "packages/react-native-webrtc", "packages/fishjam-core", + "packages/android-client", "examples/react-client/*", "examples/mobile-client/*", + "examples/android-client", "e2e-tests/webrtc-client", "e2e-tests/react-client", "e2e-tests/livestream-client" diff --git a/packages/android-client/.editorconfig b/packages/android-client/.editorconfig new file mode 100644 index 00000000..e38ee6f6 --- /dev/null +++ b/packages/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/packages/android-client/.gitignore b/packages/android-client/.gitignore new file mode 100644 index 00000000..255c3dae --- /dev/null +++ b/packages/android-client/.gitignore @@ -0,0 +1,9 @@ +.gradle/ +build/ +local.properties +*.iml +.idea/ +captures/ +.cxx/ +.externalNativeBuild/ +.kotlin/ diff --git a/packages/android-client/build.gradle.kts b/packages/android-client/build.gradle.kts new file mode 100644 index 00000000..916d15f2 --- /dev/null +++ b/packages/android-client/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.protobuf) apply false + alias(libs.plugins.ktlint) apply false +} diff --git a/packages/android-client/client/build.gradle.kts b/packages/android-client/client/build.gradle.kts new file mode 100644 index 00000000..6e5019a4 --- /dev/null +++ b/packages/android-client/client/build.gradle.kts @@ -0,0 +1,141 @@ +import com.google.protobuf.gradle.proto +import java.util.Properties + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.protobuf) + alias(libs.plugins.ktlint) +} + +val ndkVersionName = "27.1.12297006" +val fishjamCoreAbis = mapOf( + "arm64-v8a" to "aarch64-linux-android", + "x86_64" to "x86_64-linux-android" +) +val fishjamCoreDir: File = projectDir.resolve("../../fishjam-core").canonicalFile + +android { + namespace = "io.fishjam.client" + compileSdk = 35 + ndkVersion = ndkVersionName + + defaultConfig { + minSdk = 24 + consumerProguardFiles("consumer-rules.pro") + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + ndk { + abiFilters += fishjamCoreAbis.keys + } + externalNativeBuild { + cmake { + arguments += listOf( + "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON", + "-DFISHJAM_CORE_DIR=${fishjamCoreDir.absolutePath}" + ) + } + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + version = "3.22.1" + } + } + + sourceSets { + getByName("main") { + proto { + srcDir(fishjamCoreDir.resolve("proto")) + } + } + } + + testOptions { + unitTests.isReturnDefaultValues = true + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:${libs.versions.protobufRuntime.get()}" + } + generateProtoTasks { + all().configureEach { + builtins { + register("java") { option("lite") } + register("kotlin") { option("lite") } + } + } + } +} + +ktlint { + android.set(true) + outputToConsole.set(true) + ignoreFailures.set(false) + filter { + exclude { it.file.path.contains("/build/") } + } +} + +dependencies { + implementation(libs.protobuf.kotlin.lite) + implementation(libs.coroutines.android) + + testImplementation(libs.junit) + testImplementation(libs.coroutines.test) + + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.core) +} + +val buildFishjamCore = tasks.register("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/androidTest/java/io/fishjam/client/FishjamCoreTest.kt b/packages/android-client/client/src/androidTest/java/io/fishjam/client/FishjamCoreTest.kt new file mode 100644 index 00000000..83fd7a46 --- /dev/null +++ b/packages/android-client/client/src/androidTest/java/io/fishjam/client/FishjamCoreTest.kt @@ -0,0 +1,15 @@ +package io.fishjam.client + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class FishjamCoreTest { + @Test + fun addCallsFishjamCore() { + assertEquals(5, FishjamCore.add(2, 3)) + assertEquals(-1, FishjamCore.add(2, -3)) + } +} 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..b2d3ea12 --- /dev/null +++ b/packages/android-client/client/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + 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..64f61322 --- /dev/null +++ b/packages/android-client/client/src/main/cpp/fishjam_jni.cpp @@ -0,0 +1,65 @@ +#include +#include +#include + +#include "fishjam.h" + +namespace { + +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); } +}; + +} // namespace + +extern "C" JNIEXPORT jlong JNICALL +Java_io_fishjam_client_NativeCore_nativeCreate(JNIEnv *env, jclass, jbyteArray config) { + ByteArrayView view(env, config); + FishjamClient *client = fishjam_client_new(view.bytes(), static_cast(view.len), nullptr, nullptr); + return reinterpret_cast(client); +} + +extern "C" JNIEXPORT jobject JNICALL +Java_io_fishjam_client_NativeCore_nativeCall(JNIEnv *env, jclass, jlong handle, jbyteArray request) { + FishjamBuf out; + memset(&out, 0, sizeof(out)); + + int32_t code; + { + ByteArrayView view(env, request); + code = fishjam_client_call(reinterpret_cast(handle), 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 *, jclass, jlong handle) { + fishjam_client_free(reinterpret_cast(handle)); +} diff --git a/packages/android-client/client/src/main/java/io/fishjam/client/FishjamCore.kt b/packages/android-client/client/src/main/java/io/fishjam/client/FishjamCore.kt new file mode 100644 index 00000000..5b2bb78d --- /dev/null +++ b/packages/android-client/client/src/main/java/io/fishjam/client/FishjamCore.kt @@ -0,0 +1,24 @@ +package io.fishjam.client + +import fishjam.sdk.SdkApi + +/** Temporary check that the Kotlin code reaches fishjam-core through JNI. */ +object FishjamCore { + fun add(a: Int, b: Int): Int { + val handle = NativeCore.nativeCreate(ByteArray(0)) + check(handle != 0L) { "fishjam_client_new returned null" } + try { + val request = + SdkApi.SdkRequest + .newBuilder() + .setAdd(SdkApi.AddRequest.newBuilder().setA(a).setB(b)) + .build() + val result = NativeCore.nativeCall(handle, request.toByteArray()) + val response = SdkApi.SdkResponse.parseFrom(result.payload) + check(result.code == 0 && response.hasAdd()) { response.error.message } + return response.add.sum + } finally { + NativeCore.nativeDestroy(handle) + } + } +} 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..3d701be6 --- /dev/null +++ b/packages/android-client/client/src/main/java/io/fishjam/client/NativeCore.kt @@ -0,0 +1,18 @@ +package io.fishjam.client + +class NativeCallResult( + @JvmField val code: Int, + @JvmField val payload: ByteArray +) + +internal object NativeCore { + init { + System.loadLibrary("fishjam_jni") + } + + @JvmStatic external fun nativeCreate(config: ByteArray): Long + + @JvmStatic external fun nativeCall(handle: Long, request: ByteArray): NativeCallResult + + @JvmStatic external fun nativeDestroy(handle: Long) +} 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..b8c07264 --- /dev/null +++ b/packages/android-client/gradle/libs.versions.toml @@ -0,0 +1,37 @@ +[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" +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" } +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 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 0 HcmV?d00001 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..d4549d7f --- /dev/null +++ b/packages/android-client/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "android-client" +include(":client") diff --git a/yarn.lock b/yarn.lock index 65ff120e..b9417dd4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5388,6 +5388,12 @@ __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" @@ -5730,6 +5736,12 @@ __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/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" From 122efa6c08e356f44f5e70981beafc0fec378714 Mon Sep 17 00:00:00 2001 From: Angelika Serwa Date: Fri, 16 Oct 2026 02:08:27 +0200 Subject: [PATCH 03/11] Add ios-client package and example calling fishjam-core add via C ABI --- examples/ios-client/.gitignore | 5 + examples/ios-client/.swiftlint.yml | 8 + .../FishjamExample/FishjamExampleApp.swift | 49 + examples/ios-client/package.json | 15 + examples/ios-client/project.yml | 37 + package.json | 2 + packages/ios-client/.gitignore | 7 + packages/ios-client/.swiftlint.yml | 17 + .../FishjamClient/FishjamCore.swift | 35 + .../FishjamClient/Generated/sdk_api.pb.swift | 3076 +++++++++++++++++ .../FishjamClientTests/FishjamCoreTests.swift | 9 + packages/ios-client/package.json | 16 + packages/ios-client/project.yml | 52 + packages/ios-client/scripts/build-core.sh | 33 + packages/ios-client/scripts/generate-proto.sh | 9 + yarn.lock | 12 + 16 files changed, 3382 insertions(+) create mode 100644 examples/ios-client/.gitignore create mode 100644 examples/ios-client/.swiftlint.yml create mode 100644 examples/ios-client/FishjamExample/FishjamExampleApp.swift create mode 100644 examples/ios-client/package.json create mode 100644 examples/ios-client/project.yml create mode 100644 packages/ios-client/.gitignore create mode 100644 packages/ios-client/.swiftlint.yml create mode 100644 packages/ios-client/FishjamClient/FishjamCore.swift create mode 100644 packages/ios-client/FishjamClient/Generated/sdk_api.pb.swift create mode 100644 packages/ios-client/FishjamClientTests/FishjamCoreTests.swift create mode 100644 packages/ios-client/package.json create mode 100644 packages/ios-client/project.yml create mode 100755 packages/ios-client/scripts/build-core.sh create mode 100755 packages/ios-client/scripts/generate-proto.sh diff --git a/examples/ios-client/.gitignore b/examples/ios-client/.gitignore new file mode 100644 index 00000000..74d52379 --- /dev/null +++ b/examples/ios-client/.gitignore @@ -0,0 +1,5 @@ +build/ +DerivedData/ +*.xcodeproj/ +*.xcuserstate +xcuserdata/ diff --git a/examples/ios-client/.swiftlint.yml b/examples/ios-client/.swiftlint.yml new file mode 100644 index 00000000..e6b91af6 --- /dev/null +++ b/examples/ios-client/.swiftlint.yml @@ -0,0 +1,8 @@ +included: + - FishjamExample +excluded: + - build +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..a9ac8586 --- /dev/null +++ b/examples/ios-client/FishjamExample/FishjamExampleApp.swift @@ -0,0 +1,49 @@ +import FishjamClient +import SwiftUI + +@main +struct FishjamExampleApp: App { + var body: some Scene { + WindowGroup { + AddView() + } + } +} + +struct AddView: View { + @State private var first = "2" + @State private var second = "3" + @State private var result = "" + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text("Fishjam example") + .font(.title2) + TextField("First number", text: $first) + .keyboardType(.numbersAndPunctuation) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("first") + TextField("Second number", text: $second) + .keyboardType(.numbersAndPunctuation) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("second") + Button("Add") { + let trimmedFirst = first.trimmingCharacters(in: .whitespaces) + let trimmedSecond = second.trimmingCharacters(in: .whitespaces) + guard let lhs = Int32(trimmedFirst), let rhs = Int32(trimmedSecond) else { + result = "Enter two whole numbers" + return + } + result = "Result: \(FishjamCoreBridge.add(a: lhs, b: rhs))" + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("add") + Text(result) + .accessibilityIdentifier("result") + } + .padding(16) + } + } +} diff --git a/examples/ios-client/package.json b/examples/ios-client/package.json new file mode 100644 index 00000000..baff29d0 --- /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": "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..2753bf21 --- /dev/null +++ b/examples/ios-client/project.yml @@ -0,0 +1,37 @@ +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 + 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 + settings: + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_UILaunchScreen_Generation: YES + INFOPLIST_KEY_CFBundleDisplayName: Fishjam Example + PRODUCT_BUNDLE_IDENTIFIER: io.fishjam.example + TARGETED_DEVICE_FAMILY: "1,2" +schemes: + FishjamExample: + build: + targets: + FishjamExample: all + run: + config: Debug diff --git a/package.json b/package.json index 433325fd..3c479182 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,11 @@ "packages/react-native-webrtc", "packages/fishjam-core", "packages/android-client", + "packages/ios-client", "examples/react-client/*", "examples/mobile-client/*", "examples/android-client", + "examples/ios-client", "e2e-tests/webrtc-client", "e2e-tests/react-client", "e2e-tests/livestream-client" diff --git a/packages/ios-client/.gitignore b/packages/ios-client/.gitignore new file mode 100644 index 00000000..b9d57e7d --- /dev/null +++ b/packages/ios-client/.gitignore @@ -0,0 +1,7 @@ +build/ +Core/ +Vendor/ +DerivedData/ +*.xcodeproj/ +*.xcuserstate +xcuserdata/ diff --git a/packages/ios-client/.swiftlint.yml b/packages/ios-client/.swiftlint.yml new file mode 100644 index 00000000..1c50d980 --- /dev/null +++ b/packages/ios-client/.swiftlint.yml @@ -0,0 +1,17 @@ +included: + - FishjamClient + - FishjamClientTests +excluded: + - FishjamClient/Generated + - build + - Core + - Vendor +strict: true +line_length: + warning: 120 + error: 160 +identifier_name: + excluded: + - a + - b + - id diff --git a/packages/ios-client/FishjamClient/FishjamCore.swift b/packages/ios-client/FishjamClient/FishjamCore.swift new file mode 100644 index 00000000..bb97bbff --- /dev/null +++ b/packages/ios-client/FishjamClient/FishjamCore.swift @@ -0,0 +1,35 @@ +import FishjamCore +import Foundation + +/// Temporary check that the Swift code reaches fishjam-core through the C ABI. +public enum FishjamCoreBridge { + public static func add(a: Int32, b: Int32) -> Int32 { + guard let client = fishjam_client_new(nil, 0, nil, nil) else { + fatalError("fishjam_client_new returned null") + } + defer { fishjam_client_free(client) } + + let request = Fishjam_Sdk_SdkRequest.with { + $0.add = Fishjam_Sdk_AddRequest.with { + $0.a = a + $0.b = b + } + } + // swiftlint:disable:next force_try + let bytes = try! request.serializedData() + + var out = FishjamBuf(data: nil, len: 0) + let code = bytes.withUnsafeBytes { raw in + fishjam_client_call(client, raw.bindMemory(to: UInt8.self).baseAddress, bytes.count, &out) + } + defer { fishjam_buf_free(out) } + + let payload = out.data.map { Data(bytes: $0, count: out.len) } ?? Data() + guard code == 0, let response = try? Fishjam_Sdk_SdkResponse(serializedBytes: payload), + case let .add(result)? = response.content + else { + fatalError("fishjam_client_call failed with code \(code)") + } + return result.sum + } +} 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..1d647d18 --- /dev/null +++ b/packages/ios-client/FishjamClient/Generated/sdk_api.pb.swift @@ -0,0 +1,3076 @@ +// 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 add: Fishjam_Sdk_AddRequest { + get { + if case .add(let v)? = content {return v} + return Fishjam_Sdk_AddRequest() + } + set {content = .add(newValue)} + } + + 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 add(Fishjam_Sdk_AddRequest) + 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 add: Fishjam_Sdk_AddResponse { + get { + if case .add(let v)? = content {return v} + return Fishjam_Sdk_AddResponse() + } + set {content = .add(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 add(Fishjam_Sdk_AddResponse) + 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_AddRequest: 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 a: Int32 = 0 + + var b: Int32 = 0 + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Fishjam_Sdk_AddResponse: 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 sum: Int32 = 0 + + 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{1}add\0\u{3}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") + + 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_AddRequest? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .add(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .add(v) + } + }() + 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 .add?: try { + guard case .add(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + }() + 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{1}add\0\u{1}empty\0\u{3}add_track\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 { + 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 2: try { + var v: Fishjam_Sdk_AddResponse? + var hadOneofValue = false + if let current = self.content { + hadOneofValue = true + if case .add(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.content = .add(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 .add?: try { + guard case .add(let v)? = self.content else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + }() + 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_AddRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".AddRequest" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}a\0\u{1}b\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.a) }() + case 2: try { try decoder.decodeSingularInt32Field(value: &self.b) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if self.a != 0 { + try visitor.visitSingularInt32Field(value: self.a, fieldNumber: 1) + } + if self.b != 0 { + try visitor.visitSingularInt32Field(value: self.b, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_AddRequest, rhs: Fishjam_Sdk_AddRequest) -> Bool { + if lhs.a != rhs.a {return false} + if lhs.b != rhs.b {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Fishjam_Sdk_AddResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".AddResponse" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}sum\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.sum) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if self.sum != 0 { + try visitor.visitSingularInt32Field(value: self.sum, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Fishjam_Sdk_AddResponse, rhs: Fishjam_Sdk_AddResponse) -> Bool { + if lhs.sum != rhs.sum {return false} + 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/FishjamClientTests/FishjamCoreTests.swift b/packages/ios-client/FishjamClientTests/FishjamCoreTests.swift new file mode 100644 index 00000000..ad96cbc3 --- /dev/null +++ b/packages/ios-client/FishjamClientTests/FishjamCoreTests.swift @@ -0,0 +1,9 @@ +@testable import FishjamClient +import XCTest + +final class FishjamCoreTests: XCTestCase { + func testAddCallsFishjamCore() { + XCTAssertEqual(FishjamCoreBridge.add(a: 2, b: 3), 5) + XCTAssertEqual(FishjamCoreBridge.add(a: 2, b: -3), -1) + } +} diff --git a/packages/ios-client/package.json b/packages/ios-client/package.json new file mode 100644 index 00000000..c8efd08b --- /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 && 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..7e3cd267 --- /dev/null +++ b/packages/ios-client/project.yml @@ -0,0 +1,52 @@ +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 + 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 + 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 + 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/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 b9417dd4..9811c971 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5400,6 +5400,12 @@ __metadata: 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" @@ -5742,6 +5748,12 @@ __metadata: 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" From 612bfc0ef3130f49bc62e443473c365332a31e90 Mon Sep 17 00:00:00 2001 From: Angelika Serwa Date: Fri, 16 Oct 2026 14:40:49 +0200 Subject: [PATCH 04/11] Add joinRoom and leaveRoom to fishjam-core and android-client --- .env.example | 2 + examples/android-client/app/build.gradle.kts | 34 + .../java/io/fishjam/example/MainActivity.kt | 134 +++- .../android-client/client/build.gradle.kts | 1 + .../java/io/fishjam/client/FishjamCoreTest.kt | 15 - .../client/src/main/AndroidManifest.xml | 5 +- .../client/src/main/cpp/fishjam_jni.cpp | 74 ++- .../java/io/fishjam/client/FishjamClient.kt | 108 ++++ .../java/io/fishjam/client/FishjamCore.kt | 24 - .../main/java/io/fishjam/client/NativeCore.kt | 6 +- .../io/fishjam/client/PeerStatusMapping.kt | 50 ++ .../fishjam/client/PeerStatusMappingTest.kt | 72 +++ .../android-client/gradle/libs.versions.toml | 2 + packages/fishjam-core/Cargo.lock | 579 +++++++++++++++++- packages/fishjam-core/Cargo.toml | 6 + packages/fishjam-core/build.rs | 14 +- packages/fishjam-core/proto/sdk_api.proto | 13 +- packages/fishjam-core/src/abi.rs | 85 ++- packages/fishjam-core/src/auth.rs | 26 + packages/fishjam-core/src/client.rs | 333 ++++++++++ packages/fishjam-core/src/lib.rs | 30 +- packages/fishjam-core/src/signaling.rs | 114 ++++ packages/fishjam-core/src/url.rs | 24 + packages/fishjam-core/tests/abi.rs | 190 +++++- packages/fishjam-core/tests/auth.rs | 254 ++++++++ packages/fishjam-core/tests/common/mod.rs | 234 +++++++ packages/fishjam-core/tests/live.rs | 86 +++ 27 files changed, 2357 insertions(+), 158 deletions(-) create mode 100644 .env.example delete mode 100644 packages/android-client/client/src/androidTest/java/io/fishjam/client/FishjamCoreTest.kt create mode 100644 packages/android-client/client/src/main/java/io/fishjam/client/FishjamClient.kt delete mode 100644 packages/android-client/client/src/main/java/io/fishjam/client/FishjamCore.kt create mode 100644 packages/android-client/client/src/main/java/io/fishjam/client/PeerStatusMapping.kt create mode 100644 packages/android-client/client/src/test/java/io/fishjam/client/PeerStatusMappingTest.kt create mode 100644 packages/fishjam-core/src/auth.rs create mode 100644 packages/fishjam-core/src/client.rs create mode 100644 packages/fishjam-core/src/signaling.rs create mode 100644 packages/fishjam-core/src/url.rs create mode 100644 packages/fishjam-core/tests/auth.rs create mode 100644 packages/fishjam-core/tests/common/mod.rs create mode 100644 packages/fishjam-core/tests/live.rs 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/examples/android-client/app/build.gradle.kts b/examples/android-client/app/build.gradle.kts index ce2e5872..c7210fa3 100644 --- a/examples/android-client/app/build.gradle.kts +++ b/examples/android-client/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.io.File + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) @@ -5,6 +7,30 @@ plugins { 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 @@ -16,10 +42,17 @@ android { 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 { @@ -48,6 +81,7 @@ dependencies { 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) 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 index 87876ffa..5f58a15b 100644 --- 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 @@ -1,5 +1,6 @@ package io.fishjam.example +import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -13,7 +14,6 @@ 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 @@ -21,15 +21,28 @@ 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.platform.testTag -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import io.fishjam.client.FishjamCore +import io.fishjam.client.FishjamClient +import io.fishjam.client.PeerStatus +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 class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -38,7 +51,7 @@ class MainActivity : ComponentActivity() { setContent { MaterialTheme { Surface(modifier = Modifier.fillMaxSize()) { - AddScreen() + JoinScreen() } } } @@ -46,10 +59,17 @@ class MainActivity : ComponentActivity() { } @Composable -fun AddScreen() { - var first by rememberSaveable { mutableStateOf("2") } - var second by rememberSaveable { mutableStateOf("3") } - var result by rememberSaveable { mutableStateOf("") } +fun JoinScreen() { + val client = remember { FishjamClient(FISHJAM_ID) } + DisposableEffect(client) { + onDispose { client.close() } + } + val scope = rememberCoroutineScope() + val peerStatus by client.peerStatus.collectAsState() + var roomName by rememberSaveable { mutableStateOf("testtest") } + var peerName by rememberSaveable { mutableStateOf("android") } + var token by rememberSaveable { mutableStateOf("") } + var notice by rememberSaveable { mutableStateOf("") } Column( modifier = @@ -62,34 +82,92 @@ fun AddScreen() { ) { Text("Fishjam example", style = MaterialTheme.typography.headlineSmall) OutlinedTextField( - value = first, - onValueChange = { first = it }, - label = { Text("First number") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth().testTag("first") + value = roomName, + onValueChange = { roomName = it }, + label = { Text("Room name") }, + modifier = Modifier.fillMaxWidth().testTag("room") + ) + OutlinedTextField( + value = peerName, + onValueChange = { peerName = it }, + label = { Text("Peer name") }, + modifier = Modifier.fillMaxWidth().testTag("peer") ) + 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.fillMaxWidth().testTag("fetch") + ) { + Text("Fetch token") + } OutlinedTextField( - value = second, - onValueChange = { second = it }, - label = { Text("Second number") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth().testTag("second") + value = token, + onValueChange = { token = it }, + label = { Text("Token") }, + modifier = Modifier.fillMaxWidth().testTag("token") ) Button( onClick = { - val a = first.trim().toIntOrNull() - val b = second.trim().toIntOrNull() - result = - if (a == null || b == null) { - "Enter two whole numbers" - } else { - "Result: ${FishjamCore.add(a, b)}" + scope.launch { + notice = "" + try { + client.joinRoom(token.trim()) + } catch (error: Exception) { + notice = error.message ?: "join failed" } + } + }, + enabled = token.isNotBlank() && peerStatus != PeerStatus.Connected, + modifier = Modifier.fillMaxWidth().testTag("join") + ) { + Text("Join") + } + Button( + onClick = { + notice = "" + client.leaveRoom() }, - modifier = Modifier.fillMaxWidth().testTag("add") + enabled = peerStatus != PeerStatus.Idle, + modifier = Modifier.fillMaxWidth().testTag("leave") ) { - Text("Add") + Text("Leave") + } + 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") } - Text(result, modifier = Modifier.testTag("result")) + JSONObject(body).getString("peerToken") + } finally { + connection.disconnect() } } diff --git a/packages/android-client/client/build.gradle.kts b/packages/android-client/client/build.gradle.kts index 6e5019a4..3c51a6c9 100644 --- a/packages/android-client/client/build.gradle.kts +++ b/packages/android-client/client/build.gradle.kts @@ -94,6 +94,7 @@ ktlint { dependencies { implementation(libs.protobuf.kotlin.lite) implementation(libs.coroutines.android) + api(libs.serialization.json) testImplementation(libs.junit) testImplementation(libs.coroutines.test) diff --git a/packages/android-client/client/src/androidTest/java/io/fishjam/client/FishjamCoreTest.kt b/packages/android-client/client/src/androidTest/java/io/fishjam/client/FishjamCoreTest.kt deleted file mode 100644 index 83fd7a46..00000000 --- a/packages/android-client/client/src/androidTest/java/io/fishjam/client/FishjamCoreTest.kt +++ /dev/null @@ -1,15 +0,0 @@ -package io.fishjam.client - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class FishjamCoreTest { - @Test - fun addCallsFishjamCore() { - assertEquals(5, FishjamCore.add(2, 3)) - assertEquals(-1, FishjamCore.add(2, -3)) - } -} diff --git a/packages/android-client/client/src/main/AndroidManifest.xml b/packages/android-client/client/src/main/AndroidManifest.xml index b2d3ea12..56a9e28a 100644 --- a/packages/android-client/client/src/main/AndroidManifest.xml +++ b/packages/android-client/client/src/main/AndroidManifest.xml @@ -1,2 +1,5 @@ - + + + + diff --git a/packages/android-client/client/src/main/cpp/fishjam_jni.cpp b/packages/android-client/client/src/main/cpp/fishjam_jni.cpp index 64f61322..62ae25e9 100644 --- a/packages/android-client/client/src/main/cpp/fishjam_jni.cpp +++ b/packages/android-client/client/src/main/cpp/fishjam_jni.cpp @@ -6,6 +6,8 @@ namespace { +JavaVM *g_vm = nullptr; + struct ByteArrayView { JNIEnv *env; jbyteArray array; @@ -26,25 +28,77 @@ struct ByteArrayView { 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) { +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); - FishjamClient *client = fishjam_client_new(view.bytes(), static_cast(view.len), nullptr, nullptr); - return reinterpret_cast(client); + 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 handle, jbyteArray request) { +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(reinterpret_cast(handle), view.bytes(), - static_cast(view.len), &out); + code = fishjam_client_call(handle->client, view.bytes(), static_cast(view.len), &out); } jsize out_len = out.data != nullptr ? static_cast(out.len) : 0; @@ -60,6 +114,10 @@ Java_io_fishjam_client_NativeCore_nativeCall(JNIEnv *env, jclass, jlong handle, } extern "C" JNIEXPORT void JNICALL -Java_io_fishjam_client_NativeCore_nativeDestroy(JNIEnv *, jclass, jlong handle) { - fishjam_client_free(reinterpret_cast(handle)); +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..b65aaa57 --- /dev/null +++ b/packages/android-client/client/src/main/java/io/fishjam/client/FishjamClient.kt @@ -0,0 +1,108 @@ +package io.fishjam.client + +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.launch +import kotlinx.serialization.json.JsonObject + +class FishjamClient( + fishjamId: String, + debug: Boolean = false +) : AutoCloseable { + private val _peerStatus = MutableStateFlow(PeerStatus.Idle) + val peerStatus: StateFlow = _peerStatus.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 events = + NativeEvents { payload -> + val update = peerStatusUpdate(SdkApi.SdkEvent.parseFrom(payload)) ?: return@NativeEvents + scope.launch { + if (closed.get()) return@launch + _peerStatus.value = update.peerStatus + val waiter = synchronized(joinLock) { joinResult } + when { + update.peerStatus == PeerStatus.Connected -> waiter?.complete(Unit) + else -> joinFailure(update)?.let { waiter?.completeExceptionally(it) } + } + } + } + + 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 request = + SdkApi.SdkRequest + .newBuilder() + .setJoinRoom( + SdkApi.JoinRoom + .newBuilder() + .setPeerToken(peerToken) + .setPeerMetadataJson(peerMetadata.toString()) + ).build() + val result = NativeCore.nativeCall(handle, request.toByteArray()) + if (result.code != 0) { + val response = SdkApi.SdkResponse.parseFrom(result.payload) + throw IllegalStateException(response.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 + NativeCore.nativeCall( + handle, + SdkApi.SdkRequest + .newBuilder() + .setLeaveRoom(SdkApi.LeaveRoom.getDefaultInstance()) + .build() + .toByteArray() + ) + } + + override fun close() { + if (!closed.compareAndSet(false, true)) return + synchronized(joinLock) { + joinResult?.cancel() + joinResult = null + } + scope.cancel() + NativeCore.nativeDestroy(handle) + } +} diff --git a/packages/android-client/client/src/main/java/io/fishjam/client/FishjamCore.kt b/packages/android-client/client/src/main/java/io/fishjam/client/FishjamCore.kt deleted file mode 100644 index 5b2bb78d..00000000 --- a/packages/android-client/client/src/main/java/io/fishjam/client/FishjamCore.kt +++ /dev/null @@ -1,24 +0,0 @@ -package io.fishjam.client - -import fishjam.sdk.SdkApi - -/** Temporary check that the Kotlin code reaches fishjam-core through JNI. */ -object FishjamCore { - fun add(a: Int, b: Int): Int { - val handle = NativeCore.nativeCreate(ByteArray(0)) - check(handle != 0L) { "fishjam_client_new returned null" } - try { - val request = - SdkApi.SdkRequest - .newBuilder() - .setAdd(SdkApi.AddRequest.newBuilder().setA(a).setB(b)) - .build() - val result = NativeCore.nativeCall(handle, request.toByteArray()) - val response = SdkApi.SdkResponse.parseFrom(result.payload) - check(result.code == 0 && response.hasAdd()) { response.error.message } - return response.add.sum - } finally { - NativeCore.nativeDestroy(handle) - } - } -} 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 index 3d701be6..7361f796 100644 --- 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 @@ -5,12 +5,16 @@ class NativeCallResult( @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): Long + @JvmStatic external fun nativeCreate(config: ByteArray, events: NativeEvents): Long @JvmStatic external fun nativeCall(handle: Long, request: ByteArray): NativeCallResult 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/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/gradle/libs.versions.toml b/packages/android-client/gradle/libs.versions.toml index b8c07264..e5587ab6 100644 --- a/packages/android-client/gradle/libs.versions.toml +++ b/packages/android-client/gradle/libs.versions.toml @@ -8,6 +8,7 @@ activity = "1.9.3" protobuf = "0.9.4" protobufRuntime = "3.25.5" coroutines = "1.9.0" +serialization = "1.7.3" androidxJunit = "1.2.1" testRunner = "1.6.2" testCore = "1.6.1" @@ -24,6 +25,7 @@ activity-compose = { group = "androidx.activity", name = "activity-compose", ver 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" } 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" } diff --git a/packages/fishjam-core/Cargo.lock b/packages/fishjam-core/Cargo.lock index 1e7b230d..4f2fdf50 100644 --- a/packages/fishjam-core/Cargo.lock +++ b/packages/fishjam-core/Cargo.lock @@ -23,18 +23,72 @@ 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 = "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" @@ -54,7 +108,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -63,13 +117,24 @@ 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", + "tokio", + "tokio-tungstenite", ] [[package]] @@ -78,6 +143,70 @@ 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" @@ -86,7 +215,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", ] [[package]] @@ -101,6 +230,22 @@ 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" @@ -120,6 +265,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "libc" version = "0.2.190" @@ -144,6 +295,17 @@ 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" @@ -166,6 +328,21 @@ dependencies = [ "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" @@ -173,7 +350,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.119", ] [[package]] @@ -211,7 +388,7 @@ dependencies = [ "prost", "prost-types", "regex", - "syn", + "syn 2.0.119", "tempfile", ] @@ -225,7 +402,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -310,12 +487,47 @@ 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" @@ -345,6 +557,20 @@ 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" @@ -355,9 +581,82 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-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 = "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" @@ -369,6 +668,17 @@ dependencies = [ "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" @@ -376,24 +686,181 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "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 = "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 = "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" @@ -402,3 +869,99 @@ 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" diff --git a/packages/fishjam-core/Cargo.toml b/packages/fishjam-core/Cargo.toml index 11ef8b5e..ae608faa 100644 --- a/packages/fishjam-core/Cargo.toml +++ b/packages/fishjam-core/Cargo.toml @@ -11,10 +11,16 @@ 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"] } +tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros", "net"] } +tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } [dev-dependencies] prost = "0.13" +tokio = { version = "1", features = ["rt-multi-thread", "net", "macros"] } [build-dependencies] prost-build = "0.13" diff --git a/packages/fishjam-core/build.rs b/packages/fishjam-core/build.rs index 9d59c510..1cd30ce6 100644 --- a/packages/fishjam-core/build.rs +++ b/packages/fishjam-core/build.rs @@ -2,9 +2,19 @@ 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") - .compile_protos(&["proto/sdk_api.proto"], &["proto/"]) - .expect("compile sdk_api.proto"); + .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/proto/sdk_api.proto b/packages/fishjam-core/proto/sdk_api.proto index 2840826a..a267ab32 100644 --- a/packages/fishjam-core/proto/sdk_api.proto +++ b/packages/fishjam-core/proto/sdk_api.proto @@ -10,8 +10,8 @@ message ClientConfig { // Bytes passed to fishjam_client_call. message SdkRequest { + reserved 1; oneof content { - AddRequest add = 1; JoinRoom join_room = 2; LeaveRoom leave_room = 3; AddTrack add_track = 4; @@ -28,9 +28,9 @@ message SdkRequest { // Bytes written to FishjamBuf by fishjam_client_call. message SdkResponse { + reserved 2; oneof content { Error error = 1; - AddResponse add = 2; Empty empty = 3; AddTrackResult add_track = 4; RoomState room_state = 5; @@ -61,15 +61,6 @@ message JoinRoom { message LeaveRoom {} -message AddRequest { - int32 a = 1; - int32 b = 2; -} - -message AddResponse { - int32 sum = 1; -} - message Empty {} message Error { diff --git a/packages/fishjam-core/src/abi.rs b/packages/fishjam-core/src/abi.rs index 9b06a990..cc72359b 100644 --- a/packages/fishjam-core/src/abi.rs +++ b/packages/fishjam-core/src/abi.rs @@ -2,10 +2,9 @@ use std::os::raw::c_void; use prost::Message; -use crate::add; +use crate::client::FishjamClient; use crate::sdk::{ - sdk_request, sdk_response, AddRequest, AddResponse, ClientConfig, Error, SdkRequest, - SdkResponse, + sdk_request, sdk_response, ClientConfig, Empty, Error, SdkEvent, SdkRequest, SdkResponse, }; /// Return codes for [`fishjam_client_call`]. @@ -22,11 +21,16 @@ pub struct FishjamBuf { pub len: usize, } -#[allow(dead_code)] -pub struct FishjamClient { - config: ClientConfig, - on_event: FishjamOnEvent, - user_data: *mut c_void, +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 @@ -48,11 +52,14 @@ pub unsafe extern "C" fn fishjam_client_new( } }; - Box::into_raw(Box::new(FishjamClient { - config: parsed, - on_event, - user_data, - })) + 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 @@ -66,7 +73,7 @@ pub unsafe extern "C" fn fishjam_client_free(client: *mut FishjamClient) { /// # Safety /// `client` is a live client. `req` points at `req_len` bytes. `out` is a live `FishjamBuf` -/// the caller owns; on success its `data` must be released with [`fishjam_buf_free`]. +/// 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, @@ -77,6 +84,7 @@ pub unsafe extern "C" fn fishjam_client_call( 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); @@ -87,14 +95,32 @@ pub unsafe extern "C" fn fishjam_client_call( } }; - match request.content { - Some(sdk_request::Content::Add(AddRequest { a, b })) => { - let response = SdkResponse { - content: Some(sdk_response::Content::Add(AddResponse { sum: add(a, b) })), - }; - write_response(out, response, OK) + 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(_) | None => write_response(out, error_response("unsupported request"), ILLEGAL_CALL), + Some(_) | 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, + ), } } @@ -108,13 +134,20 @@ pub unsafe extern "C" fn fishjam_buf_free(buf: FishjamBuf) { 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.to_owned(), - auth_reason: String::new(), - join_reason: String::new(), - })), + content: Some(sdk_response::Content::Error(error_message(message))), } } 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..47dcbed4 --- /dev/null +++ b/packages/fishjam-core/src/client.rs @@ -0,0 +1,333 @@ +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::sdk::{sdk_event, ClientConfig, Error, PeerStatus, PeerStatusChanged, SdkEvent}; +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, + is_audio_only: bool, +} + +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, + is_audio_only: false, + }), + 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 + } +} + +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; + self.local_endpoint_id = None; + 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, + ) { + match content { + server::media_event::Content::Connected(connected) => { + self.local_endpoint_id = Some(connected.endpoint_id); + self.set_status(PeerStatus::Connected, None, events); + } + server::media_event::Content::Error(error) => { + self.set_status( + PeerStatus::Error, + Some(Error { + message: error.message, + ..Default::default() + }), + 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() + } + }; + self.set_status(PeerStatus::Error, Some(error), events); + } +} diff --git a/packages/fishjam-core/src/lib.rs b/packages/fishjam-core/src/lib.rs index ee9dd7c2..de6c56d8 100644 --- a/packages/fishjam-core/src/lib.rs +++ b/packages/fishjam-core/src/lib.rs @@ -1,24 +1,20 @@ -pub mod sdk { - include!(concat!(env!("OUT_DIR"), "/fishjam.sdk.rs")); +#[allow(clippy::all)] +mod proto { + include!(concat!(env!("OUT_DIR"), "/protos.rs")); } mod abi; +mod auth; +mod client; +mod signaling; +mod url; pub use abi::{ fishjam_buf_free, fishjam_client_call, fishjam_client_free, fishjam_client_new, FishjamBuf, + FishjamOnEvent, }; - -/// Adds two numbers. Temporary stand-in used to prove the C ABI before joinRoom exists. -pub fn add(a: i32, b: i32) -> i32 { - a.wrapping_add(b) -} - -#[cfg(test)] -mod tests { - use super::add; - - #[test] - fn add_sums_two_numbers() { - assert_eq!(add(2, 3), 5); - } -} +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/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 index fba0e38a..bb6cfe67 100644 --- a/packages/fishjam-core/tests/abi.rs +++ b/packages/fishjam-core/tests/abi.rs @@ -1,37 +1,193 @@ -use fishjam_core::sdk::{sdk_request, sdk_response, AddRequest, SdkRequest, SdkResponse}; +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; -#[test] -fn c_abi_add_sums_two_numbers() { - let client = unsafe { fishjam_client_new(std::ptr::null(), 0, None, std::ptr::null_mut()) }; - assert!(!client.is_null()); +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(); +} - let request = SdkRequest { - content: Some(sdk_request::Content::Add(AddRequest { a: 2, b: 3 })), - }; - let bytes = request.encode_to_vec(); +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) }; - assert_eq!(code, 0); + let response = + SdkResponse::decode(unsafe { std::slice::from_raw_parts(out.data, out.len) }).unwrap(); + unsafe { fishjam_buf_free(out) }; + (code, response) +} - let response = unsafe { - let slice = std::slice::from_raw_parts(out.data, out.len); - SdkResponse::decode(slice).expect("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, + ) }; - match response.content { - Some(sdk_response::Content::Add(add)) => assert_eq!(add.sum, 5), - other => panic!("expected add response, got {other:?}"), + 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(); +} From 51070a317b75b6ab5d9e443a034e17048f3c51ad Mon Sep 17 00:00:00 2001 From: Angelika Serwa Date: Fri, 16 Oct 2026 15:05:41 +0200 Subject: [PATCH 05/11] Add WebRTC 124.0.2.3 to android-client so it can open a peer connection. --- examples/android-client/settings.gradle.kts | 1 + packages/android-client/client/build.gradle.kts | 1 + packages/android-client/gradle/libs.versions.toml | 2 ++ packages/android-client/settings.gradle.kts | 1 + 4 files changed, 5 insertions(+) diff --git a/examples/android-client/settings.gradle.kts b/examples/android-client/settings.gradle.kts index 59fe1fa4..2634e274 100644 --- a/examples/android-client/settings.gradle.kts +++ b/examples/android-client/settings.gradle.kts @@ -11,6 +11,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = uri("https://www.jitpack.io") } } versionCatalogs { create("libs") { diff --git a/packages/android-client/client/build.gradle.kts b/packages/android-client/client/build.gradle.kts index 3c51a6c9..65425b7c 100644 --- a/packages/android-client/client/build.gradle.kts +++ b/packages/android-client/client/build.gradle.kts @@ -95,6 +95,7 @@ dependencies { implementation(libs.protobuf.kotlin.lite) implementation(libs.coroutines.android) api(libs.serialization.json) + api(libs.webrtc) testImplementation(libs.junit) testImplementation(libs.coroutines.test) diff --git a/packages/android-client/gradle/libs.versions.toml b/packages/android-client/gradle/libs.versions.toml index e5587ab6..f547a6c6 100644 --- a/packages/android-client/gradle/libs.versions.toml +++ b/packages/android-client/gradle/libs.versions.toml @@ -9,6 +9,7 @@ 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" @@ -26,6 +27,7 @@ protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin- 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" } diff --git a/packages/android-client/settings.gradle.kts b/packages/android-client/settings.gradle.kts index d4549d7f..d4af4ce7 100644 --- a/packages/android-client/settings.gradle.kts +++ b/packages/android-client/settings.gradle.kts @@ -11,6 +11,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = uri("https://www.jitpack.io") } } } From 024f137dd9ceec45f9ed73d2a90a39cdd81eb831 Mon Sep 17 00:00:00 2001 From: Angelika Serwa Date: Fri, 16 Oct 2026 15:27:03 +0200 Subject: [PATCH 06/11] Publish camera and microphone from android-client and show remote cameras. fishjam-core now drives the offer/answer exchange and room state; the Android client runs the WebRTC peer connection, renders video with VideoView, and the example shows the local camera and remote peers' cameras. Co-authored-by: Cursor --- .../java/io/fishjam/example/MainActivity.kt | 66 +- .../android-client/client/build.gradle.kts | 7 + .../client/src/main/AndroidManifest.xml | 4 + .../java/io/fishjam/client/FishjamClient.kt | 201 ++++- .../java/io/fishjam/client/MediaSession.kt | 528 ++++++++++++++ .../main/java/io/fishjam/client/RoomState.kt | 111 +++ .../main/java/io/fishjam/client/VideoView.kt | 71 ++ .../io/fishjam/client/RoomStateMappingTest.kt | 53 ++ packages/fishjam-core/Cargo.lock | 129 ++++ packages/fishjam-core/Cargo.toml | 2 + packages/fishjam-core/src/abi.rs | 46 +- packages/fishjam-core/src/client.rs | 684 +++++++++++++++++- packages/fishjam-core/src/lib.rs | 1 + packages/fishjam-core/src/negotiation.rs | 296 ++++++++ packages/fishjam-core/tests/negotiation.rs | 544 ++++++++++++++ 15 files changed, 2709 insertions(+), 34 deletions(-) create mode 100644 packages/android-client/client/src/main/java/io/fishjam/client/MediaSession.kt create mode 100644 packages/android-client/client/src/main/java/io/fishjam/client/RoomState.kt create mode 100644 packages/android-client/client/src/main/java/io/fishjam/client/VideoView.kt create mode 100644 packages/android-client/client/src/test/java/io/fishjam/client/RoomStateMappingTest.kt create mode 100644 packages/fishjam-core/src/negotiation.rs create mode 100644 packages/fishjam-core/tests/negotiation.rs 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 index 5f58a15b..5f47e37a 100644 --- 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 @@ -1,15 +1,19 @@ 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.layout.Arrangement import androidx.compose.foundation.layout.Column 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 @@ -30,10 +34,12 @@ 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.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import io.fishjam.client.FishjamClient import io.fishjam.client.PeerStatus +import io.fishjam.client.VideoView import java.net.HttpURLConnection import java.net.URL import kotlinx.coroutines.Dispatchers @@ -60,16 +66,48 @@ class MainActivity : ComponentActivity() { @Composable fun JoinScreen() { - val client = remember { FishjamClient(FISHJAM_ID) } + 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("") } + 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 = @@ -141,6 +179,32 @@ fun JoinScreen() { ) { Text("Leave") } + Button( + onClick = { cameraPermission.launch(Manifest.permission.CAMERA) }, + modifier = Modifier.fillMaxWidth().testTag("camera") + ) { + Text("Camera") + } + Button( + onClick = { microphonePermission.launch(Manifest.permission.RECORD_AUDIO) }, + modifier = Modifier.fillMaxWidth().testTag("microphone") + ) { + Text("Microphone") + } + room.localPeer?.cameraTrack?.videoTrack?.let { video -> + VideoView( + track = video, + modifier = Modifier.fillMaxWidth().height(200.dp).testTag("local-video"), + mirror = true + ) + } + room.remotePeers.forEach { peer -> + val video = peer.cameraTrack?.videoTrack ?: return@forEach + VideoView( + track = video, + modifier = Modifier.fillMaxWidth().height(200.dp).testTag("remote-video") + ) + } Text( text = notice.ifEmpty { peerStatus.name.lowercase() }, modifier = Modifier.testTag("status") diff --git a/packages/android-client/client/build.gradle.kts b/packages/android-client/client/build.gradle.kts index 65425b7c..b466e0c6 100644 --- a/packages/android-client/client/build.gradle.kts +++ b/packages/android-client/client/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.protobuf) alias(libs.plugins.ktlint) + alias(libs.plugins.kotlin.compose) } val ndkVersionName = "27.1.12297006" @@ -60,6 +61,10 @@ android { testOptions { unitTests.isReturnDefaultValues = true } + + buildFeatures { + compose = true + } } kotlin { @@ -96,6 +101,8 @@ dependencies { implementation(libs.coroutines.android) api(libs.serialization.json) api(libs.webrtc) + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) testImplementation(libs.junit) testImplementation(libs.coroutines.test) diff --git a/packages/android-client/client/src/main/AndroidManifest.xml b/packages/android-client/client/src/main/AndroidManifest.xml index 56a9e28a..ab85033f 100644 --- a/packages/android-client/client/src/main/AndroidManifest.xml +++ b/packages/android-client/client/src/main/AndroidManifest.xml @@ -2,4 +2,8 @@ + + + + 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 index b65aaa57..77fc6f6e 100644 --- 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 @@ -1,5 +1,6 @@ package io.fishjam.client +import android.content.Context import fishjam.sdk.SdkApi import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CompletableDeferred @@ -10,32 +11,41 @@ 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 update = peerStatusUpdate(SdkApi.SdkEvent.parseFrom(payload)) ?: return@NativeEvents + val event = SdkApi.SdkEvent.parseFrom(payload) scope.launch { if (closed.get()) return@launch - _peerStatus.value = update.peerStatus - val waiter = synchronized(joinLock) { joinResult } - when { - update.peerStatus == PeerStatus.Connected -> waiter?.complete(Unit) - else -> joinFailure(update)?.let { waiter?.completeExceptionally(it) } - } + dispatch(event) } } @@ -58,19 +68,19 @@ class FishjamClient( val waiter = CompletableDeferred() synchronized(joinLock) { joinResult = waiter } try { - val request = - SdkApi.SdkRequest - .newBuilder() - .setJoinRoom( - SdkApi.JoinRoom - .newBuilder() - .setPeerToken(peerToken) - .setPeerMetadataJson(peerMetadata.toString()) - ).build() - val result = NativeCore.nativeCall(handle, request.toByteArray()) - if (result.code != 0) { - val response = SdkApi.SdkResponse.parseFrom(result.payload) - throw IllegalStateException(response.error.message) + 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 { @@ -86,23 +96,168 @@ class FishjamClient( joinResult = null } if (handle == 0L || closed.get()) return - NativeCore.nativeCall( - handle, + send( SdkApi.SdkRequest .newBuilder() .setLeaveRoom(SdkApi.LeaveRoom.getDefaultInstance()) .build() - .toByteArray() ) } + 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/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..7dbb6f05 --- /dev/null +++ b/packages/android-client/client/src/main/java/io/fishjam/client/VideoView.kt @@ -0,0 +1,71 @@ +package io.fishjam.client + +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 } + +@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.setScalingType( + when (objectFit) { + ObjectFit.Contain -> RendererCommon.ScalingType.SCALE_ASPECT_FIT + ObjectFit.Cover -> RendererCommon.ScalingType.SCALE_ASPECT_FILL + } + ) + view.setTrack(track) + }, + onRelease = { view -> view.releaseView() } + ) +} + +private class FishjamVideoView(context: android.content.Context) : SurfaceViewRenderer(context) { + private var current: VideoTrack? = null + + init { + init(SharedEgl.base.eglBaseContext, null) + setEnableHardwareScaler(true) + } + + fun setTrack(track: VideoTrack) { + if (current === track) return + removeSinkQuietly(current) + current = track + try { + track.addSink(this) + } catch (error: IllegalStateException) { + current = null + } + } + + fun releaseView() { + removeSinkQuietly(current) + current = null + release() + } + + private fun removeSinkQuietly(track: VideoTrack?) { + if (track == null) return + try { + track.removeSink(this) + } catch (error: IllegalStateException) { + Unit + } + } +} 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/fishjam-core/Cargo.lock b/packages/fishjam-core/Cargo.lock index 4f2fdf50..d6945b1c 100644 --- a/packages/fishjam-core/Cargo.lock +++ b/packages/fishjam-core/Cargo.lock @@ -32,6 +32,12 @@ 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" @@ -133,8 +139,10 @@ dependencies = [ "prost-build", "protoc-bin-vendored", "rustls", + "serde_json", "tokio", "tokio-tungstenite", + "uuid", ] [[package]] @@ -271,6 +279,17 @@ 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" @@ -618,6 +637,54 @@ dependencies = [ "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" @@ -807,6 +874,17 @@ 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" @@ -828,6 +906,51 @@ 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" @@ -965,3 +1088,9 @@ 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 index ae608faa..fc1255d7 100644 --- a/packages/fishjam-core/Cargo.toml +++ b/packages/fishjam-core/Cargo.toml @@ -15,8 +15,10 @@ futures-util = { version = "0.3", default-features = false, features = ["sink", 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" diff --git a/packages/fishjam-core/src/abi.rs b/packages/fishjam-core/src/abi.rs index cc72359b..fe7f5e12 100644 --- a/packages/fishjam-core/src/abi.rs +++ b/packages/fishjam-core/src/abi.rs @@ -103,7 +103,51 @@ pub unsafe extern "C" fn fishjam_client_call( client.leave_room(); Ok(empty()) } - Some(_) | None => Err(error_message("unsupported request")), + 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 { diff --git a/packages/fishjam-core/src/client.rs b/packages/fishjam-core/src/client.rs index 47dcbed4..4dc9f648 100644 --- a/packages/fishjam-core/src/client.rs +++ b/packages/fishjam-core/src/client.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::{Arc, Mutex, MutexGuard, Weak}; use prost::Message; @@ -5,7 +6,14 @@ 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::sdk::{sdk_event, ClientConfig, Error, PeerStatus, PeerStatusChanged, SdkEvent}; +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; @@ -38,7 +46,19 @@ struct State { 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 { @@ -63,7 +83,19 @@ impl FishjamClient { 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), @@ -104,6 +136,93 @@ impl FishjamClient { 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 { @@ -222,7 +341,11 @@ impl State { } self.socket_open = false; self.generation += 1; - self.local_endpoint_id = None; + 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); } @@ -285,18 +408,94 @@ impl State { 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.local_endpoint_id = Some(connected.endpoint_id); - self.set_status(PeerStatus::Connected, None, events); + 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::Error(error) => { + 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 { - message: error.message, - ..Default::default() - }), + Some(error(&server_error.message)), events, ); } @@ -328,6 +527,473 @@ impl State { ..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 index de6c56d8..62f791ac 100644 --- a/packages/fishjam-core/src/lib.rs +++ b/packages/fishjam-core/src/lib.rs @@ -6,6 +6,7 @@ mod proto { mod abi; mod auth; mod client; +mod negotiation; mod signaling; mod 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/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()); +} From 6b4676ba8fce82506ac394d50f4c12e9b558b8cb Mon Sep 17 00:00:00 2001 From: Angelika Serwa Date: Fri, 16 Oct 2026 15:40:00 +0200 Subject: [PATCH 07/11] Add FishjamWebRTC 124.0.2.3 to ios-client so it can open a peer connection. --- examples/ios-client/project.yml | 2 ++ packages/ios-client/.gitignore | 2 ++ packages/ios-client/.swiftlint.yml | 1 + packages/ios-client/Podfile | 20 +++++++++++++++ packages/ios-client/Podfile.lock | 16 ++++++++++++ packages/ios-client/package.json | 2 +- packages/ios-client/project.yml | 4 +++ packages/ios-client/scripts/fetch-webrtc.sh | 28 +++++++++++++++++++++ 8 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 packages/ios-client/Podfile create mode 100644 packages/ios-client/Podfile.lock create mode 100644 packages/ios-client/scripts/fetch-webrtc.sh diff --git a/examples/ios-client/project.yml b/examples/ios-client/project.yml index 2753bf21..b7a945e7 100644 --- a/examples/ios-client/project.yml +++ b/examples/ios-client/project.yml @@ -22,6 +22,8 @@ targets: 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 diff --git a/packages/ios-client/.gitignore b/packages/ios-client/.gitignore index b9d57e7d..bbdb3951 100644 --- a/packages/ios-client/.gitignore +++ b/packages/ios-client/.gitignore @@ -1,7 +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 index 1c50d980..12b75a98 100644 --- a/packages/ios-client/.swiftlint.yml +++ b/packages/ios-client/.swiftlint.yml @@ -6,6 +6,7 @@ excluded: - build - Core - Vendor + - Pods strict: true line_length: warning: 120 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 index c8efd08b..45196e1b 100644 --- a/packages/ios-client/package.json +++ b/packages/ios-client/package.json @@ -5,7 +5,7 @@ "license": "Apache-2.0", "description": "Fishjam client SDK for native iOS", "scripts": { - "prepare:project": "./scripts/build-core.sh && ./scripts/generate-proto.sh && xcodegen generate --quiet", + "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", diff --git a/packages/ios-client/project.yml b/packages/ios-client/project.yml index 7e3cd267..15c6672a 100644 --- a/packages/ios-client/project.yml +++ b/packages/ios-client/project.yml @@ -25,6 +25,8 @@ targets: product: SwiftProtobuf - framework: Core/FishjamCore.xcframework embed: false + - framework: Vendor/WebRTC.xcframework + embed: false settings: DEFINES_MODULE: YES GENERATE_INFOPLIST_FILE: YES @@ -37,6 +39,8 @@ targets: - FishjamClientTests dependencies: - target: FishjamClient + - framework: Vendor/WebRTC.xcframework + embed: true settings: GENERATE_INFOPLIST_FILE: YES PRODUCT_BUNDLE_IDENTIFIER: io.fishjam.FishjamClientTests 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" From 0aa0848256db9a32fb312380b2341f057cd5ef53 Mon Sep 17 00:00:00 2001 From: Angelika Serwa Date: Fri, 16 Oct 2026 15:57:07 +0200 Subject: [PATCH 08/11] Publish camera and microphone from ios-client and show remote cameras. Join and leave land here because the iOS auth step had no commit of its own. Co-authored-by: Cursor --- examples/ios-client/.gitignore | 1 + examples/ios-client/.swiftlint.yml | 1 + .../FishjamExample/FishjamExampleApp.swift | 164 ++++++++-- examples/ios-client/package.json | 2 +- examples/ios-client/project.yml | 2 + .../ios-client/scripts/generate-config.sh | 45 +++ .../FishjamClient/FishjamClient.swift | 284 ++++++++++++++++++ .../FishjamClient/FishjamCore.swift | 35 --- .../FishjamClient/Generated/sdk_api.pb.swift | 147 +-------- .../FishjamClient/MediaSession+Capture.swift | 57 ++++ .../MediaSession+Negotiation.swift | 215 +++++++++++++ .../FishjamClient/MediaSession.swift | 269 +++++++++++++++++ .../FishjamClient/PeerStatusMapping.swift | 59 ++++ .../ios-client/FishjamClient/RoomState.swift | 150 +++++++++ .../ios-client/FishjamClient/VideoView.swift | 64 ++++ .../FishjamClientTests/FishjamCoreTests.swift | 9 - .../PeerStatusMappingTests.swift | 55 ++++ .../RoomStateMappingTests.swift | 42 +++ packages/ios-client/project.yml | 3 + 19 files changed, 1393 insertions(+), 211 deletions(-) create mode 100644 examples/ios-client/scripts/generate-config.sh create mode 100644 packages/ios-client/FishjamClient/FishjamClient.swift delete mode 100644 packages/ios-client/FishjamClient/FishjamCore.swift create mode 100644 packages/ios-client/FishjamClient/MediaSession+Capture.swift create mode 100644 packages/ios-client/FishjamClient/MediaSession+Negotiation.swift create mode 100644 packages/ios-client/FishjamClient/MediaSession.swift create mode 100644 packages/ios-client/FishjamClient/PeerStatusMapping.swift create mode 100644 packages/ios-client/FishjamClient/RoomState.swift create mode 100644 packages/ios-client/FishjamClient/VideoView.swift delete mode 100644 packages/ios-client/FishjamClientTests/FishjamCoreTests.swift create mode 100644 packages/ios-client/FishjamClientTests/PeerStatusMappingTests.swift create mode 100644 packages/ios-client/FishjamClientTests/RoomStateMappingTests.swift diff --git a/examples/ios-client/.gitignore b/examples/ios-client/.gitignore index 74d52379..f7055894 100644 --- a/examples/ios-client/.gitignore +++ b/examples/ios-client/.gitignore @@ -1,5 +1,6 @@ build/ DerivedData/ +FishjamExample/FishjamConfig.swift *.xcodeproj/ *.xcuserstate xcuserdata/ diff --git a/examples/ios-client/.swiftlint.yml b/examples/ios-client/.swiftlint.yml index e6b91af6..bf519560 100644 --- a/examples/ios-client/.swiftlint.yml +++ b/examples/ios-client/.swiftlint.yml @@ -2,6 +2,7 @@ included: - FishjamExample excluded: - build + - FishjamExample/FishjamConfig.swift strict: true line_length: warning: 120 diff --git a/examples/ios-client/FishjamExample/FishjamExampleApp.swift b/examples/ios-client/FishjamExample/FishjamExampleApp.swift index a9ac8586..8b404bcc 100644 --- a/examples/ios-client/FishjamExample/FishjamExampleApp.swift +++ b/examples/ios-client/FishjamExample/FishjamExampleApp.swift @@ -1,49 +1,171 @@ +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 { - AddView() + JoinView() } } } -struct AddView: View { - @State private var first = "2" - @State private var second = "3" - @State private var result = "" +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 = "" var body: some View { ScrollView { VStack(alignment: .leading, spacing: 12) { Text("Fishjam example") .font(.title2) - TextField("First number", text: $first) - .keyboardType(.numbersAndPunctuation) + TextField("Room name", text: $roomName) .textFieldStyle(.roundedBorder) - .accessibilityIdentifier("first") - TextField("Second number", text: $second) - .keyboardType(.numbersAndPunctuation) + .accessibilityIdentifier("room") + TextField("Peer name", text: $peerName) .textFieldStyle(.roundedBorder) - .accessibilityIdentifier("second") - Button("Add") { - let trimmedFirst = first.trimmingCharacters(in: .whitespaces) - let trimmedSecond = second.trimmingCharacters(in: .whitespaces) - guard let lhs = Int32(trimmedFirst), let rhs = Int32(trimmedSecond) else { - result = "Enter two whole numbers" - return + .accessibilityIdentifier("peer") + Button("Fetch token") { + notice = "" + Task { + do { + token = try await fetchPeerToken(roomName: roomName, peerName: peerName) + } catch { + notice = error.localizedDescription + } + } + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + .disabled(roomName.isEmpty || peerName.isEmpty) + .accessibilityIdentifier("fetch") + TextField("Token", text: $token) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("token") + Button("Join") { + notice = "" + let peerToken = token.trimmingCharacters(in: .whitespacesAndNewlines) + Task { + do { + try await client.joinRoom(peerToken: peerToken) + } catch is CancellationError { + return + } catch { + notice = error.localizedDescription + } + } + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + .disabled(token.isEmpty || client.peerStatus == .connected) + .accessibilityIdentifier("join") + Button("Leave") { + notice = "" + client.leaveRoom() + } + .buttonStyle(.bordered) + .frame(maxWidth: .infinity) + .disabled(client.peerStatus == .idle) + .accessibilityIdentifier("leave") + Button("Camera") { + notice = "" + Task { + await startCamera(client: client, notice: $notice) } - result = "Result: \(FishjamCoreBridge.add(a: lhs, b: rhs))" } .buttonStyle(.borderedProminent) .frame(maxWidth: .infinity) - .accessibilityIdentifier("add") - Text(result) - .accessibilityIdentifier("result") + .accessibilityIdentifier("camera") + Button("Microphone") { + notice = "" + Task { + await startMicrophone(client: client, notice: $notice) + } + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("microphone") + if let video = client.roomState.localPeer?.cameraTrack?.videoTrack { + VideoView(track: video, mirror: true) + .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) + .frame(maxWidth: .infinity) + .frame(height: 200) + .background(Color.black) + .accessibilityIdentifier("remote-video") + } + } + Text(notice.isEmpty ? client.peerStatus.rawValue : notice) + .accessibilityIdentifier("status") } .padding(16) } } } + +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 index baff29d0..6fbf6287 100644 --- a/examples/ios-client/package.json +++ b/examples/ios-client/package.json @@ -4,7 +4,7 @@ "private": true, "license": "Apache-2.0", "scripts": { - "prepare:project": "yarn workspace @fishjam-cloud/ios-client prepare:project && xcodegen generate --quiet", + "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", diff --git a/examples/ios-client/project.yml b/examples/ios-client/project.yml index b7a945e7..04dfb388 100644 --- a/examples/ios-client/project.yml +++ b/examples/ios-client/project.yml @@ -28,6 +28,8 @@ targets: 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 TARGETED_DEVICE_FAMILY: "1,2" schemes: 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" <?, + _ 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/FishjamCore.swift b/packages/ios-client/FishjamClient/FishjamCore.swift deleted file mode 100644 index bb97bbff..00000000 --- a/packages/ios-client/FishjamClient/FishjamCore.swift +++ /dev/null @@ -1,35 +0,0 @@ -import FishjamCore -import Foundation - -/// Temporary check that the Swift code reaches fishjam-core through the C ABI. -public enum FishjamCoreBridge { - public static func add(a: Int32, b: Int32) -> Int32 { - guard let client = fishjam_client_new(nil, 0, nil, nil) else { - fatalError("fishjam_client_new returned null") - } - defer { fishjam_client_free(client) } - - let request = Fishjam_Sdk_SdkRequest.with { - $0.add = Fishjam_Sdk_AddRequest.with { - $0.a = a - $0.b = b - } - } - // swiftlint:disable:next force_try - let bytes = try! request.serializedData() - - var out = FishjamBuf(data: nil, len: 0) - let code = bytes.withUnsafeBytes { raw in - fishjam_client_call(client, raw.bindMemory(to: UInt8.self).baseAddress, bytes.count, &out) - } - defer { fishjam_buf_free(out) } - - let payload = out.data.map { Data(bytes: $0, count: out.len) } ?? Data() - guard code == 0, let response = try? Fishjam_Sdk_SdkResponse(serializedBytes: payload), - case let .add(result)? = response.content - else { - fatalError("fishjam_client_call failed with code \(code)") - } - return result.sum - } -} diff --git a/packages/ios-client/FishjamClient/Generated/sdk_api.pb.swift b/packages/ios-client/FishjamClient/Generated/sdk_api.pb.swift index 1d647d18..c2400fa1 100644 --- a/packages/ios-client/FishjamClient/Generated/sdk_api.pb.swift +++ b/packages/ios-client/FishjamClient/Generated/sdk_api.pb.swift @@ -304,14 +304,6 @@ nonisolated struct Fishjam_Sdk_SdkRequest: Sendable { var content: Fishjam_Sdk_SdkRequest.OneOf_Content? = nil - var add: Fishjam_Sdk_AddRequest { - get { - if case .add(let v)? = content {return v} - return Fishjam_Sdk_AddRequest() - } - set {content = .add(newValue)} - } - var joinRoom: Fishjam_Sdk_JoinRoom { get { if case .joinRoom(let v)? = content {return v} @@ -403,7 +395,6 @@ nonisolated struct Fishjam_Sdk_SdkRequest: Sendable { var unknownFields = SwiftProtobuf.UnknownStorage() nonisolated enum OneOf_Content: Equatable, Sendable { - case add(Fishjam_Sdk_AddRequest) case joinRoom(Fishjam_Sdk_JoinRoom) case leaveRoom(Fishjam_Sdk_LeaveRoom) case addTrack(Fishjam_Sdk_AddTrack) @@ -437,14 +428,6 @@ nonisolated struct Fishjam_Sdk_SdkResponse: Sendable { set {content = .error(newValue)} } - var add: Fishjam_Sdk_AddResponse { - get { - if case .add(let v)? = content {return v} - return Fishjam_Sdk_AddResponse() - } - set {content = .add(newValue)} - } - var empty: Fishjam_Sdk_Empty { get { if case .empty(let v)? = content {return v} @@ -473,7 +456,6 @@ nonisolated struct Fishjam_Sdk_SdkResponse: Sendable { nonisolated enum OneOf_Content: Equatable, Sendable { case error(Fishjam_Sdk_Error) - case add(Fishjam_Sdk_AddResponse) case empty(Fishjam_Sdk_Empty) case addTrack(Fishjam_Sdk_AddTrackResult) case roomState(Fishjam_Sdk_RoomState) @@ -623,32 +605,6 @@ nonisolated struct Fishjam_Sdk_LeaveRoom: Sendable { init() {} } -nonisolated struct Fishjam_Sdk_AddRequest: 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 a: Int32 = 0 - - var b: Int32 = 0 - - var unknownFields = SwiftProtobuf.UnknownStorage() - - init() {} -} - -nonisolated struct Fishjam_Sdk_AddResponse: 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 sum: Int32 = 0 - - 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 @@ -1253,7 +1209,7 @@ nonisolated extension Fishjam_Sdk_ClientConfig: SwiftProtobuf.Message, SwiftProt 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{1}add\0\u{3}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") + 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() { @@ -1261,19 +1217,6 @@ nonisolated extension Fishjam_Sdk_SdkRequest: SwiftProtobuf.Message, SwiftProtob // 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_AddRequest? - var hadOneofValue = false - if let current = self.content { - hadOneofValue = true - if case .add(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.content = .add(v) - } - }() case 2: try { var v: Fishjam_Sdk_JoinRoom? var hadOneofValue = false @@ -1428,10 +1371,6 @@ nonisolated extension Fishjam_Sdk_SdkRequest: SwiftProtobuf.Message, SwiftProtob // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and // https://github.com/apple/swift-protobuf/issues/1182 switch self.content { - case .add?: try { - guard case .add(let v)? = self.content else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 1) - }() case .joinRoom?: try { guard case .joinRoom(let v)? = self.content else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 2) @@ -1490,7 +1429,7 @@ nonisolated extension Fishjam_Sdk_SdkRequest: SwiftProtobuf.Message, SwiftProtob 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{1}add\0\u{1}empty\0\u{3}add_track\0\u{3}room_state\0") + 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() { @@ -1511,19 +1450,6 @@ nonisolated extension Fishjam_Sdk_SdkResponse: SwiftProtobuf.Message, SwiftProto self.content = .error(v) } }() - case 2: try { - var v: Fishjam_Sdk_AddResponse? - var hadOneofValue = false - if let current = self.content { - hadOneofValue = true - if case .add(let m) = current {v = m} - } - try decoder.decodeSingularMessageField(value: &v) - if let v = v { - if hadOneofValue {try decoder.handleConflictingOneOf()} - self.content = .add(v) - } - }() case 3: try { var v: Fishjam_Sdk_Empty? var hadOneofValue = false @@ -1578,10 +1504,6 @@ nonisolated extension Fishjam_Sdk_SdkResponse: SwiftProtobuf.Message, SwiftProto guard case .error(let v)? = self.content else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 1) }() - case .add?: try { - guard case .add(let v)? = self.content else { preconditionFailure() } - try visitor.visitSingularMessageField(value: v, fieldNumber: 2) - }() case .empty?: try { guard case .empty(let v)? = self.content else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 3) @@ -1880,71 +1802,6 @@ nonisolated extension Fishjam_Sdk_LeaveRoom: SwiftProtobuf.Message, SwiftProtobu } } -nonisolated extension Fishjam_Sdk_AddRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - static let protoMessageName: String = _protobuf_package + ".AddRequest" - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}a\0\u{1}b\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.a) }() - case 2: try { try decoder.decodeSingularInt32Field(value: &self.b) }() - default: break - } - } - } - - func traverse(visitor: inout V) throws { - if self.a != 0 { - try visitor.visitSingularInt32Field(value: self.a, fieldNumber: 1) - } - if self.b != 0 { - try visitor.visitSingularInt32Field(value: self.b, fieldNumber: 2) - } - try unknownFields.traverse(visitor: &visitor) - } - - static func ==(lhs: Fishjam_Sdk_AddRequest, rhs: Fishjam_Sdk_AddRequest) -> Bool { - if lhs.a != rhs.a {return false} - if lhs.b != rhs.b {return false} - if lhs.unknownFields != rhs.unknownFields {return false} - return true - } -} - -nonisolated extension Fishjam_Sdk_AddResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - static let protoMessageName: String = _protobuf_package + ".AddResponse" - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}sum\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.sum) }() - default: break - } - } - } - - func traverse(visitor: inout V) throws { - if self.sum != 0 { - try visitor.visitSingularInt32Field(value: self.sum, fieldNumber: 1) - } - try unknownFields.traverse(visitor: &visitor) - } - - static func ==(lhs: Fishjam_Sdk_AddResponse, rhs: Fishjam_Sdk_AddResponse) -> Bool { - if lhs.sum != rhs.sum {return false} - 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() 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/FishjamCoreTests.swift b/packages/ios-client/FishjamClientTests/FishjamCoreTests.swift deleted file mode 100644 index ad96cbc3..00000000 --- a/packages/ios-client/FishjamClientTests/FishjamCoreTests.swift +++ /dev/null @@ -1,9 +0,0 @@ -@testable import FishjamClient -import XCTest - -final class FishjamCoreTests: XCTestCase { - func testAddCallsFishjamCore() { - XCTAssertEqual(FishjamCoreBridge.add(a: 2, b: 3), 5) - XCTAssertEqual(FishjamCoreBridge.add(a: 2, b: -3), -1) - } -} 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/project.yml b/packages/ios-client/project.yml index 15c6672a..45fb25ae 100644 --- a/packages/ios-client/project.yml +++ b/packages/ios-client/project.yml @@ -9,6 +9,9 @@ settings: 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: From c478a83779127799565844ff85fddd6f167cd470 Mon Sep 17 00:00:00 2001 From: Angelika Serwa Date: Fri, 16 Oct 2026 16:03:01 +0200 Subject: [PATCH 09/11] Let the Android example switch mirror, contain, and cover. Contain fits the camera frame inside the view, with black bars, instead of cropping it. --- .../java/io/fishjam/example/MainActivity.kt | 173 ++++++++++++------ .../main/java/io/fishjam/client/VideoView.kt | 99 ++++++++-- .../java/io/fishjam/client/VideoLayoutTest.kt | 30 +++ 3 files changed, 238 insertions(+), 64 deletions(-) create mode 100644 packages/android-client/client/src/test/java/io/fishjam/client/VideoLayoutTest.kt 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 index 5f47e37a..e95f7c26 100644 --- 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 @@ -8,8 +8,13 @@ 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 @@ -18,6 +23,7 @@ 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 @@ -34,10 +40,16 @@ 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 @@ -49,6 +61,11 @@ 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?) { @@ -64,6 +81,7 @@ class MainActivity : ComponentActivity() { } } +@OptIn(ExperimentalLayoutApi::class) @Composable fun JoinScreen() { val context = LocalContext.current @@ -78,6 +96,11 @@ fun JoinScreen() { 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) { @@ -114,6 +137,16 @@ fun JoinScreen() { 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) @@ -123,86 +156,122 @@ fun JoinScreen() { 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") ) - 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.fillMaxWidth().testTag("fetch") + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text("Fetch token") + 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") ) - 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.fillMaxWidth().testTag("join") - ) { - Text("Join") - } - Button( - onClick = { - notice = "" - client.leaveRoom() - }, - enabled = peerStatus != PeerStatus.Idle, - modifier = Modifier.fillMaxWidth().testTag("leave") - ) { - Text("Leave") - } - Button( - onClick = { cameraPermission.launch(Manifest.permission.CAMERA) }, - modifier = Modifier.fillMaxWidth().testTag("camera") - ) { - Text("Camera") - } - Button( - onClick = { microphonePermission.launch(Manifest.permission.RECORD_AUDIO) }, - modifier = Modifier.fillMaxWidth().testTag("microphone") + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text("Microphone") + 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 = true + 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") + modifier = Modifier.fillMaxWidth().height(200.dp).testTag("remote-video"), + mirror = mirrored, + objectFit = objectFit ) } Text( 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 index 7dbb6f05..57637df0 100644 --- 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 @@ -1,5 +1,7 @@ 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 @@ -9,6 +11,41 @@ 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, @@ -23,24 +60,38 @@ fun VideoView( modifier = modifier, update = { view -> view.setMirror(mirror) - view.setScalingType( - when (objectFit) { - ObjectFit.Contain -> RendererCommon.ScalingType.SCALE_ASPECT_FIT - ObjectFit.Cover -> RendererCommon.ScalingType.SCALE_ASPECT_FILL - } - ) + view.setObjectFit(objectFit) view.setTrack(track) }, onRelease = { view -> view.releaseView() } ) } -private class FishjamVideoView(context: android.content.Context) : SurfaceViewRenderer(context) { +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 { - init(SharedEgl.base.eglBaseContext, null) - setEnableHardwareScaler(true) + 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) { @@ -48,7 +99,7 @@ private class FishjamVideoView(context: android.content.Context) : SurfaceViewRe removeSinkQuietly(current) current = track try { - track.addSink(this) + track.addSink(renderer) } catch (error: IllegalStateException) { current = null } @@ -57,15 +108,39 @@ private class FishjamVideoView(context: android.content.Context) : SurfaceViewRe fun releaseView() { removeSinkQuietly(current) current = null - release() + renderer.release() } private fun removeSinkQuietly(track: VideoTrack?) { if (track == null) return try { - track.removeSink(this) + 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/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) + ) + } +} From 4d8d59a54a7724c6e5faa91f7c474fd2f749f547 Mon Sep 17 00:00:00 2001 From: Angelika Serwa Date: Fri, 16 Oct 2026 16:05:13 +0200 Subject: [PATCH 10/11] Let the iOS example switch mirror, contain, and cover. The remote camera can fit inside the view or fill it, and flip left to right. Co-authored-by: Cursor --- .../FishjamExample/FishjamExampleApp.swift | 369 ++++++++++++++---- examples/ios-client/project.yml | 5 + 2 files changed, 303 insertions(+), 71 deletions(-) diff --git a/examples/ios-client/FishjamExample/FishjamExampleApp.swift b/examples/ios-client/FishjamExample/FishjamExampleApp.swift index 8b404bcc..0fc14339 100644 --- a/examples/ios-client/FishjamExample/FishjamExampleApp.swift +++ b/examples/ios-client/FishjamExample/FishjamExampleApp.swift @@ -20,80 +20,27 @@ struct JoinView: View { @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 { - ScrollView { - VStack(alignment: .leading, spacing: 12) { - Text("Fishjam example") - .font(.title2) - TextField("Room name", text: $roomName) - .textFieldStyle(.roundedBorder) - .accessibilityIdentifier("room") - TextField("Peer name", text: $peerName) - .textFieldStyle(.roundedBorder) - .accessibilityIdentifier("peer") - Button("Fetch token") { - notice = "" - Task { - do { - token = try await fetchPeerToken(roomName: roomName, peerName: peerName) - } catch { - notice = error.localizedDescription - } - } - } - .buttonStyle(.borderedProminent) - .frame(maxWidth: .infinity) - .disabled(roomName.isEmpty || peerName.isEmpty) - .accessibilityIdentifier("fetch") + 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) - .textFieldStyle(.roundedBorder) - .accessibilityIdentifier("token") - Button("Join") { - notice = "" - let peerToken = token.trimmingCharacters(in: .whitespacesAndNewlines) - Task { - do { - try await client.joinRoom(peerToken: peerToken) - } catch is CancellationError { - return - } catch { - notice = error.localizedDescription - } - } - } - .buttonStyle(.borderedProminent) - .frame(maxWidth: .infinity) - .disabled(token.isEmpty || client.peerStatus == .connected) - .accessibilityIdentifier("join") - Button("Leave") { - notice = "" - client.leaveRoom() - } - .buttonStyle(.bordered) - .frame(maxWidth: .infinity) - .disabled(client.peerStatus == .idle) - .accessibilityIdentifier("leave") - Button("Camera") { - notice = "" - Task { - await startCamera(client: client, notice: $notice) - } - } - .buttonStyle(.borderedProminent) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("camera") - Button("Microphone") { - notice = "" - Task { - await startMicrophone(client: client, notice: $notice) - } - } - .buttonStyle(.borderedProminent) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("microphone") + .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: true) + VideoView(track: video, mirror: !mirrored, objectFit: objectFit) .frame(maxWidth: .infinity) .frame(height: 200) .background(Color.black) @@ -101,7 +48,7 @@ struct JoinView: View { } ForEach(client.roomState.remotePeers, id: \.id) { peer in if let video = peer.cameraTrack?.videoTrack { - VideoView(track: video) + VideoView(track: video, mirror: mirrored, objectFit: objectFit) .frame(maxWidth: .infinity) .frame(height: 200) .background(Color.black) @@ -112,7 +59,287 @@ struct JoinView: View { .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) } } diff --git a/examples/ios-client/project.yml b/examples/ios-client/project.yml index 04dfb388..a289fac2 100644 --- a/examples/ios-client/project.yml +++ b/examples/ios-client/project.yml @@ -9,6 +9,9 @@ settings: 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: @@ -31,6 +34,8 @@ targets: 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: From c7cd6a1a45d00e5b4dac39a8fe746cb012e5b080 Mon Sep 17 00:00:00 2001 From: Angelika Serwa Date: Mon, 19 Oct 2026 18:00:34 +0200 Subject: [PATCH 11/11] Run native Android and iOS builds and tests on CI. Co-authored-by: Cursor --- .github/workflows/mobile_eas_build.yaml | 2 +- .github/workflows/node.yaml | 133 +++++++++++++++++++++++- .github/workflows/release.yaml | 12 +-- .github/workflows/tests.yaml | 4 +- package.json | 4 + 5 files changed, 142 insertions(+), 13 deletions(-) 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/package.json b/package.json index 3c479182..34edf9de 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,10 @@ "format:check": "yarn format:root:check & yarn workspaces foreach -A -p run format:check", "lint": "yarn workspaces foreach -A -p run lint || echo 'โŒ Lint errors! โŒ'", "lint:check": "yarn workspaces foreach -A -p run lint:check", + "build:non-native": "yarn workspaces foreach -Ap --topological-dev --exclude '@fishjam-{cloud,example}/{android,ios}-client' run build", + "test:unit:non-native": "yarn workspaces foreach -Ap --exclude '@fishjam-{cloud,example}/{android,ios}-client' run test", + "format:check:non-native": "yarn format:root:check & yarn workspaces foreach -A -p --exclude '@fishjam-{cloud,example}/{android,ios}-client' run format:check", + "lint:check:non-native": "yarn workspaces foreach -A -p --exclude '@fishjam-{cloud,example}/{android,ios}-client' run lint:check", "docs": "typedoc", "spellcheck": "cspell '**/*.md' '**/*.mdx' '**/*.html' --exclude '**/api/**' --show-suggestions", "spellcheck:report": "yarn spellcheck --no-show-suggestions",