From fd1f2a1db242e6ef6ff8926464d1d49e6991e982 Mon Sep 17 00:00:00 2001 From: wiiznokes <78230769+wiiznokes@users.noreply.github.com> Date: Sun, 1 Feb 2026 13:51:28 +0100 Subject: [PATCH 01/11] fix udp: more reliable protocol --- .../domain/streaming/UdpStreamer.kt | 57 +++++++-- Android/app/src/main/proto/message.proto | 21 +++- RustApp/src/proto/message.proto | 23 ++-- RustApp/src/streamer/adb_streamer.rs | 10 +- RustApp/src/streamer/message.rs | 17 +++ RustApp/src/streamer/mod.rs | 6 +- RustApp/src/streamer/tcp_streamer.rs | 8 +- RustApp/src/streamer/udp_streamer.rs | 112 ++++++++++++------ RustApp/src/ui/app.rs | 10 +- RustApp/src/ui/view.rs | 4 +- 10 files changed, 190 insertions(+), 78 deletions(-) diff --git a/Android/app/src/main/java/io/github/teamclouday/AndroidMic/domain/streaming/UdpStreamer.kt b/Android/app/src/main/java/io/github/teamclouday/AndroidMic/domain/streaming/UdpStreamer.kt index 4c183f37..6d1a65c8 100644 --- a/Android/app/src/main/java/io/github/teamclouday/AndroidMic/domain/streaming/UdpStreamer.kt +++ b/Android/app/src/main/java/io/github/teamclouday/AndroidMic/domain/streaming/UdpStreamer.kt @@ -25,7 +25,41 @@ class UdpStreamer(private val scope: CoroutineScope, val ip: String, var port: I override fun connect(): Boolean { socket.soTimeout = 1500 - return true + + val message = Message.MessageWrapper.newBuilder() + .setConnect( + Message.ConnectMessage.newBuilder() + .build() + ) + .build() + + val pack = message.toByteArray() + val combined = pack.size.toBigEndianU32() + pack + + val packet = DatagramPacket( + combined, + 0, + combined.size, + address, + port + ) + + try { + socket.send(packet) + } catch (_: Exception) { + return false + } + + val buff = ByteArray(CHECK_2.length) + val recvPacket = DatagramPacket(buff, buff.size) + + try { + socket.receive(recvPacket) + } catch (_: Exception) { + return false + } + + return recvPacket.data.contentEquals(CHECK_2.toByteArray()) } override fun disconnect(): Boolean { @@ -45,18 +79,23 @@ class UdpStreamer(private val scope: CoroutineScope, val ip: String, var port: I streamJob = scope.launch { audioStream.collect { data -> try { - val message = Message.AudioPacketMessageOrdered.newBuilder() - .setSequenceNumber(sequenceIdx++) + + val message = Message.MessageWrapper.newBuilder() .setAudioPacket( - Message.AudioPacketMessage.newBuilder() - .setBuffer(ByteString.copyFrom(data.buffer)) - .setSampleRate(data.sampleRate) - .setAudioFormat(data.audioFormat) - .setChannelCount(data.channelCount) + Message.AudioPacketMessageOrdered.newBuilder() + .setSequenceNumber(sequenceIdx++) + .setAudioPacket( + Message.AudioPacketMessage.newBuilder() + .setBuffer(ByteString.copyFrom(data.buffer)) + .setSampleRate(data.sampleRate) + .setAudioFormat(data.audioFormat) + .setChannelCount(data.channelCount) + ) .build() ) .build() + val pack = message.toByteArray() val combined = pack.size.toBigEndianU32() + pack @@ -65,7 +104,7 @@ class UdpStreamer(private val scope: CoroutineScope, val ip: String, var port: I 0, combined.size, address, - port!! + port ) socket.send(packet) diff --git a/Android/app/src/main/proto/message.proto b/Android/app/src/main/proto/message.proto index c6c14585..61354111 100644 --- a/Android/app/src/main/proto/message.proto +++ b/Android/app/src/main/proto/message.proto @@ -1,13 +1,22 @@ syntax = "proto3"; message AudioPacketMessage { - bytes buffer = 1; - uint32 sample_rate = 2; - uint32 channel_count = 3; - uint32 audio_format = 4; + bytes buffer = 1; + uint32 sample_rate = 2; + uint32 channel_count = 3; + uint32 audio_format = 4; } message AudioPacketMessageOrdered { - uint32 sequence_number = 1; - AudioPacketMessage audio_packet = 2; + uint32 sequence_number = 1; + AudioPacketMessage audio_packet = 2; +} + +message ConnectMessage {} + +message MessageWrapper { + oneof payload { + AudioPacketMessageOrdered audio_packet = 1; + ConnectMessage connect = 2; + } } \ No newline at end of file diff --git a/RustApp/src/proto/message.proto b/RustApp/src/proto/message.proto index e48f723b..61354111 100644 --- a/RustApp/src/proto/message.proto +++ b/RustApp/src/proto/message.proto @@ -1,15 +1,22 @@ syntax = "proto3"; -package message; - message AudioPacketMessage { - bytes buffer = 1; - uint32 sample_rate = 2; - uint32 channel_count = 3; - uint32 audio_format = 4; + bytes buffer = 1; + uint32 sample_rate = 2; + uint32 channel_count = 3; + uint32 audio_format = 4; } message AudioPacketMessageOrdered { - uint32 sequence_number = 1; - AudioPacketMessage audio_packet = 2; + uint32 sequence_number = 1; + AudioPacketMessage audio_packet = 2; +} + +message ConnectMessage {} + +message MessageWrapper { + oneof payload { + AudioPacketMessageOrdered audio_packet = 1; + ConnectMessage connect = 2; + } } \ No newline at end of file diff --git a/RustApp/src/streamer/adb_streamer.rs b/RustApp/src/streamer/adb_streamer.rs index f4536ad7..01f55682 100644 --- a/RustApp/src/streamer/adb_streamer.rs +++ b/RustApp/src/streamer/adb_streamer.rs @@ -3,7 +3,7 @@ use tokio::process::Command; use crate::{ config::ConnectionMode, - streamer::{StreamerMsg, DEFAULT_PC_PORT, tcp_streamer}, + streamer::{DEFAULT_PC_PORT, StreamerMsg, tcp_streamer}, }; use super::{ @@ -68,12 +68,8 @@ async fn exec_cmd(mut cmd: Command) -> Result { } pub async fn new(stream_config: AudioStream) -> Result { - let tcp_streamer = tcp_streamer::new( - "127.0.0.1".parse().unwrap(), - DEFAULT_PC_PORT, - stream_config, - ) - .await?; + let tcp_streamer = + tcp_streamer::new("127.0.0.1".parse().unwrap(), DEFAULT_PC_PORT, stream_config).await?; let devices = get_connected_devices().await?; if devices.is_empty() { diff --git a/RustApp/src/streamer/message.rs b/RustApp/src/streamer/message.rs index 0a0ebaf4..b209b7c3 100644 --- a/RustApp/src/streamer/message.rs +++ b/RustApp/src/streamer/message.rs @@ -17,3 +17,20 @@ pub struct AudioPacketMessageOrdered { #[prost(message, optional, tag = "2")] pub audio_packet: ::core::option::Option, } +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ConnectMessage {} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct MessageWrapper { + #[prost(oneof = "message_wrapper::Payload", tags = "1, 2")] + pub payload: ::core::option::Option, +} +/// Nested message and enum types in `MessageWrapper`. +pub mod message_wrapper { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Payload { + #[prost(message, tag = "1")] + AudioPacket(super::AudioPacketMessageOrdered), + #[prost(message, tag = "2")] + Connect(super::ConnectMessage), + } +} diff --git a/RustApp/src/streamer/mod.rs b/RustApp/src/streamer/mod.rs index 086aacba..fd1d1e0f 100644 --- a/RustApp/src/streamer/mod.rs +++ b/RustApp/src/streamer/mod.rs @@ -24,7 +24,7 @@ mod usb_streamer; #[cfg(feature = "usb")] use crate::streamer::usb_streamer::UsbStreamer; -pub use message::{AudioPacketMessage, AudioPacketMessageOrdered}; +pub use message::AudioPacketMessage; pub use streamer_runner::{ConnectOption, StreamerCommand, StreamerMsg, sub}; use crate::{audio::AudioProcessParams, config::AudioFormat}; @@ -90,8 +90,8 @@ enum Streamer { #[derive(Debug, Error)] enum ConnectError { - #[error("can't bind a port on the pc: {0}")] - CantBindPort(io::Error), + #[error("can't bind port {0} on the pc: {1}")] + CantBindPort(u16, io::Error), #[error("can't find a local address: {0}")] NoLocalAddress(io::Error), #[error("accept failed: {0}")] diff --git a/RustApp/src/streamer/tcp_streamer.rs b/RustApp/src/streamer/tcp_streamer.rs index 6404df28..166d47dd 100644 --- a/RustApp/src/streamer/tcp_streamer.rs +++ b/RustApp/src/streamer/tcp_streamer.rs @@ -37,10 +37,14 @@ pub enum TcpStreamerState { }, } -pub async fn new(ip: IpAddr, port: u16, stream_config: AudioStream) -> Result { +pub async fn new( + ip: IpAddr, + port: u16, + stream_config: AudioStream, +) -> Result { let listener = TcpListener::bind((ip, port)) .await - .map_err(ConnectError::CantBindPort)?; + .map_err(|e| ConnectError::CantBindPort(port, e))?; let addr = TcpListener::local_addr(&listener).map_err(ConnectError::NoLocalAddress)?; diff --git a/RustApp/src/streamer/udp_streamer.rs b/RustApp/src/streamer/udp_streamer.rs index 90757665..64171a9d 100644 --- a/RustApp/src/streamer/udp_streamer.rs +++ b/RustApp/src/streamer/udp_streamer.rs @@ -7,10 +7,13 @@ use tokio_util::{codec::LengthDelimitedCodec, udp::UdpFramed}; use crate::{ config::ConnectionMode, - streamer::{AudioPacketMessage, WriteError}, + streamer::{ + AudioPacketMessage, CHECK_2, WriteError, + message::{MessageWrapper, message_wrapper::Payload}, + }, }; -use super::{AudioPacketMessageOrdered, AudioStream, ConnectError, StreamerMsg, StreamerTrait}; +use super::{AudioStream, ConnectError, StreamerMsg, StreamerTrait}; const MAX_WAIT_TIME: Duration = Duration::from_millis(1500); @@ -25,10 +28,14 @@ pub struct UdpStreamer { tracked_sequence: u32, } -pub async fn new(ip: IpAddr, port: u16, stream_config: AudioStream) -> Result { +pub async fn new( + ip: IpAddr, + port: u16, + stream_config: AudioStream, +) -> Result { let socket = UdpSocket::bind((ip, port)) .await - .map_err(ConnectError::CantBindPort)?; + .map_err(|e| ConnectError::CantBindPort(port, e))?; let addr = socket.local_addr().map_err(ConnectError::NoLocalAddress)?; @@ -66,48 +73,77 @@ impl StreamerTrait for UdpStreamer { async fn next(&mut self) -> Result, ConnectError> { match tokio::time::timeout( - Duration::from_secs(if self.is_listening { Duration::MAX.as_secs() } else { 1 }), + Duration::from_secs(if self.is_listening { + Duration::MAX.as_secs() + } else { + 1 + }), self.framed.next(), ) .await { Ok(res) => match res { Some(Ok((frame, addr))) => { - match AudioPacketMessageOrdered::decode(frame) { + match MessageWrapper::decode(frame) { Ok(packet) => { - if self.is_listening { - self.is_listening = false; - return Ok(Some(StreamerMsg::Connected { - ip: Some(self.ip), - port: Some(self.port), - mode: ConnectionMode::Udp, - })); - } - - if packet.sequence_number < self.tracked_sequence { - // drop packet - info!( - "dropped packet: old sequence number {} < {}", - packet.sequence_number, self.tracked_sequence - ); - } - self.tracked_sequence = packet.sequence_number; - - let packet = packet.audio_packet.unwrap(); - let buffer_size = packet.buffer.len(); - let sample_rate = packet.sample_rate; - - match self.stream_config.process_audio_packet(packet) { - Ok(Some(buffer)) => { - debug!("From {:?}, received {} bytes", addr, buffer_size); - Ok(Some(StreamerMsg::UpdateAudioWave { - data: AudioPacketMessage::to_wave_data( - &buffer, - sample_rate, - ), - })) + match packet.payload { + Some(payload) => { + let message = match payload { + Payload::AudioPacket(packet) => { + if packet.sequence_number < self.tracked_sequence { + // drop packet + info!( + "dropped packet: old sequence number {} < {}", + packet.sequence_number, self.tracked_sequence + ); + } + self.tracked_sequence = packet.sequence_number; + + let packet = packet.audio_packet.unwrap(); + let buffer_size = packet.buffer.len(); + let sample_rate = packet.sample_rate; + + match self.stream_config.process_audio_packet(packet) { + Ok(Some(buffer)) => { + debug!( + "From {:?}, received {} bytes", + addr, buffer_size + ); + Some(StreamerMsg::UpdateAudioWave { + data: AudioPacketMessage::to_wave_data( + &buffer, + sample_rate, + ), + }) + } + _ => None, + } + } + Payload::Connect(_) => { + self.framed + .get_ref() + .send_to(CHECK_2.as_bytes(), &addr) + .await + .map_err(|e| { + ConnectError::HandShakeFailed("writing", e) + })?; + + None + } + }; + + if self.is_listening { + self.is_listening = false; + Ok(Some(StreamerMsg::Connected { + ip: Some(self.ip), + port: Some(self.port), + mode: ConnectionMode::Udp, + })) + } else { + Ok(message) + } } - _ => Ok(None), + None => todo!(), } } Err(e) => Err(ConnectError::WriteError(WriteError::Deserializer(e))), diff --git a/RustApp/src/ui/app.rs b/RustApp/src/ui/app.rs index 5c052c20..dd49fea9 100644 --- a/RustApp/src/ui/app.rs +++ b/RustApp/src/ui/app.rs @@ -211,7 +211,10 @@ impl AppState { return self.add_log(e); }; - ConnectOption::Tcp { ip, port: config.port } + ConnectOption::Tcp { + ip, + port: config.port, + } } ConnectionMode::Udp => { let Some(ip) = config.ip_or_default() else { @@ -220,7 +223,10 @@ impl AppState { error!("failed to start audio stream: {e}"); return self.add_log(e); }; - ConnectOption::Udp { ip, port: config.port } + ConnectOption::Udp { + ip, + port: config.port, + } } #[cfg(feature = "adb")] ConnectionMode::Adb => ConnectOption::Adb, diff --git a/RustApp/src/ui/view.rs b/RustApp/src/ui/view.rs index 3a3d7bc6..c6f8aed3 100644 --- a/RustApp/src/ui/view.rs +++ b/RustApp/src/ui/view.rs @@ -173,9 +173,7 @@ fn port(app: &AppState) -> Element<'_, AppMsg> { .on_input(AppMsg::PortTextInput) .width(Length::Fixed(150.0)), ) - .push( - button::text(fl!("save")).on_press(AppMsg::PortSave), - ), + .push(button::text(fl!("save")).on_press(AppMsg::PortSave)), ) .into() } From 93e1f29d3f2e9f179928a49e828600aab7aed692 Mon Sep 17 00:00:00 2001 From: wiiznokes <78230769+wiiznokes@users.noreply.github.com> Date: Sun, 1 Feb 2026 14:18:53 +0100 Subject: [PATCH 02/11] move the port selection in settings page to reduce clutter --- RustApp/.gitignore | 3 ++- RustApp/i18n/en/android_mic.ftl | 2 ++ RustApp/src/ui/app.rs | 41 +++++++++++++++++---------------- RustApp/src/ui/message.rs | 4 ++-- RustApp/src/ui/view.rs | 37 ++++++++++++++--------------- 5 files changed, 44 insertions(+), 43 deletions(-) diff --git a/RustApp/.gitignore b/RustApp/.gitignore index 8d32d4af..223891a0 100644 --- a/RustApp/.gitignore +++ b/RustApp/.gitignore @@ -9,4 +9,5 @@ /flatpak-out /repo /log -/config/ \ No newline at end of file +/config/ +/src/streamer/_.rs \ No newline at end of file diff --git a/RustApp/i18n/en/android_mic.ftl b/RustApp/i18n/en/android_mic.ftl index 62c2cdd8..89b626e7 100644 --- a/RustApp/i18n/en/android_mic.ftl +++ b/RustApp/i18n/en/android_mic.ftl @@ -31,6 +31,8 @@ channel_count = Channel count audio_format = Audio format use_recommended_audio_format = Use Recommended Audio Format +title_connection = Connection + denoise = Noise reduction denoise_enabled = Enabled denoise_type = Type diff --git a/RustApp/src/ui/app.rs b/RustApp/src/ui/app.rs index dd49fea9..88f6d48c 100644 --- a/RustApp/src/ui/app.rs +++ b/RustApp/src/ui/app.rs @@ -561,26 +561,6 @@ impl Application for AppState { self.network_adapter = Some(adapter.clone()); return self.add_log(format!("Selected network adapter: {adapter}").as_str()); } - AppMsg::PortTextInput(text) => { - self.port_input = text; - } - AppMsg::PortSave => { - let port = if self.port_input.is_empty() { - 55555 - } else { - match self.port_input.parse() { - Ok(p) => p, - Err(_) => { - self.port_input = "55555".to_string(); - self.config.update(|c| c.port = 55555); - return self.add_log("Invalid port number, using default 55555"); - } - } - }; - self.config.update(|c| c.port = port); - self.port_input = port.to_string(); - return self.add_log(format!("Changed port to {}", port).as_str()); - } AppMsg::Connect => { return self.connect(); } @@ -613,6 +593,27 @@ impl Application for AppState { } }, AppMsg::Config(msg) => match msg { + ConfigMsg::PortTextInput(text) => { + self.port_input = text; + } + ConfigMsg::PortSave => { + let port = if self.port_input.is_empty() { + 55555 + } else { + match self.port_input.parse() { + Ok(p) => p, + Err(_) => { + self.port_input = "55555".to_string(); + self.config.update(|c| c.port = 55555); + return self.add_log("Invalid port number, using default 55555"); + } + } + }; + self.config.update(|c| c.port = port); + self.port_input = port.to_string(); + return self.add_log(format!("Changed port to {}", port).as_str()); + } + ConfigMsg::SampleRate(sample_rate) => { self.config.update(|s| s.sample_rate = sample_rate); return self.update_audio_stream(); diff --git a/RustApp/src/ui/message.rs b/RustApp/src/ui/message.rs index e362a3b5..8aefe6c1 100644 --- a/RustApp/src/ui/message.rs +++ b/RustApp/src/ui/message.rs @@ -15,8 +15,6 @@ pub enum AppMsg { Streamer(StreamerMsg), Device(AudioDevice), Adapter(NetworkAdapter), - PortTextInput(String), - PortSave, Connect, Stop, ToggleSettingsWindow, @@ -55,6 +53,8 @@ pub enum ConfigMsg { Amplify(bool), AmplifyValue(f32), ToggleAboutWindow, + PortTextInput(String), + PortSave, } #[derive(Debug, Clone, PartialEq, Eq, Copy)] diff --git a/RustApp/src/ui/view.rs b/RustApp/src/ui/view.rs index c6f8aed3..328df2b2 100644 --- a/RustApp/src/ui/view.rs +++ b/RustApp/src/ui/view.rs @@ -44,9 +44,9 @@ pub fn main_window(app: &AppState) -> Element<'_, AppMsg> { column() .width(Length::FillPortion(1)) .height(Length::Fill) + .spacing(35) .align_x(Horizontal::Center) .push(network_adapter(app)) - .push(port(app)) .push(audio(app)) .push(vertical_space()) .push(connection_type(app)), @@ -159,25 +159,6 @@ fn network_adapter(app: &AppState) -> Element<'_, AppMsg> { .into() } -fn port(app: &AppState) -> Element<'_, AppMsg> { - column() - .spacing(20) - .align_x(Horizontal::Center) - .push(text::title4(fl!("port"))) - .push( - row() - .width(Length::Fill) - .spacing(5) - .push( - text_input("", &app.port_input) - .on_input(AppMsg::PortTextInput) - .width(Length::Fixed(150.0)), - ) - .push(button::text(fl!("save")).on_press(AppMsg::PortSave)), - ) - .into() -} - fn connection_type(app: &AppState) -> Element<'_, AppMsg> { let connection_mode = &app.config.data().connection_mode; @@ -299,6 +280,22 @@ pub fn settings_window(app: &AppState) -> Element<'_, ConfigMsg> { .push(horizontal_space()), ), ) + .push( + settings::section().title(fl!("title_connection")).add( + row() + .width(Length::Fill) + .align_y(Vertical::Center) + .spacing(5) + .push(text(fl!("port"))) + .push(horizontal_space()) + .push( + text_input("", &app.port_input) + .on_input(ConfigMsg::PortTextInput) + .width(Length::Fixed(150.0)), + ) + .push(button::text(fl!("save")).on_press(ConfigMsg::PortSave)), + ), + ) .push( settings::section() .title(fl!("denoise")) From 4d10444209c3945129e8785da1880286b2a5d763 Mon Sep 17 00:00:00 2001 From: wiiznokes <78230769+wiiznokes@users.noreply.github.com> Date: Sun, 1 Feb 2026 15:36:11 +0100 Subject: [PATCH 03/11] Add mute button on Android --- .../domain/audio/MicAudioManager.kt | 16 +++++ .../AndroidMic/domain/service/Command.kt | 65 ++++++++++++------ .../domain/service/ForegroundService.kt | 46 ++++++++----- .../AndroidMic/ui/MainViewModel.kt | 32 +++++++-- .../AndroidMic/ui/home/HomeScreen.kt | 66 +++++++++++++++++-- .../app/src/main/res/values-fr/strings.xml | 2 +- .../src/main/res/values-zh-rCN/strings.xml | 1 - Android/app/src/main/res/values/strings.xml | 2 +- 8 files changed, 177 insertions(+), 53 deletions(-) diff --git a/Android/app/src/main/java/io/github/teamclouday/AndroidMic/domain/audio/MicAudioManager.kt b/Android/app/src/main/java/io/github/teamclouday/AndroidMic/domain/audio/MicAudioManager.kt index 357257c6..988ebb67 100644 --- a/Android/app/src/main/java/io/github/teamclouday/AndroidMic/domain/audio/MicAudioManager.kt +++ b/Android/app/src/main/java/io/github/teamclouday/AndroidMic/domain/audio/MicAudioManager.kt @@ -41,6 +41,8 @@ class MicAudioManager( private val bufferFloatConvert: ByteBuffer private var streamJob: Job? = null + private var isMuted = false + init { // check microphone require(ctx.packageManager.hasSystemFeature(PackageManager.FEATURE_MICROPHONE)) { @@ -92,6 +94,12 @@ class MicAudioManager( // launch in scope so infinite loop will be canceled when scope exits streamJob = scope.launch { while (true) { + + if (isMuted) { + delay(RECORD_DELAY_MS) + continue + } + if (recorder.state != AudioRecord.STATE_INITIALIZED || recorder.recordingState != AudioRecord.RECORDSTATE_RECORDING) { delay(RECORD_DELAY_MS) continue @@ -143,6 +151,14 @@ class MicAudioManager( } } + fun mute() { + isMuted = true + } + + fun unmute() { + isMuted = false + } + // start recording fun start() { recorder.startRecording() diff --git a/Android/app/src/main/java/io/github/teamclouday/AndroidMic/domain/service/Command.kt b/Android/app/src/main/java/io/github/teamclouday/AndroidMic/domain/service/Command.kt index d3481652..d0f17030 100644 --- a/Android/app/src/main/java/io/github/teamclouday/AndroidMic/domain/service/Command.kt +++ b/Android/app/src/main/java/io/github/teamclouday/AndroidMic/domain/service/Command.kt @@ -12,10 +12,16 @@ import io.github.teamclouday.AndroidMic.utils.Either import io.github.teamclouday.AndroidMic.utils.checkIp import io.github.teamclouday.AndroidMic.utils.checkPort - +/** + * Service -> UI + */ private const val ID_MSG: String = "ID_MSG" -private const val ID_STATE: String = "ID_STATE" +private const val ID_CONNECTION_STATE: String = "ID_CONNECTION_STATE" +private const val ID_MUTE_STATE: String = "ID_MUTE_STATE" +/** + * UI -> Service + */ private const val ID_MODE: String = "ID_MODE" private const val ID_IP: String = "ID_IP" @@ -27,7 +33,7 @@ private const val ID_AUDIO_FORMAT: String = "ID_AUDIO_FORMAT" /** - * Commands UI -> Service + * UI -> Service */ enum class Command { StartStream, @@ -36,18 +42,12 @@ enum class Command { // called when the ui is bind BindCheck, -} - -fun Bundle.getOrdinal(key: String): Int? { - val v = this.getInt(key, Int.MIN_VALUE); - return if (v == Int.MIN_VALUE) { - null - } else { - v - } + Mute, + Unmute, } + data class CommandData( val command: Command, val mode: Mode? = null, @@ -134,29 +134,38 @@ data class CommandData( /** - * Response Service -> UI + * Service -> UI */ -enum class Response { +enum class ResponseKind { Standard, } -enum class ServiceState { +private enum class ConnectionState { Connected, - Disconnected, + Disconnected; +} + +private enum class MuteState { + Muted, + Unmuted, } data class ResponseData( - val state: ServiceState? = null, val msg: String? = null, - val kind: Response = Response.Standard, + val isConnected: Boolean? = null, + val isMuted: Boolean? = null, + val kind: ResponseKind = ResponseKind.Standard, ) { companion object { fun fromMessage(msg: Message): ResponseData { return ResponseData( - kind = Response.entries[msg.what], - state = msg.data.getOrdinal(ID_STATE)?.let { ServiceState.entries[it] }, + kind = ResponseKind.entries[msg.what], + isConnected = msg.data.getOrdinal(ID_CONNECTION_STATE) + ?.let { ConnectionState.entries[it] == ConnectionState.Connected }, + isMuted = msg.data.getOrdinal(ID_MUTE_STATE) + ?.let { MuteState.entries[it] == MuteState.Muted }, msg = msg.data.getString(ID_MSG) ) } @@ -168,7 +177,13 @@ data class ResponseData( val r = Bundle() this.msg?.let { r.putString(ID_MSG, it) } - this.state?.let { r.putInt(ID_STATE, it.ordinal) } + this.isConnected?.let { + r.putInt( + ID_CONNECTION_STATE, + (if (it) ConnectionState.Connected else ConnectionState.Disconnected).ordinal + ) + } + this.isMuted?.let { r.putInt(ID_MUTE_STATE, (if (it) MuteState.Muted else MuteState.Unmuted).ordinal) } val reply = Message.obtain() reply.data = r @@ -180,4 +195,12 @@ data class ResponseData( } +fun Bundle.getOrdinal(key: String): Int? { + val v = this.getInt(key, Int.MIN_VALUE); + return if (v == Int.MIN_VALUE) { + null + } else { + v + } +} diff --git a/Android/app/src/main/java/io/github/teamclouday/AndroidMic/domain/service/ForegroundService.kt b/Android/app/src/main/java/io/github/teamclouday/AndroidMic/domain/service/ForegroundService.kt index f0a84080..7d673269 100644 --- a/Android/app/src/main/java/io/github/teamclouday/AndroidMic/domain/service/ForegroundService.kt +++ b/Android/app/src/main/java/io/github/teamclouday/AndroidMic/domain/service/ForegroundService.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.launch data class ServiceStates( var isStreamStarted: Boolean = false, var isAudioStarted: Boolean = false, + var isMuted: Boolean = false, var mode: Mode = Mode.WIFI ) @@ -52,6 +53,16 @@ class ForegroundService : Service() { Command.BindCheck -> { uiMessenger = msg.replyTo } + + Command.Mute -> { + states.isMuted = true + managerAudio?.mute() + } + + Command.Unmute -> { + states.isMuted = false + managerAudio?.unmute() + } } } @@ -181,13 +192,15 @@ class ForegroundService : Service() { // start streaming private fun startStream(msg: CommandData, replyTo: Messenger) { + states.isMuted = false // check connection state if (states.isStreamStarted) { reply( replyTo, ResponseData( - ServiceState.Connected, - this.getString(R.string.stream_already_started) + msg = this.getString(R.string.stream_already_started), + isConnected = true, + isMuted = states.isMuted, ) ) return @@ -206,8 +219,9 @@ class ForegroundService : Service() { reply( replyTo, ResponseData( - ServiceState.Disconnected, - applicationContext.getString(R.string.error) + e.message + msg = applicationContext.getString(R.string.error) + e.message, + isConnected = false, + isMuted = states.isMuted, ) ) return @@ -219,8 +233,9 @@ class ForegroundService : Service() { reply( replyTo, ResponseData( - ServiceState.Disconnected, - applicationContext.getString(R.string.failed_to_connect) + msg = applicationContext.getString(R.string.failed_to_connect), + isConnected = false, + isMuted = states.isMuted, ) ) shutdownStream() @@ -245,8 +260,9 @@ class ForegroundService : Service() { reply( replyTo, ResponseData( - ServiceState.Connected, - applicationContext.getString(R.string.connected_device) + managerStream?.getInfo() + msg = applicationContext.getString(R.string.connected_device) + managerStream?.getInfo(), + isConnected = true, + isMuted = states.isMuted, ) ) @@ -263,8 +279,9 @@ class ForegroundService : Service() { reply( uiMessenger, ResponseData( - ServiceState.Disconnected, - applicationContext.getString(R.string.device_disconnected) + msg = applicationContext.getString(R.string.device_disconnected), + isConnected = false, + isMuted = states.isMuted, ) ) @@ -279,13 +296,6 @@ class ForegroundService : Service() { states.isStreamStarted = false } - private fun isConnected(): ServiceState { - return if (states.isStreamStarted) { - ServiceState.Connected - } else { - ServiceState.Disconnected - } - } // start mic private fun startAudio(msg: CommandData, replyTo: Messenger): Boolean { @@ -349,6 +359,6 @@ class ForegroundService : Service() { private fun getStatus(replyTo: Messenger) { Log.d(TAG, "getStatus") - reply(replyTo, ResponseData(isConnected())) + reply(replyTo, ResponseData(isConnected = states.isStreamStarted, isMuted = states.isMuted)) } } \ No newline at end of file diff --git a/Android/app/src/main/java/io/github/teamclouday/AndroidMic/ui/MainViewModel.kt b/Android/app/src/main/java/io/github/teamclouday/AndroidMic/ui/MainViewModel.kt index 138e307e..d28ed991 100644 --- a/Android/app/src/main/java/io/github/teamclouday/AndroidMic/ui/MainViewModel.kt +++ b/Android/app/src/main/java/io/github/teamclouday/AndroidMic/ui/MainViewModel.kt @@ -21,9 +21,8 @@ import io.github.teamclouday.AndroidMic.SampleRates import io.github.teamclouday.AndroidMic.Themes import io.github.teamclouday.AndroidMic.domain.service.Command import io.github.teamclouday.AndroidMic.domain.service.CommandData -import io.github.teamclouday.AndroidMic.domain.service.Response +import io.github.teamclouday.AndroidMic.domain.service.ResponseKind import io.github.teamclouday.AndroidMic.domain.service.ResponseData -import io.github.teamclouday.AndroidMic.domain.service.ServiceState import io.github.teamclouday.AndroidMic.ui.utils.UiHelper import io.github.teamclouday.AndroidMic.utils.Either import kotlinx.coroutines.launch @@ -51,6 +50,8 @@ class MainViewModel : ViewModel() { val isStreamStarted = mutableStateOf(false) val isButtonConnectClickable = mutableStateOf(false) + val isMuted = mutableStateOf(false) + init { Log.d(TAG, "init") } @@ -61,10 +62,14 @@ class MainViewModel : ViewModel() { val data = ResponseData.fromMessage(msg) when (data.kind) { - Response.Standard -> { - data.state?.let { + ResponseKind.Standard -> { + data.isConnected?.let { isButtonConnectClickable.value = true - isStreamStarted.value = it == ServiceState.Connected + isStreamStarted.value = it + } + + data.isMuted?.let { + isMuted.value = it } data.msg?.let { @@ -89,14 +94,31 @@ class MainViewModel : ViewModel() { isStreamStarted.value = false isButtonConnectClickable.value = false + isMuted.value = false val msg = CommandData(Command.BindCheck).toCommandMsg() msg.replyTo = messenger service?.send(msg) } + fun onMuteSwitch() { + if (!isBound) return + + val message = if (isMuted.value) { + isMuted.value = false + CommandData(Command.Unmute) + } else { + isMuted.value = true + CommandData(Command.Mute) + }.toCommandMsg() + + message.replyTo = messenger + service?.send(message) + } + fun onConnectButton(): Dialogs? { if (!isBound) return null + isMuted.value = false val message = if (isStreamStarted.value) { Log.d(TAG, "onConnectButton: stop stream") CommandData(Command.StopStream) diff --git a/Android/app/src/main/java/io/github/teamclouday/AndroidMic/ui/home/HomeScreen.kt b/Android/app/src/main/java/io/github/teamclouday/AndroidMic/ui/home/HomeScreen.kt index 82fa4058..72cab39f 100644 --- a/Android/app/src/main/java/io/github/teamclouday/AndroidMic/ui/home/HomeScreen.kt +++ b/Android/app/src/main/java/io/github/teamclouday/AndroidMic/ui/home/HomeScreen.kt @@ -12,12 +12,15 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight 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.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.DrawerValue @@ -25,6 +28,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable @@ -103,12 +107,23 @@ fun HomeScreen( .fillMaxWidth() .padding(all = 15.dp) ) + + Spacer(modifier = Modifier.height(40.dp)) ConnectButton( vm = vm, - modifier = Modifier - .padding(vertical = 40.dp), + modifier = Modifier, openAppSettings = openAppSettings ) + + if (vm.isStreamStarted.value) { + Spacer(modifier = Modifier.height(15.dp)) + + AudioSwitch( + vm = vm, + ) + } + + Spacer(modifier = Modifier.height(40.dp)) } } else { @@ -128,12 +143,26 @@ fun HomeScreen( .padding(all = 15.dp) ) - ConnectButton( - vm = vm, + Column( modifier = Modifier .padding(all = 15.dp), - openAppSettings = openAppSettings - ) + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + ConnectButton( + vm = vm, + modifier = Modifier, + openAppSettings = openAppSettings + ) + + + if (vm.isStreamStarted.value) { + Spacer(modifier = Modifier.height(15.dp)) + AudioSwitch( + vm = vm, + ) + } + } } } @@ -170,6 +199,31 @@ private fun Log( } } +@Composable +private fun AudioSwitch( + vm: MainViewModel, +) { + + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + + Text( + text = stringResource(id = R.string.turn_audio), + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.labelLarge + ) + Spacer(Modifier.width(12.dp)) + Switch( + checked = vm.isMuted.value, + onCheckedChange = { + vm.onMuteSwitch() + } + ) + } +} + @Composable private fun ConnectButton( vm: MainViewModel, diff --git a/Android/app/src/main/res/values-fr/strings.xml b/Android/app/src/main/res/values-fr/strings.xml index abcfd19c..9617c432 100644 --- a/Android/app/src/main/res/values-fr/strings.xml +++ b/Android/app/src/main/res/values-fr/strings.xml @@ -4,7 +4,7 @@ --> Connecter Deconnecter - Enregister l\'audio + Muter l\'audio 连接 断开连接 - 录制音频 Connect Disconnect - Record Audio + Mute Audio