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;