From d7b88874df1e457cf26353f8ce179bf625686367 Mon Sep 17 00:00:00 2001 From: restitux Date: Mon, 19 Jan 2026 01:03:45 +0000 Subject: [PATCH] android builds (#9) This adds android builds to the CI infrastructure. These builds generate an `apk` file that you can download and install. - Adds a new container build job that builds a container with all the required android dependencies - Adds a new release build that builds an android apk - Updated the imp module to split out mobile and desktop behavior - Adds logic to request microphone permissions - Added a custom android manifest that declares the required permissions Reviewed-on: https://git.ohea.xyz/mumble/mumble-web2/pulls/9 --- .../workflows/android-container-builds.yaml | 27 ++ .gitea/workflows/build-release.yaml | 23 ++ Cargo.lock | 44 +++ docker/android-release-builder.Dockerfile | 43 +++ gui/Cargo.toml | 25 +- gui/Dioxus.toml | 4 +- gui/build/AndroidManifest.xml | 32 ++ gui/src/app.rs | 5 + gui/src/imp/connect.rs | 110 ++++++ gui/src/imp/desktop.rs | 318 +----------------- gui/src/imp/mobile.rs | 67 ++++ gui/src/imp/mod.rs | 24 +- gui/src/imp/native_audio.rs | 209 ++++++++++++ gui/src/lib.rs | 3 - 14 files changed, 611 insertions(+), 323 deletions(-) create mode 100644 .gitea/workflows/android-container-builds.yaml create mode 100644 docker/android-release-builder.Dockerfile create mode 100644 gui/build/AndroidManifest.xml create mode 100644 gui/src/imp/connect.rs create mode 100644 gui/src/imp/mobile.rs create mode 100644 gui/src/imp/native_audio.rs diff --git a/.gitea/workflows/android-container-builds.yaml b/.gitea/workflows/android-container-builds.yaml new file mode 100644 index 0000000..f63d29c --- /dev/null +++ b/.gitea/workflows/android-container-builds.yaml @@ -0,0 +1,27 @@ +name: Build android container +on: + workflow_dispatch: + schedule: + - cron: "0 4 * * *" + +jobs: + android-release-builder-container-build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Log in to container registry + uses: docker/login-action@v3 + with: + registry: git.ohea.xyz + username: ${{ secrets.CI_REGISTRY_USER }} + password: ${{ secrets.CI_REGISTRY_PASSWORD }} + + - name: Build Android builder image + shell: bash + run: | + docker pull "$(grep -m1 '^FROM' ./docker/android-release-builder.Dockerfile | awk '{print $2}')" + docker build -t git.ohea.xyz/mumble/mumble-web2/android-release-builder:latest -f ./docker/android-release-builder.Dockerfile . + docker push git.ohea.xyz/mumble/mumble-web2/android-release-builder:latest diff --git a/.gitea/workflows/build-release.yaml b/.gitea/workflows/build-release.yaml index 67027bf..0d84348 100644 --- a/.gitea/workflows/build-release.yaml +++ b/.gitea/workflows/build-release.yaml @@ -83,3 +83,26 @@ jobs: name: mumble-web2-gui-windows path: gui/dist retention-days: 5 + + android_build: + runs-on: ubuntu-latest + container: + image: git.ohea.xyz/mumble/mumble-web2/android-release-builder:latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - uses: Swatinem/rust-cache@v2 + + - name: Build dioxus project (x86_64-linux-android) + run: dx build --platform android --target x86_64-linux-android --release -p mumble-web2-gui + + - name: Build dioxus project (aarch64-linux-android) + run: dx build --platform android --target aarch64-linux-android --release -p mumble-web2-gui + + - name: Upload mumble-web2-gui Android Artifact + uses: https://gitea.com/actions/gitea-upload-artifact@v4 + with: + name: mumble-web2-android + path: target/dx/mumble-web2-gui/release/android/app/app/build/outputs/apk/debug/app-debug.apk + retention-days: 5 diff --git a/Cargo.lock b/Cargo.lock index 3b6252c..1a6f340 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,6 +127,37 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "android-permissions" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b4eabf57cddc2cd9934aabb1d17f3c9f1f1cfcac48746944fcff76ed0f62bb" +dependencies = [ + "android_logger", + "jni", + "log", + "thiserror 1.0.69", + "walkdir", +] + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c494134f746c14dc653a35a4ea5aca24ac368529da5370ecf41fe0341c35772f" +dependencies = [ + "android_log-sys", + "env_logger", + "log", + "once_cell", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -2244,6 +2275,16 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "log", + "regex", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -4268,6 +4309,7 @@ dependencies = [ name = "mumble-web2-gui" version = "0.1.0" dependencies = [ + "android-permissions", "async_cell", "asynchronous-codec", "base64", @@ -4286,6 +4328,7 @@ dependencies = [ "futures-channel", "gloo-timers", "html-purifier", + "jni", "js-sys", "lol_html 2.7.0", "markdown", @@ -4293,6 +4336,7 @@ dependencies = [ "mime_guess", "mumble-protocol-2x", "mumble-web2-common", + "ndk-context", "ogg 0.9.2", "once_cell", "opus", diff --git a/docker/android-release-builder.Dockerfile b/docker/android-release-builder.Dockerfile new file mode 100644 index 0000000..2839171 --- /dev/null +++ b/docker/android-release-builder.Dockerfile @@ -0,0 +1,43 @@ +FROM rust:trixie + +ARG ANDROID_CLI_TOOLS_VERSION=13114758 + +# Install android rust toolchains +RUN rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android + +# Install debian dependencies +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + nodejs \ + ca-certificates \ + curl \ + unzip \ + default-jdk + +# Install android commandline tools (required to install the sdk) +RUN cd /tmp && \ + curl -o commandlinetools-linux.zip "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CLI_TOOLS_VERSION}_latest.zip" && \ + unzip commandlinetools-linux.zip && \ + mkdir -p /opt/android-tools/cmdline-tools && \ + cp -r cmdline-tools /opt/android-tools/cmdline-tools/latest + + +# Install required android tools +RUN yes | /opt/android-tools/cmdline-tools/latest/bin/sdkmanager --install "platform-tools" "platforms;android-36.1" "build-tools;36.1.0" "ndk;29.0.14206865" "cmake;3.31.6" + +# Install cargo binstall +RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash + +# Install dioxus-cli +RUN cargo binstall dioxus-cli@0.7.2 + +# Install bindgen-cli +RUN cargo binstall bindgen-cli + +# Set required env vars +ENV ANDROID_HOME="/opt/android-tools/" +ENV NDK_HOME="$ANDROID_HOME/ndk/29.0.14206865" +ENV PATH="$PATH:$ANDROID_HOME/platform-tools" +ENV PATH="$PATH:/opt/android-tools/cmake/3.31.6/bin/" +ENV LLVM_CONFIG_PATH="/opt/android-tools/ndk/29.0.14206865/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-config" + diff --git a/gui/Cargo.toml b/gui/Cargo.toml index a7337ee..0433118 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml @@ -88,13 +88,13 @@ tracing = "^0.1.40" color-eyre = "^0.6.3" crossbeam-queue = "^0.3.11" lol_html = "^2.2.0" -rfd = { git = "https://github.com/PolyMeilex/rfd.git", version = "^0.16.0", default-features = false } base64 = "^0.22" mime_guess = "^2.0.5" async_cell = "^0.2.3" reqwest = { version = "^0.12.22", features = ["json"] } dioxus-asset-resolver = "0.7.2" + # Denoising # ========= deep_filter = { git = "https://github.com/Rikorose/DeepFilterNet.git", rev = "d375b2d8309e0935d165700c91da9de862a99c31", features = [ @@ -102,6 +102,18 @@ deep_filter = { git = "https://github.com/Rikorose/DeepFilterNet.git", rev = "d3 ] } crossbeam = "0.8.4" +# Platform Integration +# ==================== +# rfd only supports windows, macos, linux, and wasm32. No support for Android or iOS +[target.'cfg(any(target_os = "linux", target_os = "windows", target_os = "macos", target_arch = "wasm32"))'.dependencies] +rfd = { git = "https://github.com/PolyMeilex/rfd.git", version = "^0.16.0", default-features = false, optional = true } + +# Android dependencies for requesting permissions +[target.'cfg(target_os = "android")'.dependencies] +android-permissions = "0.1.2" +jni = "0.21.1" +ndk-context = "0.1.1" + [patch.crates-io] tract-hir = "=0.12.4" tract-core = "=0.12.4" @@ -121,6 +133,7 @@ web = [ "gloo-timers", "tracing-web", "deep_filter/wasm", + "rfd", ] desktop = [ "dioxus/desktop", @@ -133,3 +146,13 @@ desktop = [ "rfd/xdg-portal", "etcetera", ] + +mobile = [ + "dioxus/mobile", + "tokio", + "tokio-rustls", + "tracing-subscriber/env-filter", + "opus", + "cpal", + "dasp_ring_buffer", +] diff --git a/gui/Dioxus.toml b/gui/Dioxus.toml index 571a2c6..9f19d56 100644 --- a/gui/Dioxus.toml +++ b/gui/Dioxus.toml @@ -8,6 +8,8 @@ out_dir = "dist" # resource (public) file folder asset_dir = "public" +android_manifest = "build/AndroidManifest.xml" + [web.app] # HTML title tag content title = "Mumble Web 2" @@ -33,7 +35,7 @@ style = [] script = [] [bundle] -identifier = "xyz.ohea.mumble-web-2" +identifier = "xyz.ohea.mumble_web_2" publisher = "OheaCorp" icon = [ "icons/32x32.png", diff --git a/gui/build/AndroidManifest.xml b/gui/build/AndroidManifest.xml new file mode 100644 index 0000000..7cac783 --- /dev/null +++ b/gui/build/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gui/src/app.rs b/gui/src/app.rs index cca6638..b9d20ff 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -210,6 +210,7 @@ pub fn Channel(id: ChannelId) -> Element { ) } +#[cfg(any(feature = "desktop", feature = "web"))] pub fn pick_and_send_file(net: &Coroutine) { let channels = if let Some(user) = STATE.server.read().this_user() { vec![user.channel] @@ -231,6 +232,8 @@ pub fn pick_and_send_file(net: &Coroutine) { }); }); } +#[cfg(not(any(feature = "desktop", feature = "web")))] +pub fn pick_and_send_file(net: &Coroutine) {} #[component] pub fn ChatView() -> Element { @@ -720,6 +723,8 @@ pub fn app() -> Element { } }); + imp::request_permissions(); + rsx!( document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" } document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" } diff --git a/gui/src/imp/connect.rs b/gui/src/imp/connect.rs new file mode 100644 index 0000000..8136e2f --- /dev/null +++ b/gui/src/imp/connect.rs @@ -0,0 +1,110 @@ +use crate::app::Command; +use color_eyre::eyre::{bail, Error}; +use dioxus::hooks::UnboundedReceiver; +use mumble_protocol::control::ClientControlCodec; +use std::net::ToSocketAddrs; +use std::sync::Arc; +use tokio::net::TcpStream; +use tokio_rustls::rustls; +use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier}; +use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use tokio_rustls::rustls::ClientConfig as RlsClientConfig; +use tokio_rustls::rustls::DigitallySignedStruct; +use tokio_rustls::TlsConnector; +use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _}; +use tracing::{info, instrument}; + +use mumble_web2_common::{ClientConfig, ServerStatus}; + +#[derive(Debug)] +struct NoCertificateVerification; + +impl ServerCertVerifier for NoCertificateVerification { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp: &[u8], + _now: UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + rustls::SignatureScheme::RSA_PKCS1_SHA1, + rustls::SignatureScheme::ECDSA_SHA1_Legacy, + rustls::SignatureScheme::RSA_PKCS1_SHA256, + rustls::SignatureScheme::ECDSA_NISTP256_SHA256, + rustls::SignatureScheme::RSA_PKCS1_SHA384, + rustls::SignatureScheme::ECDSA_NISTP384_SHA384, + rustls::SignatureScheme::RSA_PKCS1_SHA512, + rustls::SignatureScheme::ECDSA_NISTP521_SHA512, + rustls::SignatureScheme::RSA_PSS_SHA256, + rustls::SignatureScheme::RSA_PSS_SHA384, + rustls::SignatureScheme::RSA_PSS_SHA512, + rustls::SignatureScheme::ED25519, + rustls::SignatureScheme::ED448, + ] + } +} + +#[instrument] +pub async fn network_connect( + address: String, + username: String, + event_rx: &mut UnboundedReceiver, + gui_config: &ClientConfig, +) -> Result<(), Error> { + info!("connecting"); + + let config = RlsClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoCertificateVerification)) + .with_no_client_auth(); + + let connector = TlsConnector::from(Arc::new(config)); + + let addr = format!("{}:{}", address, 64738) + .to_socket_addrs()? + .next() + .unwrap(); + + let server_tcp = TcpStream::connect(addr).await?; + let server_stream = connector + //.connect("127.0.0.1".try_into()?, server_tcp) + .connect(address.try_into()?, server_tcp) + .await?; + let (read_server, write_server) = tokio::io::split(server_stream); + + let read_codec = ClientControlCodec::new(); + let write_codec = ClientControlCodec::new(); + + let reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec); + let writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec); + + crate::network_loop(username, event_rx, reader, writer).await +} + +pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result { + bail!("status not supported on desktop yet") +} diff --git a/gui/src/imp/desktop.rs b/gui/src/imp/desktop.rs index d392ddf..8a41f31 100644 --- a/gui/src/imp/desktop.rs +++ b/gui/src/imp/desktop.rs @@ -1,320 +1,12 @@ -use crate::app::Command; -use crate::effects::{AudioProcessor, AudioProcessorSender}; -use color_eyre::eyre::{bail, eyre, Error}; -use cpal::traits::{DeviceTrait, HostTrait, StreamTrait as _}; -use dioxus::hooks::UnboundedReceiver; use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs}; -use futures::io::{AsyncRead, AsyncWrite}; -use mumble_protocol::control::ClientControlCodec; -use mumble_web2_common::{ClientConfig, ServerStatus}; +use mumble_web2_common::ClientConfig; use std::collections::HashMap; -use std::mem::replace; -use std::net::ToSocketAddrs; -use std::sync::Arc; -use std::sync::Mutex; -use tokio::net::TcpStream; -use tokio_rustls::rustls; -use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier}; -use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime}; -use tokio_rustls::rustls::ClientConfig as RlsClientConfig; -use tokio_rustls::rustls::DigitallySignedStruct; -use tokio_rustls::TlsConnector; -use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _}; -use tracing::{error, info, instrument, warn}; - pub use tokio::runtime::Handle as SpawnHandle; pub use tokio::task::spawn; pub use tokio::time::sleep; -pub trait ImpRead: AsyncRead + Unpin + Send + 'static {} -impl ImpRead for T {} - -pub trait ImpWrite: AsyncWrite + Unpin + Send + 'static {} -impl ImpWrite for T {} - -pub struct AudioSystem { - output: cpal::Device, - input: cpal::Device, - processors: AudioProcessorSender, - recording_stream: Option, -} - -const SAMPLE_RATE: u32 = 48_000; -const PACKET_SAMPLES: u32 = 960; - -type Buffer = Arc>>>; - -impl AudioSystem { - pub async fn new() -> Result { - // TODO - let host = cpal::default_host(); - let name = host.id(); - let processors = AudioProcessorSender::default(); - Ok(AudioSystem { - output: host - .default_output_device() - .ok_or(eyre!("no output devices from {name:?}"))?, - input: host - .default_input_device() - .ok_or(eyre!("no input devices from {name:?}"))?, - processors, - recording_stream: None, - }) - } - - pub fn set_processor(&self, processor: AudioProcessor) { - self.processors.store(Some(processor)) - } - - fn choose_config( - &self, - configs: impl Iterator, - ) -> Result { - let mut supported_configs: Vec<_> = configs - .filter_map(|cfg| cfg.try_with_sample_rate(cpal::SampleRate(SAMPLE_RATE))) - .filter(|cfg| cfg.sample_format() == cpal::SampleFormat::I16) - .map(|cfg| cpal::StreamConfig { - buffer_size: cpal::BufferSize::Fixed(match *cfg.buffer_size() { - cpal::SupportedBufferSize::Range { min, max } => 480.clamp(min, max), - cpal::SupportedBufferSize::Unknown => 480, - }), - ..cfg.config() - }) - .collect(); - supported_configs.sort_by(|a, b| { - let cpal::BufferSize::Fixed(a_buf) = a.buffer_size else { - unreachable!() - }; - let cpal::BufferSize::Fixed(b_buf) = b.buffer_size else { - unreachable!() - }; - Ord::cmp(&a.channels, &b.channels).then(Ord::cmp(&a_buf, &b_buf)) - }); - supported_configs - .get(0) - .cloned() - .ok_or(eyre!("no supported stream configs")) - } - - pub fn start_recording( - &mut self, - mut each: impl FnMut(Vec) + Send + 'static, - ) -> Result<(), Error> { - let config = self.choose_config(self.input.supported_input_configs()?)?; - info!( - "creating recording on {:?} with {:#?}", - self.input.name()?, - config - ); - let mut encoder = - opus::Encoder::new(SAMPLE_RATE, opus::Channels::Mono, opus::Application::Voip)?; - let mut current_processor = AudioProcessor::new_plain(); - let mut output_buffer = Vec::new(); - let processors = self.processors.clone(); - let error_callback = move |e: cpal::StreamError| error!("error recording: {e:?}"); - let data_callback = move |frame: &[f32], _: &cpal::InputCallbackInfo| { - if let Some(new_processor) = processors.take() { - current_processor = new_processor; - } - current_processor.process(frame, config.channels as usize, &mut output_buffer); - if output_buffer.len() < PACKET_SAMPLES as usize { - return; - } - let remainder = output_buffer.split_off(PACKET_SAMPLES as usize); - let frame = replace(&mut output_buffer, remainder); - match encoder.encode_vec_float(&frame, frame.len() * 2) { - Ok(buf) => { - each(buf); - } - Err(e) => { - error!("error encoding {} samples: {e:?}", frame.len()); - } - } - }; - - match self - .input - .build_input_stream(&config, data_callback, error_callback, None) - { - Ok(stream) => { - stream.play()?; - self.recording_stream = Some(stream); - Ok(()) - } - Err(err) => { - self.recording_stream = None; - Err(err.into()) - } - } - } - - pub fn create_player(&mut self) -> Result { - let config = self.choose_config(self.output.supported_output_configs()?)?; - info!( - "creating player on {:?} with {:#?}", - self.output.name().ok(), - &config - ); - let buffer = Arc::new(Mutex::new(dasp_ring_buffer::Bounded::from_raw_parts( - 0, - 0, - vec![ - 0; - SAMPLE_RATE as usize/4 // 250ms of buffer - ], - ))); - let decoder = opus::Decoder::new(SAMPLE_RATE, opus::Channels::Mono)?; - let stream = { - let buffer = buffer.clone(); - self.output.build_output_stream( - &config, - move |frame, _info| { - let mut buffer = buffer.lock().unwrap(); - for x in frame.chunks_mut(config.channels as usize) { - match buffer.pop() { - Some(y) => { - x.fill(y); - } - None => { - x.fill(0); - } - } - } - }, - move |err| error!("could not create output stream {err:?}"), - None, - )? - }; - stream.play()?; - Ok(AudioPlayer { - decoder, - stream, - buffer, - tmp: vec![0; 2400], - }) - } -} - -pub struct AudioPlayer { - decoder: opus::Decoder, - stream: cpal::Stream, - buffer: Buffer, - tmp: Vec, -} - -impl AudioPlayer { - pub fn play_opus(&mut self, payload: &[u8]) { - let len = loop { - match self.decoder.decode(payload, &mut self.tmp, false) { - Ok(l) => break l, - Err(e) => { - error!("opus decode error {e:?}"); - return; - } - } - }; - - let mut buffer = self.buffer.lock().unwrap(); - let mut overrun = 0; - for x in &self.tmp[..len] { - if let Some(_) = buffer.push(*x) { - overrun += 1; - } - } - if overrun > 0 { - warn!("playback overrun by {overrun} samples"); - } - } -} - -#[derive(Debug)] -struct NoCertificateVerification; - -impl ServerCertVerifier for NoCertificateVerification { - fn verify_server_cert( - &self, - _end_entity: &CertificateDer<'_>, - _intermediates: &[CertificateDer<'_>], - _server_name: &ServerName<'_>, - _ocsp: &[u8], - _now: UnixTime, - ) -> Result { - Ok(rustls::client::danger::ServerCertVerified::assertion()) - } - - fn verify_tls12_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, - ) -> Result { - Ok(HandshakeSignatureValid::assertion()) - } - - fn verify_tls13_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, - ) -> Result { - Ok(HandshakeSignatureValid::assertion()) - } - - fn supported_verify_schemes(&self) -> Vec { - vec![ - rustls::SignatureScheme::RSA_PKCS1_SHA1, - rustls::SignatureScheme::ECDSA_SHA1_Legacy, - rustls::SignatureScheme::RSA_PKCS1_SHA256, - rustls::SignatureScheme::ECDSA_NISTP256_SHA256, - rustls::SignatureScheme::RSA_PKCS1_SHA384, - rustls::SignatureScheme::ECDSA_NISTP384_SHA384, - rustls::SignatureScheme::RSA_PKCS1_SHA512, - rustls::SignatureScheme::ECDSA_NISTP521_SHA512, - rustls::SignatureScheme::RSA_PSS_SHA256, - rustls::SignatureScheme::RSA_PSS_SHA384, - rustls::SignatureScheme::RSA_PSS_SHA512, - rustls::SignatureScheme::ED25519, - rustls::SignatureScheme::ED448, - ] - } -} - -#[instrument] -pub async fn network_connect( - address: String, - username: String, - event_rx: &mut UnboundedReceiver, - gui_config: &ClientConfig, -) -> Result<(), Error> { - info!("connecting"); - - let config = RlsClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(NoCertificateVerification)) - .with_no_client_auth(); - - let connector = TlsConnector::from(Arc::new(config)); - - let addr = format!("{}:{}", address, 64738) - .to_socket_addrs()? - .next() - .unwrap(); - - let server_tcp = TcpStream::connect(addr).await?; - let server_stream = connector - //.connect("127.0.0.1".try_into()?, server_tcp) - .connect(address.try_into()?, server_tcp) - .await?; - let (read_server, write_server) = tokio::io::split(server_stream); - - let read_codec = ClientControlCodec::new(); - let write_codec = ClientControlCodec::new(); - - let reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec); - let writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec); - - crate::network_loop(username, event_rx, reader, writer).await -} +pub use super::connect::*; +pub use super::native_audio::*; fn get_config_path() -> std::path::PathBuf { let strategy = choose_app_strategy(AppStrategyArgs { @@ -374,10 +66,6 @@ pub async fn load_config() -> color_eyre::Result { }) } -pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result { - bail!("status not supported on desktop yet") -} - pub fn init_logging() { use tracing::level_filters::LevelFilter; use tracing_subscriber::filter::EnvFilter; diff --git a/gui/src/imp/mobile.rs b/gui/src/imp/mobile.rs new file mode 100644 index 0000000..b632934 --- /dev/null +++ b/gui/src/imp/mobile.rs @@ -0,0 +1,67 @@ +use android_permissions::{PermissionManager, RECORD_AUDIO}; +use jni::{objects::JObject, JavaVM}; +use mumble_web2_common::ClientConfig; + +use std::collections::HashMap; +pub use tokio::runtime::Handle as SpawnHandle; +pub use tokio::task::spawn; +pub use tokio::time::sleep; + +pub use super::connect::*; +pub use super::native_audio::*; + +pub fn set_default_username(username: &str) -> Option<()> { + None +} + +pub fn set_default_server(server: &str) -> Option<()> { + None +} + +pub fn load_username() -> Option { + None +} + +pub fn load_server_url() -> Option { + None +} + +pub async fn load_config() -> color_eyre::Result { + Ok(ClientConfig { + proxy_url: None, + cert_hash: None, + any_server: true, + }) +} + +pub fn init_logging() { + use tracing::level_filters::LevelFilter; + use tracing_subscriber::filter::EnvFilter; + + let env_filter = EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .from_env_lossy(); + + tracing_subscriber::fmt() + .with_target(true) + .with_level(true) + .with_env_filter(env_filter) + .init(); +} + +#[cfg(feature = "mobile")] +pub fn request_permissions() { + request_recording_permission(); +} + +#[cfg(target_os = "android")] +pub fn request_recording_permission() { + let ctx = ndk_context::android_context(); + let vm = unsafe { JavaVM::from_raw(ctx.vm().cast()).unwrap() }; + let activity = unsafe { JObject::from_raw(ctx.context().cast()) }; + + let manager = PermissionManager::create(vm, activity).unwrap(); + if !manager.check(&RECORD_AUDIO).unwrap() { + manager.request(&[&RECORD_AUDIO]).unwrap(); + } +} diff --git a/gui/src/imp/mod.rs b/gui/src/imp/mod.rs index ae5f314..106e281 100644 --- a/gui/src/imp/mod.rs +++ b/gui/src/imp/mod.rs @@ -1,11 +1,29 @@ #[cfg(feature = "web")] mod web; +#[cfg(any(feature = "desktop", feature = "mobile"))] +mod connect; +#[cfg(any(feature = "desktop", feature = "mobile"))] +mod native_audio; + #[cfg(feature = "desktop")] mod desktop; - -#[cfg(all(feature = "web", not(feature = "desktop")))] -pub use web::*; +#[cfg(feature = "mobile")] +mod mobile; #[cfg(feature = "desktop")] pub use desktop::*; +#[cfg(feature = "mobile")] +pub use mobile::*; + +#[cfg(feature = "mobile")] +pub use mobile::request_permissions; + +#[cfg(any(feature = "desktop", feature = "web"))] +pub fn request_permissions() {} + +#[cfg(all(feature = "web", not(any(feature = "desktop", feature = "mobile"))))] +pub use web::*; + +#[cfg(any(feature = "desktop"))] +pub use desktop::*; diff --git a/gui/src/imp/native_audio.rs b/gui/src/imp/native_audio.rs new file mode 100644 index 0000000..dda5c3a --- /dev/null +++ b/gui/src/imp/native_audio.rs @@ -0,0 +1,209 @@ +use crate::effects::{AudioProcessor, AudioProcessorSender}; +use color_eyre::eyre::{eyre, Error}; +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait as _}; +use futures::io::{AsyncRead, AsyncWrite}; +use std::mem::replace; +use std::sync::Arc; +use std::sync::Mutex; +use tracing::{error, info, warn}; + +pub trait ImpRead: AsyncRead + Unpin + Send + 'static {} +impl ImpRead for T {} + +pub trait ImpWrite: AsyncWrite + Unpin + Send + 'static {} +impl ImpWrite for T {} + +pub struct AudioSystem { + output: cpal::Device, + input: cpal::Device, + processors: AudioProcessorSender, + recording_stream: Option, +} + +const SAMPLE_RATE: u32 = 48_000; +const PACKET_SAMPLES: u32 = 960; + +type Buffer = Arc>>>; + +impl AudioSystem { + pub async fn new() -> Result { + // TODO + let host = cpal::default_host(); + let name = host.id(); + let processors = AudioProcessorSender::default(); + Ok(AudioSystem { + output: host + .default_output_device() + .ok_or(eyre!("no output devices from {name:?}"))?, + input: host + .default_input_device() + .ok_or(eyre!("no input devices from {name:?}"))?, + processors, + recording_stream: None, + }) + } + + pub fn set_processor(&self, processor: AudioProcessor) { + self.processors.store(Some(processor)) + } + + fn choose_config( + &self, + configs: impl Iterator, + ) -> Result { + let mut supported_configs: Vec<_> = configs + .filter_map(|cfg| cfg.try_with_sample_rate(cpal::SampleRate(SAMPLE_RATE))) + .filter(|cfg| cfg.sample_format() == cpal::SampleFormat::I16) + .map(|cfg| cpal::StreamConfig { + buffer_size: cpal::BufferSize::Fixed(match *cfg.buffer_size() { + cpal::SupportedBufferSize::Range { min, max } => 480.clamp(min, max), + cpal::SupportedBufferSize::Unknown => 480, + }), + ..cfg.config() + }) + .collect(); + supported_configs.sort_by(|a, b| { + let cpal::BufferSize::Fixed(a_buf) = a.buffer_size else { + unreachable!() + }; + let cpal::BufferSize::Fixed(b_buf) = b.buffer_size else { + unreachable!() + }; + Ord::cmp(&a.channels, &b.channels).then(Ord::cmp(&a_buf, &b_buf)) + }); + supported_configs + .get(0) + .cloned() + .ok_or(eyre!("no supported stream configs")) + } + + pub fn start_recording( + &mut self, + mut each: impl FnMut(Vec) + Send + 'static, + ) -> Result<(), Error> { + let config = self.choose_config(self.input.supported_input_configs()?)?; + info!( + "creating recording on {:?} with {:#?}", + self.input.name()?, + config + ); + let mut encoder = + opus::Encoder::new(SAMPLE_RATE, opus::Channels::Mono, opus::Application::Voip)?; + let mut current_processor = AudioProcessor::new_plain(); + let mut output_buffer = Vec::new(); + let processors = self.processors.clone(); + let error_callback = move |e: cpal::StreamError| error!("error recording: {e:?}"); + let data_callback = move |frame: &[f32], _: &cpal::InputCallbackInfo| { + if let Some(new_processor) = processors.take() { + current_processor = new_processor; + } + current_processor.process(frame, config.channels as usize, &mut output_buffer); + if output_buffer.len() < PACKET_SAMPLES as usize { + return; + } + let remainder = output_buffer.split_off(PACKET_SAMPLES as usize); + let frame = replace(&mut output_buffer, remainder); + match encoder.encode_vec_float(&frame, frame.len() * 2) { + Ok(buf) => { + each(buf); + } + Err(e) => { + error!("error encoding {} samples: {e:?}", frame.len()); + } + } + }; + + match self + .input + .build_input_stream(&config, data_callback, error_callback, None) + { + Ok(stream) => { + stream.play()?; + self.recording_stream = Some(stream); + Ok(()) + } + Err(err) => { + self.recording_stream = None; + Err(err.into()) + } + } + } + + pub fn create_player(&mut self) -> Result { + let config = self.choose_config(self.output.supported_output_configs()?)?; + info!( + "creating player on {:?} with {:#?}", + self.output.name().ok(), + &config + ); + let buffer = Arc::new(Mutex::new(dasp_ring_buffer::Bounded::from_raw_parts( + 0, + 0, + vec![ + 0; + SAMPLE_RATE as usize/4 // 250ms of buffer + ], + ))); + let decoder = opus::Decoder::new(SAMPLE_RATE, opus::Channels::Mono)?; + let stream = { + let buffer = buffer.clone(); + self.output.build_output_stream( + &config, + move |frame, _info| { + let mut buffer = buffer.lock().unwrap(); + for x in frame.chunks_mut(config.channels as usize) { + match buffer.pop() { + Some(y) => { + x.fill(y); + } + None => { + x.fill(0); + } + } + } + }, + move |err| error!("could not create output stream {err:?}"), + None, + )? + }; + stream.play()?; + Ok(AudioPlayer { + decoder, + stream, + buffer, + tmp: vec![0; 2400], + }) + } +} + +pub struct AudioPlayer { + decoder: opus::Decoder, + stream: cpal::Stream, + buffer: Buffer, + tmp: Vec, +} + +impl AudioPlayer { + pub fn play_opus(&mut self, payload: &[u8]) { + let len = loop { + match self.decoder.decode(payload, &mut self.tmp, false) { + Ok(l) => break l, + Err(e) => { + error!("opus decode error {e:?}"); + return; + } + } + }; + + let mut buffer = self.buffer.lock().unwrap(); + let mut overrun = 0; + for x in &self.tmp[..len] { + if let Some(_) = buffer.push(*x) { + overrun += 1; + } + } + if overrun > 0 { + warn!("playback overrun by {overrun} samples"); + } + } +} diff --git a/gui/src/lib.rs b/gui/src/lib.rs index a8a163d..b5c07a0 100644 --- a/gui/src/lib.rs +++ b/gui/src/lib.rs @@ -20,12 +20,9 @@ use mumble_protocol::voice::VoicePacket; use mumble_protocol::voice::VoicePacketPayload; use mumble_protocol::Clientbound; use mumble_protocol::Serverbound; -use mumble_web2_common::ClientConfig; -use once_cell::sync::Lazy; use std::collections::hash_map::Entry; use std::collections::HashMap; use std::time::Duration; -use tracing::debug; use tracing::error; use tracing::info;