From 31cf45ce35a5ad4340aa709e4a574e79cdc465b3 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Mon, 8 Jun 2026 22:51:41 +0900 Subject: [PATCH 01/14] feat(protocol): classify poke notification strength --- crates/chanora_protocol/src/adapter.rs | 43 ++++- crates/chanora_protocol/src/dto.rs | 4 + crates/chanora_protocol/src/lib.rs | 4 +- crates/chanora_protocol/src/poke_limiter.rs | 174 ++++++++++++++++++++ 4 files changed, 216 insertions(+), 9 deletions(-) create mode 100644 crates/chanora_protocol/src/poke_limiter.rs diff --git a/crates/chanora_protocol/src/adapter.rs b/crates/chanora_protocol/src/adapter.rs index 10628fe..d5e9ee1 100644 --- a/crates/chanora_protocol/src/adapter.rs +++ b/crates/chanora_protocol/src/adapter.rs @@ -41,6 +41,7 @@ use crate::dto::{ ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget, ProtocolDelta, ServerActivity, ServerSnapshot, }; +use crate::poke_limiter::PokeLimiter; use crate::ProtocolError; const SPEAKING_ACTIVITY_WINDOW: Duration = Duration::from_millis(750); @@ -713,6 +714,7 @@ async fn connection_task( // reply channel — at most 3 s of pending state per move. let mut pending_moves: PendingMoves = HashMap::new(); let mut voice_activity: HashMap = HashMap::new(); + let mut poke_limiter = PokeLimiter::new(); // Main loop: pump events, service requests, forward voice. loop { @@ -742,6 +744,7 @@ async fn connection_task( &channels.activity, &channels.delta, &mut pending_moves, + &mut poke_limiter, ), }, Ok(Some(Err(e))) => { @@ -841,6 +844,7 @@ async fn connection_task( &channels, &mut pending_moves, &mut voice_activity, + &mut poke_limiter, ) .await; let _ = reply.send(r); @@ -903,6 +907,7 @@ fn handle_non_audio_stream_item( activity_tx: &mpsc::Sender, delta_tx: &mpsc::Sender, pending_moves: &mut PendingMoves, + poke_limiter: &mut PokeLimiter, ) { match item { StreamItem::BookEvents(events) => { @@ -964,19 +969,25 @@ fn handle_non_audio_stream_item( message, } = ev { - let mapped = match target { - tsclientlib::MessageTarget::Server => MessageTarget::Server, - tsclientlib::MessageTarget::Channel => MessageTarget::Channel, + let (mapped, poke_strength) = match target { + tsclientlib::MessageTarget::Server => (MessageTarget::Server, None), + tsclientlib::MessageTarget::Channel => (MessageTarget::Channel, None), tsclientlib::MessageTarget::Client(id) => { - MessageTarget::Client(id.0 as u64) + (MessageTarget::Client(id.0 as u64), None) + } + tsclientlib::MessageTarget::Poke(id) => { + let own_client_id = + con.get_state().ok().map(|state| state.own_client.0 as u64); + let strength = poke_limiter.record(invoker.id.0 as u64, own_client_id); + (MessageTarget::Poke(id.0 as u64), Some(strength)) } - tsclientlib::MessageTarget::Poke(id) => MessageTarget::Poke(id.0 as u64), }; let _ = chat_tx.try_send(ChatMessage { sender_id: ClientId(invoker.id.0 as u64), sender_name: sanitize(&invoker.name), message: sanitize(&message), target: mapped, + poke_strength, }); } } @@ -1164,6 +1175,7 @@ async fn fetch_client_profile( channels: &EventChannels, pending_moves: &mut PendingMoves, voice_activity: &mut HashMap, + poke_limiter: &mut PokeLimiter, ) -> Result { let target_id = TsClientId(client_id as u16); @@ -1209,6 +1221,7 @@ async fn fetch_client_profile( channels, pending_moves, voice_activity, + poke_limiter, ) .await; } @@ -1219,6 +1232,7 @@ async fn fetch_client_profile( channels, pending_moves, voice_activity, + poke_limiter, ) .await; } @@ -1233,6 +1247,7 @@ async fn fetch_client_profile( channels, pending_moves, voice_activity, + poke_limiter, ) .await { @@ -1251,6 +1266,7 @@ async fn fetch_client_profile( channels, pending_moves, voice_activity, + poke_limiter, ) .await { @@ -1264,9 +1280,16 @@ async fn fetch_client_profile( } let db_info = if refresh_plan.needs_client_db_info { - request_client_db_info(con, database_id, channels, pending_moves, voice_activity) - .await - .ok() + request_client_db_info( + con, + database_id, + channels, + pending_moves, + voice_activity, + poke_limiter, + ) + .await + .ok() } else { None }; @@ -1426,6 +1449,7 @@ async fn request_messages( channels: &EventChannels, pending_moves: &mut PendingMoves, voice_activity: &mut HashMap, + poke_limiter: &mut PokeLimiter, ) -> Result, ProtocolError> { let handle = command .send_with_result(con) @@ -1468,6 +1492,7 @@ async fn request_messages( &channels.activity, &channels.delta, pending_moves, + poke_limiter, ), } } @@ -1479,6 +1504,7 @@ async fn request_client_db_info( channels: &EventChannels, pending_moves: &mut PendingMoves, voice_activity: &mut HashMap, + poke_limiter: &mut PokeLimiter, ) -> Result { let messages = request_messages( con, @@ -1486,6 +1512,7 @@ async fn request_client_db_info( channels, pending_moves, voice_activity, + poke_limiter, ) .await?; for message in messages { diff --git a/crates/chanora_protocol/src/dto.rs b/crates/chanora_protocol/src/dto.rs index fdf076b..142d1ff 100644 --- a/crates/chanora_protocol/src/dto.rs +++ b/crates/chanora_protocol/src/dto.rs @@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize}; +pub use crate::poke_limiter::PokeStrength; + /// Opaque server-side channel identifier. Internal representation is /// the upstream u64 but callers must treat it as opaque. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -54,6 +56,8 @@ pub struct ChatMessage { pub message: String, /// Target scope of this message. pub target: MessageTarget, + /// Strength classification for poke notifications. + pub poke_strength: Option, } /// A server-activity notification derived from TeamSpeak bookkeeping events. diff --git a/crates/chanora_protocol/src/lib.rs b/crates/chanora_protocol/src/lib.rs index 6b6a7ef..3d5d409 100644 --- a/crates/chanora_protocol/src/lib.rs +++ b/crates/chanora_protocol/src/lib.rs @@ -35,12 +35,14 @@ mod adapter; mod dto; +pub mod poke_limiter; pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe}; pub use dto::{ ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget, - ProtocolDelta, ServerActivity, ServerSnapshot, + PokeStrength, ProtocolDelta, ServerActivity, ServerSnapshot, }; +pub use poke_limiter::PokeLimiter; // Re-export the upstream voice types so chanora_audio can build outbound // voice packets without taking a direct dependency on tsclientlib / diff --git a/crates/chanora_protocol/src/poke_limiter.rs b/crates/chanora_protocol/src/poke_limiter.rs new file mode 100644 index 0000000..bb77c2c --- /dev/null +++ b/crates/chanora_protocol/src/poke_limiter.rs @@ -0,0 +1,174 @@ +//! Per-connection poke strength classification. + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +/// Notification strength assigned to an inbound poke. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum PokeStrength { + /// Poke should be surfaced at full strength. + Strong, + /// Poke is rate-limited but below overflow severity. + Suppressed, + /// Poke remains suppressed after repeated suppressed pokes. + SuppressedOverflow, +} + +/// Per-connection poke limiter. +#[derive(Debug)] +pub struct PokeLimiter { + window: Duration, + entries: HashMap, +} + +#[derive(Debug)] +struct PokeEntry { + tokens: u8, + last_refill: Instant, + suppressed_in_window: u8, +} + +impl PokeLimiter { + const CAPACITY: u8 = 2; + const OVERFLOW_THRESHOLD: u8 = 3; + + /// Create a limiter using the default five-minute refill interval. + pub fn new() -> Self { + Self { + window: Duration::from_secs(5 * 60), + entries: HashMap::new(), + } + } + + /// Record a poke at the current instant. + pub fn record(&mut self, sender_id: u64, own_client_id: Option) -> PokeStrength { + self.record_at(sender_id, own_client_id, Instant::now()) + } + + /// Record a poke at an injected instant. + pub fn record_at( + &mut self, + sender_id: u64, + own_client_id: Option, + now: Instant, + ) -> PokeStrength { + if own_client_id == Some(sender_id) { + return PokeStrength::Suppressed; + } + + let entry = self.entries.entry(sender_id).or_insert(PokeEntry { + tokens: Self::CAPACITY, + last_refill: now, + suppressed_in_window: 0, + }); + + if now.duration_since(entry.last_refill) >= self.window { + entry.tokens = Self::CAPACITY; + entry.last_refill = now; + entry.suppressed_in_window = 0; + } + + if entry.tokens > 0 { + entry.tokens -= 1; + return PokeStrength::Strong; + } + + entry.suppressed_in_window = entry.suppressed_in_window.saturating_add(1); + if entry.suppressed_in_window >= Self::OVERFLOW_THRESHOLD { + PokeStrength::SuppressedOverflow + } else { + PokeStrength::Suppressed + } + } +} + +impl Default for PokeLimiter { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn first_two_pokes_are_strong_third_is_suppressed() { + let mut limiter = PokeLimiter::new(); + let now = Instant::now(); + + assert_eq!(limiter.record_at(7, Some(1), now), PokeStrength::Strong); + assert_eq!( + limiter.record_at(7, Some(1), now + Duration::from_secs(1)), + PokeStrength::Strong + ); + assert_eq!( + limiter.record_at(7, Some(1), now + Duration::from_secs(2)), + PokeStrength::Suppressed + ); + } + + #[test] + fn self_poke_is_suppressed_without_consuming_token() { + let mut limiter = PokeLimiter::new(); + let now = Instant::now(); + + assert_eq!(limiter.record_at(7, Some(7), now), PokeStrength::Suppressed); + assert_eq!( + limiter.record_at(7, Some(1), now + Duration::from_secs(1)), + PokeStrength::Strong + ); + assert_eq!( + limiter.record_at(7, Some(1), now + Duration::from_secs(2)), + PokeStrength::Strong + ); + } + + #[test] + fn overflow_after_three_suppressed_pokes_in_five_minutes() { + let mut limiter = PokeLimiter::new(); + let now = Instant::now(); + + assert_eq!(limiter.record_at(7, Some(1), now), PokeStrength::Strong); + assert_eq!( + limiter.record_at(7, Some(1), now + Duration::from_secs(1)), + PokeStrength::Strong + ); + assert_eq!( + limiter.record_at(7, Some(1), now + Duration::from_secs(2)), + PokeStrength::Suppressed + ); + assert_eq!( + limiter.record_at(7, Some(1), now + Duration::from_secs(3)), + PokeStrength::Suppressed + ); + assert_eq!( + limiter.record_at(7, Some(1), now + Duration::from_secs(4)), + PokeStrength::SuppressedOverflow + ); + } + + #[test] + fn refill_after_interval_uses_injected_time_without_sleeping() { + let mut limiter = PokeLimiter::new(); + let now = Instant::now(); + + assert_eq!(limiter.record_at(7, Some(1), now), PokeStrength::Strong); + assert_eq!( + limiter.record_at(7, Some(1), now + Duration::from_secs(1)), + PokeStrength::Strong + ); + assert_eq!( + limiter.record_at(7, Some(1), now + Duration::from_secs(2)), + PokeStrength::Suppressed + ); + assert_eq!( + limiter.record_at(7, Some(1), now + Duration::from_secs(5 * 60)), + PokeStrength::Strong + ); + assert_eq!( + limiter.record_at(7, Some(1), now + Duration::from_secs(5 * 60 + 1)), + PokeStrength::Strong + ); + } +} From 5c7a4b64c906fbfdb1c9bcc6cda10be0888367e6 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Mon, 8 Jun 2026 22:52:08 +0900 Subject: [PATCH 02/14] feat(core): propagate poke strength events --- core/chanora_core/src/events.rs | 4 +++- core/chanora_core/src/lib.rs | 3 ++- crates/chanora_state/src/lib.rs | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/core/chanora_core/src/events.rs b/core/chanora_core/src/events.rs index 20201c2..ca3c71f 100644 --- a/core/chanora_core/src/events.rs +++ b/core/chanora_core/src/events.rs @@ -1,5 +1,5 @@ use chanora_audio::{AudioRoute, PttBackendDescriptor}; -use chanora_protocol::MessageTarget; +use chanora_protocol::{MessageTarget, PokeStrength}; /// Privacy-safe snapshot of the active PTT capability. #[derive(Debug, Clone, PartialEq, Eq)] @@ -139,6 +139,8 @@ pub enum SessionEvent { message: String, /// Target scope (server/channel/private/poke). target: MessageTarget, + /// Poke notification strength, present only for poke messages. + poke_strength: Option, }, /// Human-readable TeamSpeak-style server activity. ServerActivity { diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index b18017a..77ed1fc 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -68,7 +68,7 @@ pub use chanora_diagnostics::{ }; pub use chanora_protocol::{ ChannelInfo, ChatMessage, ClientInfo, ClientProfile, ConnectConfig, DisconnectReason, - MessageTarget, ProtocolError, ServerActivity, ServerSnapshot, + MessageTarget, PokeStrength, ProtocolError, ServerActivity, ServerSnapshot, }; pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore}; pub use events::{ @@ -1674,6 +1674,7 @@ fn spawn_event_forwarders( sender_name: msg.sender_name, message: msg.message, target: msg.target, + poke_strength: msg.poke_strength, }); } }); diff --git a/crates/chanora_state/src/lib.rs b/crates/chanora_state/src/lib.rs index 75e618a..5a624ea 100644 --- a/crates/chanora_state/src/lib.rs +++ b/crates/chanora_state/src/lib.rs @@ -615,6 +615,7 @@ mod tests { sender_name: "Alice".into(), message: "hello".into(), target: MessageTarget::Channel, + poke_strength: None, }; let disconnected = reduce(&mut state, StateEvent::ChatReceived(msg.clone())); assert!(disconnected.deltas.is_empty()); From cf64274fe6f2c700eea5f4cfee4489a853b3314e Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Mon, 8 Jun 2026 22:52:33 +0900 Subject: [PATCH 03/14] feat(bridge): expose poke strength to Flutter --- crates/chanora_bridge/src/api.rs | 25 +++++++ crates/chanora_bridge/src/frb_generated.rs | 80 ++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/crates/chanora_bridge/src/api.rs b/crates/chanora_bridge/src/api.rs index 9f989c2..4c85c4f 100644 --- a/crates/chanora_bridge/src/api.rs +++ b/crates/chanora_bridge/src/api.rs @@ -1695,6 +1695,8 @@ pub enum BridgeEvent { message: String, /// Target scope (server/channel/private/poke). target: BridgeMessageTarget, + /// Poke notification strength, present only for poke messages. + poke_strength: Option, }, /// Human-readable server activity surfaced from protocol bookkeeping events. ServerActivity { @@ -1812,6 +1814,27 @@ impl From for BridgeMessageTarget { } } +/// Bridge poke notification strength. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BridgePokeStrength { + /// Poke should be surfaced at full strength. + Strong, + /// Poke is rate-limited but below overflow severity. + Suppressed, + /// Poke remains suppressed after repeated suppressed pokes. + SuppressedOverflow, +} + +impl From for BridgePokeStrength { + fn from(strength: chanora_core::PokeStrength) -> Self { + match strength { + chanora_core::PokeStrength::Strong => Self::Strong, + chanora_core::PokeStrength::Suppressed => Self::Suppressed, + chanora_core::PokeStrength::SuppressedOverflow => Self::SuppressedOverflow, + } + } +} + /// Bridge mirror of core join projection sync state. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BridgeVoiceJoinSyncState { @@ -1958,11 +1981,13 @@ impl From for BridgeEvent { sender_name, message, target, + poke_strength, } => BridgeEvent::ChatMessage { sender_id, sender_name, message, target: target.into(), + poke_strength: poke_strength.map(Into::into), }, chanora_core::SessionEvent::ServerActivity { message } => { BridgeEvent::ServerActivity { message } diff --git a/crates/chanora_bridge/src/frb_generated.rs b/crates/chanora_bridge/src/frb_generated.rs index 00b6301..b349fbc 100644 --- a/crates/chanora_bridge/src/frb_generated.rs +++ b/crates/chanora_bridge/src/frb_generated.rs @@ -2292,11 +2292,14 @@ impl SseDecode for crate::api::BridgeEvent { let mut var_senderName = ::sse_decode(deserializer); let mut var_message = ::sse_decode(deserializer); let mut var_target = ::sse_decode(deserializer); + let mut var_pokeStrength = + >::sse_decode(deserializer); return crate::api::BridgeEvent::ChatMessage { sender_id: var_senderId, sender_name: var_senderName, message: var_message, target: var_target, + poke_strength: var_pokeStrength, }; } 11 => { @@ -2454,6 +2457,19 @@ impl SseDecode for crate::api::BridgeNetworkState { } } +impl SseDecode for crate::api::BridgePokeStrength { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return match inner { + 0 => crate::api::BridgePokeStrength::Strong, + 1 => crate::api::BridgePokeStrength::Suppressed, + 2 => crate::api::BridgePokeStrength::SuppressedOverflow, + _ => unreachable!("Invalid variant for BridgePokeStrength: {}", inner), + }; + } +} + impl SseDecode for crate::api::BridgePttBinding { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -2680,6 +2696,17 @@ impl SseDecode for Option { } } +impl SseDecode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + if (::sse_decode(deserializer)) { + return Some(::sse_decode(deserializer)); + } else { + return None; + } + } +} + impl SseDecode for Option { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -3299,12 +3326,14 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent { sender_name, message, target, + poke_strength, } => [ 10.into_dart(), sender_id.into_into_dart().into_dart(), sender_name.into_into_dart().into_dart(), message.into_into_dart().into_dart(), target.into_into_dart().into_dart(), + poke_strength.into_into_dart().into_dart(), ] .into_dart(), crate::api::BridgeEvent::ServerActivity { message } => { @@ -3484,6 +3513,28 @@ impl flutter_rust_bridge::IntoIntoDart } } // Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::BridgePokeStrength { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + match self { + Self::Strong => 0.into_dart(), + Self::Suppressed => 1.into_dart(), + Self::SuppressedOverflow => 2.into_dart(), + _ => unreachable!(), + } + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::BridgePokeStrength +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::BridgePokeStrength +{ + fn into_into_dart(self) -> crate::api::BridgePokeStrength { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::BridgePttBinding { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { [ @@ -4053,12 +4104,14 @@ impl SseEncode for crate::api::BridgeEvent { sender_name, message, target, + poke_strength, } => { ::sse_encode(10, serializer); ::sse_encode(sender_id, serializer); ::sse_encode(sender_name, serializer); ::sse_encode(message, serializer); ::sse_encode(target, serializer); + >::sse_encode(poke_strength, serializer); } crate::api::BridgeEvent::ServerActivity { message } => { ::sse_encode(11, serializer); @@ -4214,6 +4267,23 @@ impl SseEncode for crate::api::BridgeNetworkState { } } +impl SseEncode for crate::api::BridgePokeStrength { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode( + match self { + crate::api::BridgePokeStrength::Strong => 0, + crate::api::BridgePokeStrength::Suppressed => 1, + crate::api::BridgePokeStrength::SuppressedOverflow => 2, + _ => { + unimplemented!(""); + } + }, + serializer, + ); + } +} + impl SseEncode for crate::api::BridgePttBinding { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -4429,6 +4499,16 @@ impl SseEncode for Option { } } +impl SseEncode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.is_some(), serializer); + if let Some(value) = self { + ::sse_encode(value, serializer); + } + } +} + impl SseEncode for Option { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { From 6af2ed9ab5764f54956cb47fa2faf5e00696b234 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Mon, 8 Jun 2026 22:53:01 +0900 Subject: [PATCH 04/14] feat(flutter): regenerate poke strength bridge --- apps/chanora_flutter/lib/src/rust/api.dart | 94 ++++++++++++++++++- .../lib/src/rust/api.freezed.dart | 60 +++++++++--- .../lib/src/rust/frb_generated.dart | 93 ++++++++++++++++++ .../lib/src/rust/frb_generated.io.dart | 44 +++++++++ .../lib/src/rust/frb_generated.web.dart | 44 +++++++++ 5 files changed, 317 insertions(+), 18 deletions(-) diff --git a/apps/chanora_flutter/lib/src/rust/api.dart b/apps/chanora_flutter/lib/src/rust/api.dart index b0e13e6..57aaef5 100644 --- a/apps/chanora_flutter/lib/src/rust/api.dart +++ b/apps/chanora_flutter/lib/src/rust/api.dart @@ -11,7 +11,7 @@ part 'api.freezed.dart'; // These functions are ignored because they are not marked as `pub`: `dispatch_platform_audio_event`, `install_panic_diagnostic_hook`, `log_file_path`, `log_sink`, `map_join_error_code`, `map_join_sync_state`, `open_log_file`, `permission_events`, `platform_audio_events`, `process`, `publish_permission_state`, `runtime`, `session`, `task_join_error`, `transmit_mode_from_u8` // These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `PlatformAudioEvent` -// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from` +// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from` // These functions are ignored (category: IgnoreBecauseExplicitAttribute): `from_kotlin_str`, `to_permission_gate` /// Return the platform-conventional log-file path as a string, or @@ -263,7 +263,8 @@ Future audioStats() => /// Subscribe to real-time microphone input level at ~30 Hz. /// Values are dBFS (-120 = silence, 0 = clipping). The stream ends -/// when the Dart subscriber cancels or the session is dropped. +/// when the Dart subscriber cancels, the session is dropped, or +/// the session becomes persistently unavailable. Stream inputLevelStream() => RustLib.instance.api.crateApiInputLevelStream(); @@ -1209,6 +1210,9 @@ sealed class BridgeEvent with _$BridgeEvent { /// Target scope (server/channel/private/poke). required BridgeMessageTarget target, + + /// Poke notification strength, present only for poke messages. + BridgePokeStrength? pokeStrength, }) = BridgeEvent_ChatMessage; /// Human-readable server activity surfaced from protocol bookkeeping events. @@ -1219,48 +1223,116 @@ sealed class BridgeEvent with _$BridgeEvent { /// Audio route changed (speaker/earpiece/BT/wired). const factory BridgeEvent.audioRouteChanged({ + /// New audio output route. required BridgeAudioRoute route, }) = BridgeEvent_AudioRouteChanged; + + /// A client moved to a different channel. const factory BridgeEvent.clientMoved({ + /// Unique client identifier. required BigInt clientId, + + /// Destination channel. required BigInt newChannelId, }) = BridgeEvent_ClientMoved; + + /// A new client connected. const factory BridgeEvent.clientJoined({ + /// Unique client identifier. required BigInt clientId, + + /// Channel the client joined. required BigInt channelId, + + /// Display nickname. required String name, + + /// Microphone muted state. required bool inputMuted, + + /// Speaker muted state. required bool outputMuted, + + /// True for server query (bot) clients. required bool isServerQuery, + + /// Client's talk power value. required int talkPower, + + /// Whether the server granted temporary talk power. required bool talkPowerGranted, }) = BridgeEvent_ClientJoined; + + /// A client disconnected. const factory BridgeEvent.clientLeft({ + /// Unique client identifier. required BigInt clientId, + + /// Display nickname at time of disconnect. required String name, }) = BridgeEvent_ClientLeft; + + /// Client properties changed. const factory BridgeEvent.clientUpdated({ + /// Unique client identifier. required BigInt clientId, + + /// Microphone muted state. required bool inputMuted, + + /// Speaker muted state. required bool outputMuted, + + /// True for server query (bot) clients. required bool isServerQuery, + + /// Client's talk power value. required int talkPower, + + /// Whether the server granted temporary talk power. required bool talkPowerGranted, }) = BridgeEvent_ClientUpdated; + + /// A new channel appeared. const factory BridgeEvent.channelAdded({ + /// Unique channel identifier. required BigInt id, + + /// Parent channel ID. required BigInt parent, + + /// Channel name. required String name, + + /// Predecessor channel ID within the same parent (TeamSpeak + /// linked-list ordering hint). Zero means first child. required PlatformInt64 order, + + /// Whether the channel requires a password. required bool hasPassword, + + /// Talk power required to speak; `None` means no restriction. int? neededTalkPower, }) = BridgeEvent_ChannelAdded; - const factory BridgeEvent.channelRemoved({required BigInt id}) = - BridgeEvent_ChannelRemoved; - const factory BridgeEvent.channelUpdated({ + + /// A channel was deleted. + const factory BridgeEvent.channelRemoved({ + /// Channel identifier. required BigInt id, + }) = BridgeEvent_ChannelRemoved; + + /// Channel properties changed. + const factory BridgeEvent.channelUpdated({ + /// Unique channel identifier. + required BigInt id, + + /// Channel name. required String name, + + /// Whether the channel requires a password. required bool hasPassword, + + /// Talk power required to speak; `None` means no restriction. int? neededTalkPower, }) = BridgeEvent_ChannelUpdated; } @@ -1306,6 +1378,18 @@ enum BridgeNetworkState { offline, } +/// Bridge poke notification strength. +enum BridgePokeStrength { + /// Poke should be surfaced at full strength. + strong, + + /// Poke is rate-limited but below overflow severity. + suppressed, + + /// Poke remains suppressed after repeated suppressed pokes. + suppressedOverflow, +} + /// Persisted PTT binding display state for the UI. class BridgePttBinding { /// Stable input category string (`""`, `"keyboard"`, or diff --git a/apps/chanora_flutter/lib/src/rust/api.freezed.dart b/apps/chanora_flutter/lib/src/rust/api.freezed.dart index 08eb80f..f49b1e8 100644 --- a/apps/chanora_flutter/lib/src/rust/api.freezed.dart +++ b/apps/chanora_flutter/lib/src/rust/api.freezed.dart @@ -173,7 +173,7 @@ return channelUpdated(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,TResult Function( String permission, PermissionStateKind state)? permissionState,TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target)? chatMessage,TResult Function( String message)? serverActivity,TResult Function( BridgeAudioRoute route)? audioRouteChanged,TResult Function( BigInt clientId, BigInt newChannelId)? clientMoved,TResult Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientJoined,TResult Function( BigInt clientId, String name)? clientLeft,TResult Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientUpdated,TResult Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower)? channelAdded,TResult Function( BigInt id)? channelRemoved,TResult Function( BigInt id, String name, bool hasPassword, int? neededTalkPower)? channelUpdated,required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,TResult Function( String permission, PermissionStateKind state)? permissionState,TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target, BridgePokeStrength? pokeStrength)? chatMessage,TResult Function( String message)? serverActivity,TResult Function( BridgeAudioRoute route)? audioRouteChanged,TResult Function( BigInt clientId, BigInt newChannelId)? clientMoved,TResult Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientJoined,TResult Function( BigInt clientId, String name)? clientLeft,TResult Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientUpdated,TResult Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower)? channelAdded,TResult Function( BigInt id)? channelRemoved,TResult Function( BigInt id, String name, bool hasPassword, int? neededTalkPower)? channelUpdated,required TResult orElse(),}) {final _that = this; switch (_that) { case BridgeEvent_Connected() when connected != null: return connected(_that.serverName);case BridgeEvent_Lost() when lost != null: @@ -186,7 +186,7 @@ return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case Bri return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null: return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null: return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null: -return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_ServerActivity() when serverActivity != null: +return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target,_that.pokeStrength);case BridgeEvent_ServerActivity() when serverActivity != null: return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null: return audioRouteChanged(_that.route);case BridgeEvent_ClientMoved() when clientMoved != null: return clientMoved(_that.clientId,_that.newChannelId);case BridgeEvent_ClientJoined() when clientJoined != null: @@ -213,7 +213,7 @@ return channelUpdated(_that.id,_that.name,_that.hasPassword,_that.neededTalkPowe /// } /// ``` -@optionalTypeArgs TResult when({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,required TResult Function( String permission, PermissionStateKind state) permissionState,required TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target) chatMessage,required TResult Function( String message) serverActivity,required TResult Function( BridgeAudioRoute route) audioRouteChanged,required TResult Function( BigInt clientId, BigInt newChannelId) clientMoved,required TResult Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted) clientJoined,required TResult Function( BigInt clientId, String name) clientLeft,required TResult Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted) clientUpdated,required TResult Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower) channelAdded,required TResult Function( BigInt id) channelRemoved,required TResult Function( BigInt id, String name, bool hasPassword, int? neededTalkPower) channelUpdated,}) {final _that = this; +@optionalTypeArgs TResult when({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,required TResult Function( String permission, PermissionStateKind state) permissionState,required TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target, BridgePokeStrength? pokeStrength) chatMessage,required TResult Function( String message) serverActivity,required TResult Function( BridgeAudioRoute route) audioRouteChanged,required TResult Function( BigInt clientId, BigInt newChannelId) clientMoved,required TResult Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted) clientJoined,required TResult Function( BigInt clientId, String name) clientLeft,required TResult Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted) clientUpdated,required TResult Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower) channelAdded,required TResult Function( BigInt id) channelRemoved,required TResult Function( BigInt id, String name, bool hasPassword, int? neededTalkPower) channelUpdated,}) {final _that = this; switch (_that) { case BridgeEvent_Connected(): return connected(_that.serverName);case BridgeEvent_Lost(): @@ -226,7 +226,7 @@ return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case Bri return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState(): return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState(): return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage(): -return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_ServerActivity(): +return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target,_that.pokeStrength);case BridgeEvent_ServerActivity(): return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged(): return audioRouteChanged(_that.route);case BridgeEvent_ClientMoved(): return clientMoved(_that.clientId,_that.newChannelId);case BridgeEvent_ClientJoined(): @@ -249,7 +249,7 @@ return channelUpdated(_that.id,_that.name,_that.hasPassword,_that.neededTalkPowe /// } /// ``` -@optionalTypeArgs TResult? whenOrNull({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,TResult? Function( String permission, PermissionStateKind state)? permissionState,TResult? Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target)? chatMessage,TResult? Function( String message)? serverActivity,TResult? Function( BridgeAudioRoute route)? audioRouteChanged,TResult? Function( BigInt clientId, BigInt newChannelId)? clientMoved,TResult? Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientJoined,TResult? Function( BigInt clientId, String name)? clientLeft,TResult? Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientUpdated,TResult? Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower)? channelAdded,TResult? Function( BigInt id)? channelRemoved,TResult? Function( BigInt id, String name, bool hasPassword, int? neededTalkPower)? channelUpdated,}) {final _that = this; +@optionalTypeArgs TResult? whenOrNull({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,TResult? Function( String permission, PermissionStateKind state)? permissionState,TResult? Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target, BridgePokeStrength? pokeStrength)? chatMessage,TResult? Function( String message)? serverActivity,TResult? Function( BridgeAudioRoute route)? audioRouteChanged,TResult? Function( BigInt clientId, BigInt newChannelId)? clientMoved,TResult? Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientJoined,TResult? Function( BigInt clientId, String name)? clientLeft,TResult? Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientUpdated,TResult? Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower)? channelAdded,TResult? Function( BigInt id)? channelRemoved,TResult? Function( BigInt id, String name, bool hasPassword, int? neededTalkPower)? channelUpdated,}) {final _that = this; switch (_that) { case BridgeEvent_Connected() when connected != null: return connected(_that.serverName);case BridgeEvent_Lost() when lost != null: @@ -262,7 +262,7 @@ return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case Bri return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null: return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null: return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null: -return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_ServerActivity() when serverActivity != null: +return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target,_that.pokeStrength);case BridgeEvent_ServerActivity() when serverActivity != null: return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null: return audioRouteChanged(_that.route);case BridgeEvent_ClientMoved() when clientMoved != null: return clientMoved(_that.clientId,_that.newChannelId);case BridgeEvent_ClientJoined() when clientJoined != null: @@ -929,7 +929,7 @@ as PermissionStateKind, class BridgeEvent_ChatMessage extends BridgeEvent { - const BridgeEvent_ChatMessage({required this.senderId, required this.senderName, required this.message, required this.target}): super._(); + const BridgeEvent_ChatMessage({required this.senderId, required this.senderName, required this.message, required this.target, this.pokeStrength}): super._(); /// Client id of the sender. @@ -940,6 +940,8 @@ class BridgeEvent_ChatMessage extends BridgeEvent { final String message; /// Target scope (server/channel/private/poke). final BridgeMessageTarget target; +/// Poke notification strength, present only for poke messages. + final BridgePokeStrength? pokeStrength; /// Create a copy of BridgeEvent /// with the given fields replaced by the non-null parameter values. @@ -951,16 +953,16 @@ $BridgeEvent_ChatMessageCopyWith get copyWith => _$Brid @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_ChatMessage&&(identical(other.senderId, senderId) || other.senderId == senderId)&&(identical(other.senderName, senderName) || other.senderName == senderName)&&(identical(other.message, message) || other.message == message)&&(identical(other.target, target) || other.target == target)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_ChatMessage&&(identical(other.senderId, senderId) || other.senderId == senderId)&&(identical(other.senderName, senderName) || other.senderName == senderName)&&(identical(other.message, message) || other.message == message)&&(identical(other.target, target) || other.target == target)&&(identical(other.pokeStrength, pokeStrength) || other.pokeStrength == pokeStrength)); } @override -int get hashCode => Object.hash(runtimeType,senderId,senderName,message,target); +int get hashCode => Object.hash(runtimeType,senderId,senderName,message,target,pokeStrength); @override String toString() { - return 'BridgeEvent.chatMessage(senderId: $senderId, senderName: $senderName, message: $message, target: $target)'; + return 'BridgeEvent.chatMessage(senderId: $senderId, senderName: $senderName, message: $message, target: $target, pokeStrength: $pokeStrength)'; } @@ -971,7 +973,7 @@ abstract mixin class $BridgeEvent_ChatMessageCopyWith<$Res> implements $BridgeEv factory $BridgeEvent_ChatMessageCopyWith(BridgeEvent_ChatMessage value, $Res Function(BridgeEvent_ChatMessage) _then) = _$BridgeEvent_ChatMessageCopyWithImpl; @useResult $Res call({ - BigInt senderId, String senderName, String message, BridgeMessageTarget target + BigInt senderId, String senderName, String message, BridgeMessageTarget target, BridgePokeStrength? pokeStrength }); @@ -988,13 +990,14 @@ class _$BridgeEvent_ChatMessageCopyWithImpl<$Res> /// Create a copy of BridgeEvent /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') $Res call({Object? senderId = null,Object? senderName = null,Object? message = null,Object? target = null,}) { +@pragma('vm:prefer-inline') $Res call({Object? senderId = null,Object? senderName = null,Object? message = null,Object? target = null,Object? pokeStrength = freezed,}) { return _then(BridgeEvent_ChatMessage( senderId: null == senderId ? _self.senderId : senderId // ignore: cast_nullable_to_non_nullable as BigInt,senderName: null == senderName ? _self.senderName : senderName // ignore: cast_nullable_to_non_nullable as String,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable as String,target: null == target ? _self.target : target // ignore: cast_nullable_to_non_nullable -as BridgeMessageTarget, +as BridgeMessageTarget,pokeStrength: freezed == pokeStrength ? _self.pokeStrength : pokeStrength // ignore: cast_nullable_to_non_nullable +as BridgePokeStrength?, )); } @@ -1084,6 +1087,7 @@ class BridgeEvent_AudioRouteChanged extends BridgeEvent { const BridgeEvent_AudioRouteChanged({required this.route}): super._(); +/// New audio output route. final BridgeAudioRoute route; /// Create a copy of BridgeEvent @@ -1150,7 +1154,9 @@ class BridgeEvent_ClientMoved extends BridgeEvent { const BridgeEvent_ClientMoved({required this.clientId, required this.newChannelId}): super._(); +/// Unique client identifier. final BigInt clientId; +/// Destination channel. final BigInt newChannelId; /// Create a copy of BridgeEvent @@ -1218,13 +1224,21 @@ class BridgeEvent_ClientJoined extends BridgeEvent { const BridgeEvent_ClientJoined({required this.clientId, required this.channelId, required this.name, required this.inputMuted, required this.outputMuted, required this.isServerQuery, required this.talkPower, required this.talkPowerGranted}): super._(); +/// Unique client identifier. final BigInt clientId; +/// Channel the client joined. final BigInt channelId; +/// Display nickname. final String name; +/// Microphone muted state. final bool inputMuted; +/// Speaker muted state. final bool outputMuted; +/// True for server query (bot) clients. final bool isServerQuery; +/// Client's talk power value. final int talkPower; +/// Whether the server granted temporary talk power. final bool talkPowerGranted; /// Create a copy of BridgeEvent @@ -1298,7 +1312,9 @@ class BridgeEvent_ClientLeft extends BridgeEvent { const BridgeEvent_ClientLeft({required this.clientId, required this.name}): super._(); +/// Unique client identifier. final BigInt clientId; +/// Display nickname at time of disconnect. final String name; /// Create a copy of BridgeEvent @@ -1366,11 +1382,17 @@ class BridgeEvent_ClientUpdated extends BridgeEvent { const BridgeEvent_ClientUpdated({required this.clientId, required this.inputMuted, required this.outputMuted, required this.isServerQuery, required this.talkPower, required this.talkPowerGranted}): super._(); +/// Unique client identifier. final BigInt clientId; +/// Microphone muted state. final bool inputMuted; +/// Speaker muted state. final bool outputMuted; +/// True for server query (bot) clients. final bool isServerQuery; +/// Client's talk power value. final int talkPower; +/// Whether the server granted temporary talk power. final bool talkPowerGranted; /// Create a copy of BridgeEvent @@ -1442,11 +1464,18 @@ class BridgeEvent_ChannelAdded extends BridgeEvent { const BridgeEvent_ChannelAdded({required this.id, required this.parent, required this.name, required this.order, required this.hasPassword, this.neededTalkPower}): super._(); +/// Unique channel identifier. final BigInt id; +/// Parent channel ID. final BigInt parent; +/// Channel name. final String name; +/// Predecessor channel ID within the same parent (TeamSpeak +/// linked-list ordering hint). Zero means first child. final PlatformInt64 order; +/// Whether the channel requires a password. final bool hasPassword; +/// Talk power required to speak; `None` means no restriction. final int? neededTalkPower; /// Create a copy of BridgeEvent @@ -1518,6 +1547,7 @@ class BridgeEvent_ChannelRemoved extends BridgeEvent { const BridgeEvent_ChannelRemoved({required this.id}): super._(); +/// Channel identifier. final BigInt id; /// Create a copy of BridgeEvent @@ -1584,9 +1614,13 @@ class BridgeEvent_ChannelUpdated extends BridgeEvent { const BridgeEvent_ChannelUpdated({required this.id, required this.name, required this.hasPassword, this.neededTalkPower}): super._(); +/// Unique channel identifier. final BigInt id; +/// Channel name. final String name; +/// Whether the channel requires a password. final bool hasPassword; +/// Talk power required to speak; `None` means no restriction. final int? neededTalkPower; /// Create a copy of BridgeEvent diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.dart index c662636..eeb9886 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.dart @@ -1683,6 +1683,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return dco_decode_bridge_message_target(raw); } + @protected + BridgePokeStrength dco_decode_box_autoadd_bridge_poke_strength(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dco_decode_bridge_poke_strength(raw); + } + @protected BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code( dynamic raw, @@ -2007,6 +2013,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { senderName: dco_decode_String(raw[2]), message: dco_decode_String(raw[3]), target: dco_decode_box_autoadd_bridge_message_target(raw[4]), + pokeStrength: dco_decode_opt_box_autoadd_bridge_poke_strength(raw[5]), ); case 11: return BridgeEvent_ServerActivity(message: dco_decode_String(raw[1])); @@ -2098,6 +2105,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return BridgeNetworkState.values[raw as int]; } + @protected + BridgePokeStrength dco_decode_bridge_poke_strength(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return BridgePokeStrength.values[raw as int]; + } + @protected BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -2234,6 +2247,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return raw == null ? null : dco_decode_String(raw); } + @protected + BridgePokeStrength? dco_decode_opt_box_autoadd_bridge_poke_strength( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw == null + ? null + : dco_decode_box_autoadd_bridge_poke_strength(raw); + } + @protected BridgeVoiceJoinErrorCode? dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw) { @@ -2358,6 +2381,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return (sse_decode_bridge_message_target(deserializer)); } + @protected + BridgePokeStrength sse_decode_box_autoadd_bridge_poke_strength( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return (sse_decode_bridge_poke_strength(deserializer)); + } + @protected BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code( SseDeserializer deserializer, @@ -2809,11 +2840,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var var_target = sse_decode_box_autoadd_bridge_message_target( deserializer, ); + var var_pokeStrength = sse_decode_opt_box_autoadd_bridge_poke_strength( + deserializer, + ); return BridgeEvent_ChatMessage( senderId: var_senderId, senderName: var_senderName, message: var_message, target: var_target, + pokeStrength: var_pokeStrength, ); case 11: var var_message = sse_decode_String(deserializer); @@ -2941,6 +2976,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return BridgeNetworkState.values[inner]; } + @protected + BridgePokeStrength sse_decode_bridge_poke_strength( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_i_32(deserializer); + return BridgePokeStrength.values[inner]; + } + @protected BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -3132,6 +3176,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + BridgePokeStrength? sse_decode_opt_box_autoadd_bridge_poke_strength( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + if (sse_decode_bool(deserializer)) { + return (sse_decode_box_autoadd_bridge_poke_strength(deserializer)); + } else { + return null; + } + } + @protected BridgeVoiceJoinErrorCode? sse_decode_opt_box_autoadd_bridge_voice_join_error_code( @@ -3306,6 +3363,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_bridge_message_target(self, serializer); } + @protected + void sse_encode_box_autoadd_bridge_poke_strength( + BridgePokeStrength self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_bridge_poke_strength(self, serializer); + } + @protected void sse_encode_box_autoadd_bridge_voice_join_error_code( BridgeVoiceJoinErrorCode self, @@ -3644,12 +3710,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { senderName: final senderName, message: final message, target: final target, + pokeStrength: final pokeStrength, ): sse_encode_i_32(10, serializer); sse_encode_u_64(senderId, serializer); sse_encode_String(senderName, serializer); sse_encode_String(message, serializer); sse_encode_box_autoadd_bridge_message_target(target, serializer); + sse_encode_opt_box_autoadd_bridge_poke_strength( + pokeStrength, + serializer, + ); case BridgeEvent_ServerActivity(message: final message): sse_encode_i_32(11, serializer); sse_encode_String(message, serializer); @@ -3771,6 +3842,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_i_32(self.index, serializer); } + @protected + void sse_encode_bridge_poke_strength( + BridgePokeStrength self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.index, serializer); + } + @protected void sse_encode_bridge_ptt_binding( BridgePttBinding self, @@ -3947,6 +4027,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + void sse_encode_opt_box_autoadd_bridge_poke_strength( + BridgePokeStrength? self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + sse_encode_bool(self != null, serializer); + if (self != null) { + sse_encode_box_autoadd_bridge_poke_strength(self, serializer); + } + } + @protected void sse_encode_opt_box_autoadd_bridge_voice_join_error_code( BridgeVoiceJoinErrorCode? self, diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart index a4cfb75..d6cad6c 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart @@ -46,6 +46,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BridgeMessageTarget dco_decode_box_autoadd_bridge_message_target(dynamic raw); + @protected + BridgePokeStrength dco_decode_box_autoadd_bridge_poke_strength(dynamic raw); + @protected BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code( dynamic raw, @@ -120,6 +123,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BridgeNetworkState dco_decode_bridge_network_state(dynamic raw); + @protected + BridgePokeStrength dco_decode_bridge_poke_strength(dynamic raw); + @protected BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw); @@ -174,6 +180,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected String? dco_decode_opt_String(dynamic raw); + @protected + BridgePokeStrength? dco_decode_opt_box_autoadd_bridge_poke_strength( + dynamic raw, + ); + @protected BridgeVoiceJoinErrorCode? dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw); @@ -240,6 +251,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + BridgePokeStrength sse_decode_box_autoadd_bridge_poke_strength( + SseDeserializer deserializer, + ); + @protected BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code( SseDeserializer deserializer, @@ -328,6 +344,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + BridgePokeStrength sse_decode_bridge_poke_strength( + SseDeserializer deserializer, + ); + @protected BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer); @@ -400,6 +421,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected String? sse_decode_opt_String(SseDeserializer deserializer); + @protected + BridgePokeStrength? sse_decode_opt_box_autoadd_bridge_poke_strength( + SseDeserializer deserializer, + ); + @protected BridgeVoiceJoinErrorCode? sse_decode_opt_box_autoadd_bridge_voice_join_error_code( @@ -477,6 +503,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_box_autoadd_bridge_poke_strength( + BridgePokeStrength self, + SseSerializer serializer, + ); + @protected void sse_encode_box_autoadd_bridge_voice_join_error_code( BridgeVoiceJoinErrorCode self, @@ -588,6 +620,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_bridge_poke_strength( + BridgePokeStrength self, + SseSerializer serializer, + ); + @protected void sse_encode_bridge_ptt_binding( BridgePttBinding self, @@ -681,6 +719,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_opt_String(String? self, SseSerializer serializer); + @protected + void sse_encode_opt_box_autoadd_bridge_poke_strength( + BridgePokeStrength? self, + SseSerializer serializer, + ); + @protected void sse_encode_opt_box_autoadd_bridge_voice_join_error_code( BridgeVoiceJoinErrorCode? self, diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart index 9b269ba..5d9c328 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart @@ -48,6 +48,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BridgeMessageTarget dco_decode_box_autoadd_bridge_message_target(dynamic raw); + @protected + BridgePokeStrength dco_decode_box_autoadd_bridge_poke_strength(dynamic raw); + @protected BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code( dynamic raw, @@ -122,6 +125,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BridgeNetworkState dco_decode_bridge_network_state(dynamic raw); + @protected + BridgePokeStrength dco_decode_bridge_poke_strength(dynamic raw); + @protected BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw); @@ -176,6 +182,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected String? dco_decode_opt_String(dynamic raw); + @protected + BridgePokeStrength? dco_decode_opt_box_autoadd_bridge_poke_strength( + dynamic raw, + ); + @protected BridgeVoiceJoinErrorCode? dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw); @@ -242,6 +253,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + BridgePokeStrength sse_decode_box_autoadd_bridge_poke_strength( + SseDeserializer deserializer, + ); + @protected BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code( SseDeserializer deserializer, @@ -330,6 +346,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + BridgePokeStrength sse_decode_bridge_poke_strength( + SseDeserializer deserializer, + ); + @protected BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer); @@ -402,6 +423,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected String? sse_decode_opt_String(SseDeserializer deserializer); + @protected + BridgePokeStrength? sse_decode_opt_box_autoadd_bridge_poke_strength( + SseDeserializer deserializer, + ); + @protected BridgeVoiceJoinErrorCode? sse_decode_opt_box_autoadd_bridge_voice_join_error_code( @@ -479,6 +505,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_box_autoadd_bridge_poke_strength( + BridgePokeStrength self, + SseSerializer serializer, + ); + @protected void sse_encode_box_autoadd_bridge_voice_join_error_code( BridgeVoiceJoinErrorCode self, @@ -590,6 +622,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_bridge_poke_strength( + BridgePokeStrength self, + SseSerializer serializer, + ); + @protected void sse_encode_bridge_ptt_binding( BridgePttBinding self, @@ -683,6 +721,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_opt_String(String? self, SseSerializer serializer); + @protected + void sse_encode_opt_box_autoadd_bridge_poke_strength( + BridgePokeStrength? self, + SseSerializer serializer, + ); + @protected void sse_encode_opt_box_autoadd_bridge_voice_join_error_code( BridgeVoiceJoinErrorCode? self, From cc6db181994e98c363f7fa6f30846b10a36de26a Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Mon, 8 Jun 2026 22:53:29 +0900 Subject: [PATCH 05/14] build(flutter): add local notification plugin --- apps/chanora_flutter/pubspec.lock | 48 +++++++++++++++++++ apps/chanora_flutter/pubspec.yaml | 1 + .../windows/flutter/generated_plugins.cmake | 1 + 3 files changed, 50 insertions(+) diff --git a/apps/chanora_flutter/pubspec.lock b/apps/chanora_flutter/pubspec.lock index 43275f1..8b88c9d 100644 --- a/apps/chanora_flutter/pubspec.lock +++ b/apps/chanora_flutter/pubspec.lock @@ -262,6 +262,46 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: be38e3854d2baabcda8e16966a5fe8748cebb655bb94701494da0f052c2fc352 + url: "https://pub.dev" + source: hosted + version: "22.0.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b" + url: "https://pub.dev" + source: hosted + version: "8.0.1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: ff0013eae795e8dc8fad4a8992a209e64d3ba2fbd8bf5e43c36bf448f95bd814 + url: "https://pub.dev" + source: hosted + version: "12.0.0" + flutter_local_notifications_web: + dependency: transitive + description: + name: flutter_local_notifications_web + sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "5aeed973a0c1480706784fad05c5c3a911335ebb561b2274b47fe80b375201e1" + url: "https://pub.dev" + source: hosted + version: "3.1.0" flutter_localizations: dependency: "direct main" description: flutter @@ -794,6 +834,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + timezone: + dependency: transitive + description: + name: timezone + sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b" + url: "https://pub.dev" + source: hosted + version: "0.11.0" typed_data: dependency: transitive description: diff --git a/apps/chanora_flutter/pubspec.yaml b/apps/chanora_flutter/pubspec.yaml index 5be734b..350b3a0 100644 --- a/apps/chanora_flutter/pubspec.yaml +++ b/apps/chanora_flutter/pubspec.yaml @@ -75,6 +75,7 @@ dependencies: # DEC-003 iOS 13 floor; haptic_kit supports iOS 12+). haptic_kit: ^1.0.0 flutter_foreground_task: ^9.2.2 + flutter_local_notifications: ^22.0.0 url_launcher: ^6.3.2 shared_preferences: ^2.5.5 share_plus: ^13.1.0 diff --git a/apps/chanora_flutter/windows/flutter/generated_plugins.cmake b/apps/chanora_flutter/windows/flutter/generated_plugins.cmake index 4467694..3c4865e 100644 --- a/apps/chanora_flutter/windows/flutter/generated_plugins.cmake +++ b/apps/chanora_flutter/windows/flutter/generated_plugins.cmake @@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows jni ) From 44f91a2ea4ba41671e818be2d486bf93d766472c Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Mon, 8 Jun 2026 22:53:53 +0900 Subject: [PATCH 06/14] build(android): configure local notifications --- apps/chanora_flutter/android/app/build.gradle.kts | 2 ++ .../src/main/res/drawable/ic_chanora_notification.xml | 10 ++++++++++ 2 files changed, 12 insertions(+) create mode 100644 apps/chanora_flutter/android/app/src/main/res/drawable/ic_chanora_notification.xml diff --git a/apps/chanora_flutter/android/app/build.gradle.kts b/apps/chanora_flutter/android/app/build.gradle.kts index 5f0fb16..1e1cb9d 100644 --- a/apps/chanora_flutter/android/app/build.gradle.kts +++ b/apps/chanora_flutter/android/app/build.gradle.kts @@ -59,6 +59,7 @@ android { ndkVersion = flutter.ndkVersion compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -198,6 +199,7 @@ android { // armeabi-v7a, x86_64, x86. AGP merges these into the APK/AAB. dependencies { implementation("com.microsoft.onnxruntime:onnxruntime-android:1.26.0") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") } flutter { diff --git a/apps/chanora_flutter/android/app/src/main/res/drawable/ic_chanora_notification.xml b/apps/chanora_flutter/android/app/src/main/res/drawable/ic_chanora_notification.xml new file mode 100644 index 0000000..6735a70 --- /dev/null +++ b/apps/chanora_flutter/android/app/src/main/res/drawable/ic_chanora_notification.xml @@ -0,0 +1,10 @@ + + + + From b7cc4d2336efe704da093e6792597b11b34739b9 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Mon, 8 Jun 2026 22:54:18 +0900 Subject: [PATCH 07/14] build(apple): declare notification permission usage --- apps/chanora_flutter/ios/Runner/Info.plist | 2 ++ apps/chanora_flutter/macos/Runner/Info.plist | 2 ++ 2 files changed, 4 insertions(+) diff --git a/apps/chanora_flutter/ios/Runner/Info.plist b/apps/chanora_flutter/ios/Runner/Info.plist index b97e12f..5364474 100644 --- a/apps/chanora_flutter/ios/Runner/Info.plist +++ b/apps/chanora_flutter/ios/Runner/Info.plist @@ -34,6 +34,8 @@ Chanora needs local network access to connect to your voice servers. NSMicrophoneUsageDescription Chanora needs microphone access so you can talk on your voice server. + NSUserNotificationsUsageDescription + Chanora sends you a notification when another user pokes you. UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/apps/chanora_flutter/macos/Runner/Info.plist b/apps/chanora_flutter/macos/Runner/Info.plist index 3a96692..4209052 100644 --- a/apps/chanora_flutter/macos/Runner/Info.plist +++ b/apps/chanora_flutter/macos/Runner/Info.plist @@ -42,6 +42,8 @@ Chanora uses Input Monitoring so push-to-talk keys work even when other apps are focused. Chanora never records what you type — only the key you bound for talking. NSLocalNetworkUsageDescription Chanora needs local network access to connect to TeamSpeak-compatible voice servers. + NSUserNotificationsUsageDescription + Chanora sends you a notification when another user pokes you. NSBonjourServices _ts3._tcp From 4f1b85cf760aba3915671fec8852c4cf382d4dc9 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Mon, 8 Jun 2026 22:54:46 +0900 Subject: [PATCH 08/14] feat(flutter): add poke notification service --- .../services/poke_notification_service.dart | 179 ++++++++++++++++++ .../poke_notification_service_test.dart | 63 ++++++ 2 files changed, 242 insertions(+) create mode 100644 apps/chanora_flutter/lib/services/poke_notification_service.dart create mode 100644 apps/chanora_flutter/test/services/poke_notification_service_test.dart diff --git a/apps/chanora_flutter/lib/services/poke_notification_service.dart b/apps/chanora_flutter/lib/services/poke_notification_service.dart new file mode 100644 index 0000000..2cd1168 --- /dev/null +++ b/apps/chanora_flutter/lib/services/poke_notification_service.dart @@ -0,0 +1,179 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +import '../src/rust/api.dart' as rust; + +class PokeNotificationService { + PokeNotificationService({FlutterLocalNotificationsPlugin? notifications}) + : _notifications = notifications ?? FlutterLocalNotificationsPlugin(); + + static const _strongAndroidChannelId = 'chanora_pokes_strong_v1'; + static const _defaultAndroidChannelId = 'chanora_pokes_default_v1'; + static const _groupKey = 'chanora.pokes'; + static const _darwinThreadId = 'chanora.pokes'; + static const _windowsHeader = WindowsHeader( + id: 'chanora.pokes', + title: 'Pokes', + arguments: 'pokes', + ); + static const _windowsAppUserModelId = 'Chanora.Client'; + static const _windowsGuid = '6B7F3DCB-4418-4E0A-8CC7-02B7C95B675E'; + + final FlutterLocalNotificationsPlugin _notifications; + bool _initialized = false; + + Future init() async { + if (_initialized) return; + await _notifications.initialize( + settings: const InitializationSettings( + android: AndroidInitializationSettings('ic_chanora_notification'), + iOS: DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + // TODO(event-sounds): handled by future EventSoundService, not the OS channel. + requestSoundPermission: false, + // TODO(event-sounds): handled by future EventSoundService, not the OS channel. + defaultPresentSound: false, + ), + macOS: DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + // TODO(event-sounds): handled by future EventSoundService, not the OS channel. + requestSoundPermission: false, + // TODO(event-sounds): handled by future EventSoundService, not the OS channel. + defaultPresentSound: false, + ), + linux: LinuxInitializationSettings( + defaultActionName: 'Open', + // TODO(event-sounds): handled by future EventSoundService, not the OS channel. + defaultSuppressSound: true, + ), + windows: WindowsInitializationSettings( + appName: 'Chanora', + appUserModelId: _windowsAppUserModelId, + guid: _windowsGuid, + ), + ), + ); + _initialized = true; + } + + Future requestPermission() async { + await init(); + if (kIsWeb) return true; + + final android = _notifications + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + if (android != null) { + return await android.requestNotificationsPermission() ?? true; + } + + final ios = _notifications + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin + >(); + if (ios != null) { + return await ios.requestPermissions(alert: true, badge: true) ?? false; + } + + final macOS = _notifications + .resolvePlatformSpecificImplementation< + MacOSFlutterLocalNotificationsPlugin + >(); + if (macOS != null) { + return await macOS.requestPermissions(alert: true, badge: true) ?? false; + } + + return true; + } + + Future show({ + required String senderName, + required String message, + required BigInt senderId, + required rust.BridgePokeStrength strength, + }) async { + await init(); + final permitted = await requestPermission(); + if (!permitted) return; + + final trimmedMessage = message.trim(); + final body = trimmedMessage.isEmpty + ? '$senderName pokes you' + : trimmedMessage; + + await _notifications.show( + id: senderId.toUnsigned(31).toInt(), + title: 'Poke from $senderName', + body: body, + notificationDetails: NotificationDetails( + android: _androidDetails(strength), + iOS: _darwinDetails(strength), + macOS: _darwinDetails(strength), + linux: _linuxDetails(strength), + windows: _windowsDetails(strength), + ), + payload: 'poke:$senderId', + ); + } + + AndroidNotificationDetails _androidDetails(rust.BridgePokeStrength strength) { + final isStrong = strength == rust.BridgePokeStrength.strong; + return AndroidNotificationDetails( + isStrong ? _strongAndroidChannelId : _defaultAndroidChannelId, + isStrong ? 'Pokes' : 'Pokes (quiet)', + channelDescription: 'TeamSpeak poke notifications', + importance: isStrong ? Importance.max : Importance.defaultImportance, + priority: isStrong ? Priority.high : Priority.defaultPriority, + // TODO(event-sounds): handled by future EventSoundService, not the OS channel. + playSound: false, + // TODO(event-sounds): handled by future EventSoundService, not the OS channel. + silent: true, + groupKey: _groupKey, + category: AndroidNotificationCategory.message, + visibility: NotificationVisibility.private, + ); + } + + DarwinNotificationDetails _darwinDetails(rust.BridgePokeStrength strength) { + return DarwinNotificationDetails( + // TODO(event-sounds): handled by future EventSoundService, not the OS channel. + presentSound: false, + threadIdentifier: _darwinThreadId, + interruptionLevel: switch (strength) { + rust.BridgePokeStrength.strong => InterruptionLevel.timeSensitive, + rust.BridgePokeStrength.suppressed => InterruptionLevel.active, + rust.BridgePokeStrength.suppressedOverflow => InterruptionLevel.passive, + }, + ); + } + + LinuxNotificationDetails _linuxDetails(rust.BridgePokeStrength strength) { + return LinuxNotificationDetails( + // TODO(event-sounds): handled by future EventSoundService, not the OS channel. + suppressSound: true, + urgency: switch (strength) { + rust.BridgePokeStrength.strong => LinuxNotificationUrgency.critical, + rust.BridgePokeStrength.suppressed => LinuxNotificationUrgency.normal, + rust.BridgePokeStrength.suppressedOverflow => + LinuxNotificationUrgency.low, + }, + ); + } + + WindowsNotificationDetails _windowsDetails(rust.BridgePokeStrength strength) { + return WindowsNotificationDetails( + // TODO(event-sounds): handled by future EventSoundService, not the OS channel. + audio: WindowsNotificationAudio.silent(), + header: _windowsHeader, + scenario: strength == rust.BridgePokeStrength.strong + ? WindowsNotificationScenario.urgent + : null, + duration: strength == rust.BridgePokeStrength.strong + ? WindowsNotificationDuration.long + : WindowsNotificationDuration.short, + ); + } +} diff --git a/apps/chanora_flutter/test/services/poke_notification_service_test.dart b/apps/chanora_flutter/test/services/poke_notification_service_test.dart new file mode 100644 index 0000000..d356be3 --- /dev/null +++ b/apps/chanora_flutter/test/services/poke_notification_service_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +import 'package:chanora_flutter/services/poke_notification_service.dart'; +import 'package:chanora_flutter/src/rust/api.dart' as rust; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('dexterous.com/flutter/local_notifications'); + late List calls; + + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + AndroidFlutterLocalNotificationsPlugin.registerWith(); + calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return switch (call.method) { + 'initialize' => true, + 'requestNotificationsPermission' => true, + _ => null, + }; + }); + }); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test( + 'show dispatches a silent poke notification with sender payload', + () async { + final service = PokeNotificationService(); + + await service.show( + senderName: 'Alice', + message: 'wake up', + senderId: BigInt.from(42), + strength: rust.BridgePokeStrength.strong, + ); + + final showCall = calls.singleWhere((call) => call.method == 'show'); + final arguments = Map.from(showCall.arguments as Map); + + expect(arguments['id'], 42); + expect(arguments['title'], 'Poke from Alice'); + expect(arguments['body'], 'wake up'); + expect(arguments['payload'], 'poke:42'); + final specifics = Map.from( + arguments['platformSpecifics'] as Map, + ); + expect(specifics['silent'], true); + expect(specifics['playSound'], false); + expect(specifics['groupKey'], 'chanora.pokes'); + }, + ); +} From 409cd11c21ae88015c012d5b86d655a9e54c34df Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Mon, 8 Jun 2026 22:55:12 +0900 Subject: [PATCH 09/14] feat(flutter): persist poke notification preferences --- .../services/poke_preferences_service.dart | 58 +++++++++++++++++++ .../poke_preferences_service_test.dart | 58 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 apps/chanora_flutter/lib/services/poke_preferences_service.dart create mode 100644 apps/chanora_flutter/test/services/poke_preferences_service_test.dart diff --git a/apps/chanora_flutter/lib/services/poke_preferences_service.dart b/apps/chanora_flutter/lib/services/poke_preferences_service.dart new file mode 100644 index 0000000..7e930a7 --- /dev/null +++ b/apps/chanora_flutter/lib/services/poke_preferences_service.dart @@ -0,0 +1,58 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class PokePreferencesService { + static const _enabledKey = 'pokes.enabled'; + static const _mutedSendersKey = 'pokes.muted_senders'; + + final ValueNotifier _pokesEnabled = ValueNotifier(true); + final ValueNotifier> _mutedSenders = ValueNotifier>( + const {}, + ); + + ValueListenable get pokesEnabled => _pokesEnabled; + ValueListenable> get mutedSenders => _mutedSenders; + + Future load() async { + final prefs = await SharedPreferences.getInstance(); + _pokesEnabled.value = prefs.getBool(_enabledKey) ?? true; + _mutedSenders.value = (prefs.getStringList(_mutedSendersKey) ?? const []) + .map(BigInt.parse) + .toSet(); + } + + Future setPokesEnabled(bool enabled) async { + _pokesEnabled.value = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_enabledKey, enabled); + } + + Future muteSender(BigInt senderId) async { + if (_mutedSenders.value.contains(senderId)) return; + _mutedSenders.value = {..._mutedSenders.value, senderId}; + await _saveMutedSenders(); + } + + Future unmuteSender(BigInt senderId) async { + if (!_mutedSenders.value.contains(senderId)) return; + _mutedSenders.value = _mutedSenders.value + .where((mutedSender) => mutedSender != senderId) + .toSet(); + await _saveMutedSenders(); + } + + bool isMuted(BigInt senderId) => _mutedSenders.value.contains(senderId); + + Future _saveMutedSenders() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList( + _mutedSendersKey, + _mutedSenders.value.map((senderId) => senderId.toString()).toList(), + ); + } + + void dispose() { + _pokesEnabled.dispose(); + _mutedSenders.dispose(); + } +} diff --git a/apps/chanora_flutter/test/services/poke_preferences_service_test.dart b/apps/chanora_flutter/test/services/poke_preferences_service_test.dart new file mode 100644 index 0000000..4854dbc --- /dev/null +++ b/apps/chanora_flutter/test/services/poke_preferences_service_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:chanora_flutter/services/poke_preferences_service.dart'; + +void main() { + late PokePreferencesService service; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + service = PokePreferencesService(); + }); + + test('loads defaults when preferences are unset', () async { + await service.load(); + + expect(service.pokesEnabled.value, isTrue); + expect(service.mutedSenders.value, isEmpty); + }); + + test('persists global enabled state', () async { + await service.load(); + await service.setPokesEnabled(false); + + final reloaded = PokePreferencesService(); + await reloaded.load(); + + expect(reloaded.pokesEnabled.value, isFalse); + }); + + test('persists muted senders and removes them on unmute', () async { + await service.load(); + final alice = BigInt.from(42); + final bob = BigInt.from(7); + + await service.muteSender(alice); + await service.muteSender(bob); + await service.unmuteSender(alice); + + final reloaded = PokePreferencesService(); + await reloaded.load(); + + expect(reloaded.mutedSenders.value, {bob}); + }); + + test('isMuted reflects in-memory changes synchronously', () async { + await service.load(); + final sender = BigInt.from(99); + + expect(service.isMuted(sender), isFalse); + + await service.muteSender(sender); + expect(service.isMuted(sender), isTrue); + + await service.unmuteSender(sender); + expect(service.isMuted(sender), isFalse); + }); +} From 9a5f82565d3b5b42165811ce69fb41ec3fdadec8 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Mon, 8 Jun 2026 22:55:38 +0900 Subject: [PATCH 10/14] feat(l10n): add poke notification settings copy --- apps/chanora_flutter/lib/l10n/app_en.arb | 27 +++++--- apps/chanora_flutter/lib/l10n/app_zh.arb | 27 +++++--- .../lib/l10n/generated/app_localizations.dart | 66 +++++++++++++++---- .../l10n/generated/app_localizations_en.dart | 36 ++++++++-- .../l10n/generated/app_localizations_zh.dart | 35 ++++++++-- 5 files changed, 151 insertions(+), 40 deletions(-) diff --git a/apps/chanora_flutter/lib/l10n/app_en.arb b/apps/chanora_flutter/lib/l10n/app_en.arb index 91c8b22..2cda470 100644 --- a/apps/chanora_flutter/lib/l10n/app_en.arb +++ b/apps/chanora_flutter/lib/l10n/app_en.arb @@ -242,19 +242,30 @@ "clientInfoUnknown": "Unknown", "clientInfoHidden": "Hidden", "clientInfoNone": "None", - "pokeSnackBarClearAction": "Clear", - "pokeSnackBarMoreIndicator": "...", - "pokeSnackBarIncomingNoMessage": "{sender} pokes you", - "@pokeSnackBarIncomingNoMessage": { + "pokeSettingsAction": "Poke notifications", + "pokeSettingsTitle": "Poke notifications", + "pokeSettingsEnableLabel": "Notify me about pokes", + "pokeSettingsEnableDescription": "Show local notifications for incoming pokes when this is on.", + "pokeSettingsMutedSendersHeader": "Muted senders", + "pokeSettingsMutedSendersEmpty": "No muted poke senders.", + "pokeSettingsMutedSenderLabel": "Client ID {senderId}", + "@pokeSettingsMutedSenderLabel": { + "placeholders": { + "senderId": { "type": "String" } + } + }, + "pokeSettingsUnmuteSenderAction": "Unmute", + "pokeOverflowMutePrompt": "Repeated pokes from {sender} were suppressed. Mute this sender?", + "@pokeOverflowMutePrompt": { "placeholders": { "sender": { "type": "String" } } }, - "pokeSnackBarIncomingWithMessage": "{sender} pokes you: {message}", - "@pokeSnackBarIncomingWithMessage": { + "pokeOverflowMuteAction": "Mute", + "pokeMutedSenderConfirmation": "Muted pokes from {sender}", + "@pokeMutedSenderConfirmation": { "placeholders": { - "sender": { "type": "String" }, - "message": { "type": "String" } + "sender": { "type": "String" } } }, "pokeHistorySelfNoMessage": "<{time}> You poked \"{target}\".", diff --git a/apps/chanora_flutter/lib/l10n/app_zh.arb b/apps/chanora_flutter/lib/l10n/app_zh.arb index 87388fc..d9864df 100644 --- a/apps/chanora_flutter/lib/l10n/app_zh.arb +++ b/apps/chanora_flutter/lib/l10n/app_zh.arb @@ -191,19 +191,30 @@ "clientInfoUnknown": "未知", "clientInfoHidden": "隐藏", "clientInfoNone": "无", - "pokeSnackBarClearAction": "清除", - "pokeSnackBarMoreIndicator": "...", - "pokeSnackBarIncomingNoMessage": "{sender} 戳了你一下", - "@pokeSnackBarIncomingNoMessage": { + "pokeSettingsAction": "戳一戳通知", + "pokeSettingsTitle": "戳一戳通知", + "pokeSettingsEnableLabel": "接收戳一戳通知", + "pokeSettingsEnableDescription": "开启后,收到戳一戳时会显示本地通知。", + "pokeSettingsMutedSendersHeader": "已静音的发送者", + "pokeSettingsMutedSendersEmpty": "没有已静音的戳一戳发送者。", + "pokeSettingsMutedSenderLabel": "用户 ID {senderId}", + "@pokeSettingsMutedSenderLabel": { + "placeholders": { + "senderId": { "type": "String" } + } + }, + "pokeSettingsUnmuteSenderAction": "取消静音", + "pokeOverflowMutePrompt": "来自 {sender} 的重复戳一戳已被抑制。要静音此发送者吗?", + "@pokeOverflowMutePrompt": { "placeholders": { "sender": { "type": "String" } } }, - "pokeSnackBarIncomingWithMessage": "{sender} 戳了你一下:{message}", - "@pokeSnackBarIncomingWithMessage": { + "pokeOverflowMuteAction": "静音", + "pokeMutedSenderConfirmation": "已静音来自 {sender} 的戳一戳", + "@pokeMutedSenderConfirmation": { "placeholders": { - "sender": { "type": "String" }, - "message": { "type": "String" } + "sender": { "type": "String" } } }, "pokeHistorySelfNoMessage": "<{time}> 你戳了“{target}”一下。", diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart index 13bc179..8c53ef9 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart @@ -1159,29 +1159,71 @@ abstract class AppL10n { /// **'None'** String get clientInfoNone; - /// No description provided for @pokeSnackBarClearAction. + /// No description provided for @pokeSettingsAction. /// /// In en, this message translates to: - /// **'Clear'** - String get pokeSnackBarClearAction; + /// **'Poke notifications'** + String get pokeSettingsAction; - /// No description provided for @pokeSnackBarMoreIndicator. + /// No description provided for @pokeSettingsTitle. /// /// In en, this message translates to: - /// **'...'** - String get pokeSnackBarMoreIndicator; + /// **'Poke notifications'** + String get pokeSettingsTitle; - /// No description provided for @pokeSnackBarIncomingNoMessage. + /// No description provided for @pokeSettingsEnableLabel. /// /// In en, this message translates to: - /// **'{sender} pokes you'** - String pokeSnackBarIncomingNoMessage(String sender); + /// **'Notify me about pokes'** + String get pokeSettingsEnableLabel; - /// No description provided for @pokeSnackBarIncomingWithMessage. + /// No description provided for @pokeSettingsEnableDescription. /// /// In en, this message translates to: - /// **'{sender} pokes you: {message}'** - String pokeSnackBarIncomingWithMessage(String sender, String message); + /// **'Show local notifications for incoming pokes when this is on.'** + String get pokeSettingsEnableDescription; + + /// No description provided for @pokeSettingsMutedSendersHeader. + /// + /// In en, this message translates to: + /// **'Muted senders'** + String get pokeSettingsMutedSendersHeader; + + /// No description provided for @pokeSettingsMutedSendersEmpty. + /// + /// In en, this message translates to: + /// **'No muted poke senders.'** + String get pokeSettingsMutedSendersEmpty; + + /// No description provided for @pokeSettingsMutedSenderLabel. + /// + /// In en, this message translates to: + /// **'Client ID {senderId}'** + String pokeSettingsMutedSenderLabel(String senderId); + + /// No description provided for @pokeSettingsUnmuteSenderAction. + /// + /// In en, this message translates to: + /// **'Unmute'** + String get pokeSettingsUnmuteSenderAction; + + /// No description provided for @pokeOverflowMutePrompt. + /// + /// In en, this message translates to: + /// **'Repeated pokes from {sender} were suppressed. Mute this sender?'** + String pokeOverflowMutePrompt(String sender); + + /// No description provided for @pokeOverflowMuteAction. + /// + /// In en, this message translates to: + /// **'Mute'** + String get pokeOverflowMuteAction; + + /// No description provided for @pokeMutedSenderConfirmation. + /// + /// In en, this message translates to: + /// **'Muted pokes from {sender}'** + String pokeMutedSenderConfirmation(String sender); /// No description provided for @pokeHistorySelfNoMessage. /// diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart index e77422c..d8c4241 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart @@ -590,19 +590,43 @@ class AppL10nEn extends AppL10n { String get clientInfoNone => 'None'; @override - String get pokeSnackBarClearAction => 'Clear'; + String get pokeSettingsAction => 'Poke notifications'; @override - String get pokeSnackBarMoreIndicator => '...'; + String get pokeSettingsTitle => 'Poke notifications'; @override - String pokeSnackBarIncomingNoMessage(String sender) { - return '$sender pokes you'; + String get pokeSettingsEnableLabel => 'Notify me about pokes'; + + @override + String get pokeSettingsEnableDescription => + 'Show local notifications for incoming pokes when this is on.'; + + @override + String get pokeSettingsMutedSendersHeader => 'Muted senders'; + + @override + String get pokeSettingsMutedSendersEmpty => 'No muted poke senders.'; + + @override + String pokeSettingsMutedSenderLabel(String senderId) { + return 'Client ID $senderId'; } @override - String pokeSnackBarIncomingWithMessage(String sender, String message) { - return '$sender pokes you: $message'; + String get pokeSettingsUnmuteSenderAction => 'Unmute'; + + @override + String pokeOverflowMutePrompt(String sender) { + return 'Repeated pokes from $sender were suppressed. Mute this sender?'; + } + + @override + String get pokeOverflowMuteAction => 'Mute'; + + @override + String pokeMutedSenderConfirmation(String sender) { + return 'Muted pokes from $sender'; } @override diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart index 1c5fbd2..8382d60 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart @@ -577,19 +577,42 @@ class AppL10nZh extends AppL10n { String get clientInfoNone => '无'; @override - String get pokeSnackBarClearAction => '清除'; + String get pokeSettingsAction => '戳一戳通知'; @override - String get pokeSnackBarMoreIndicator => '...'; + String get pokeSettingsTitle => '戳一戳通知'; @override - String pokeSnackBarIncomingNoMessage(String sender) { - return '$sender 戳了你一下'; + String get pokeSettingsEnableLabel => '接收戳一戳通知'; + + @override + String get pokeSettingsEnableDescription => '开启后,收到戳一戳时会显示本地通知。'; + + @override + String get pokeSettingsMutedSendersHeader => '已静音的发送者'; + + @override + String get pokeSettingsMutedSendersEmpty => '没有已静音的戳一戳发送者。'; + + @override + String pokeSettingsMutedSenderLabel(String senderId) { + return '用户 ID $senderId'; } @override - String pokeSnackBarIncomingWithMessage(String sender, String message) { - return '$sender 戳了你一下:$message'; + String get pokeSettingsUnmuteSenderAction => '取消静音'; + + @override + String pokeOverflowMutePrompt(String sender) { + return '来自 $sender 的重复戳一戳已被抑制。要静音此发送者吗?'; + } + + @override + String get pokeOverflowMuteAction => '静音'; + + @override + String pokeMutedSenderConfirmation(String sender) { + return '已静音来自 $sender 的戳一戳'; } @override From 34a5247457bd3df2e85480a105c28ca6d78a2992 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Mon, 8 Jun 2026 22:56:04 +0900 Subject: [PATCH 11/14] feat(ui): add poke notification settings dialog --- .../widgets/poke_notification_settings.dart | 89 +++++++++++++++++++ .../poke_notification_settings_test.dart | 37 ++++++++ 2 files changed, 126 insertions(+) create mode 100644 apps/chanora_flutter/lib/widgets/poke_notification_settings.dart create mode 100644 apps/chanora_flutter/test/widgets/poke_notification_settings_test.dart diff --git a/apps/chanora_flutter/lib/widgets/poke_notification_settings.dart b/apps/chanora_flutter/lib/widgets/poke_notification_settings.dart new file mode 100644 index 0000000..f876552 --- /dev/null +++ b/apps/chanora_flutter/lib/widgets/poke_notification_settings.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; + +import '../l10n/generated/app_localizations.dart'; +import '../services/poke_preferences_service.dart'; +import 'voice_settings_controls.dart'; + +class PokeNotificationSettingsDialog extends StatelessWidget { + const PokeNotificationSettingsDialog({super.key, required this.preferences}); + + final PokePreferencesService preferences; + + @override + Widget build(BuildContext context) { + final l10n = AppL10n.of(context); + final theme = Theme.of(context); + return AlertDialog( + title: Text(l10n.pokeSettingsTitle), + contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0), + content: SizedBox( + width: 400, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ValueListenableBuilder( + valueListenable: preferences.pokesEnabled, + builder: (context, enabled, _) => SwitchListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text(l10n.pokeSettingsEnableLabel), + subtitle: Text(l10n.pokeSettingsEnableDescription), + value: enabled, + onChanged: (value) => preferences.setPokesEnabled(value), + ), + ), + const Divider(height: 24), + VoiceSectionHeader(l10n.pokeSettingsMutedSendersHeader), + ValueListenableBuilder>( + valueListenable: preferences.mutedSenders, + builder: (context, mutedSenders, _) { + final senders = mutedSenders.toList()..sort(); + if (senders.isEmpty) { + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 8), + child: Text( + l10n.pokeSettingsMutedSendersEmpty, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ); + } + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final senderId in senders) + ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.notifications_off_outlined), + title: Text( + l10n.pokeSettingsMutedSenderLabel( + senderId.toString(), + ), + ), + trailing: TextButton( + onPressed: () => preferences.unmuteSender(senderId), + child: Text(l10n.pokeSettingsUnmuteSenderAction), + ), + ), + ], + ); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.closeAction), + ), + ], + ); + } +} diff --git a/apps/chanora_flutter/test/widgets/poke_notification_settings_test.dart b/apps/chanora_flutter/test/widgets/poke_notification_settings_test.dart new file mode 100644 index 0000000..c33b651 --- /dev/null +++ b/apps/chanora_flutter/test/widgets/poke_notification_settings_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:chanora_flutter/l10n/generated/app_localizations.dart'; +import 'package:chanora_flutter/services/poke_preferences_service.dart'; +import 'package:chanora_flutter/widgets/poke_notification_settings.dart'; + +void main() { + testWidgets('toggles poke notifications and unmutes senders', (tester) async { + SharedPreferences.setMockInitialValues({}); + final preferences = PokePreferencesService(); + await preferences.load(); + await preferences.muteSender(BigInt.from(42)); + addTearDown(preferences.dispose); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppL10n.localizationsDelegates, + supportedLocales: AppL10n.supportedLocales, + home: PokeNotificationSettingsDialog(preferences: preferences), + ), + ); + + expect(find.text('Poke notifications'), findsOneWidget); + expect(find.text('Client ID 42'), findsOneWidget); + + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + expect(preferences.pokesEnabled.value, isFalse); + + await tester.tap(find.text('Unmute')); + await tester.pumpAndSettle(); + expect(preferences.isMuted(BigInt.from(42)), isFalse); + expect(find.text('No muted poke senders.'), findsOneWidget); + }); +} From b565663645e05c446913532ff565bb0b1db13d74 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Mon, 8 Jun 2026 22:56:52 +0900 Subject: [PATCH 12/14] feat(chat): route pokes through notifications --- apps/chanora_flutter/lib/main.dart | 245 +++++++----------- .../test/services/poke_active_chat_test.dart | 48 ++++ 2 files changed, 148 insertions(+), 145 deletions(-) create mode 100644 apps/chanora_flutter/test/services/poke_active_chat_test.dart diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index d8f51ca..f73d422 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -28,6 +28,8 @@ import 'services/connection_phase_state.dart'; import 'services/hard_mute_owners.dart'; import 'services/ios_permissions_service.dart'; import 'services/macos_permissions_service.dart'; +import 'services/poke_notification_service.dart'; +import 'services/poke_preferences_service.dart'; import 'services/prefetch_debouncer.dart'; import 'services/snapshot_state_mapper.dart'; import 'services/ts3_server_link.dart'; @@ -43,6 +45,7 @@ import 'widgets/client_info_sheet.dart'; import 'widgets/connect_widgets.dart'; import 'widgets/input_dialogs.dart'; import 'widgets/permission_state_banner.dart'; +import 'widgets/poke_notification_settings.dart'; import 'widgets/snapshot_view.dart'; import 'widgets/voice_platform.dart'; import 'widgets/voice_bar.dart'; @@ -161,10 +164,7 @@ class _ChanoraAppState extends State { supportedLocales: AppL10n.supportedLocales, home: Stack( children: [ - _BetaHome( - themeMode: _themeMode, - onThemeModeChanged: _setThemeMode, - ), + _BetaHome(themeMode: _themeMode, onThemeModeChanged: _setThemeMode), if (_showAudioDebugOverlay) const AudioDebugStatsPanel(), ], ), @@ -202,6 +202,19 @@ extension on ThemeMode { } } +bool isPokeSenderActiveChat({ + required bool chatOpen, + required rust.BridgeMessageTarget? inlineChatTarget, + required BigInt senderId, +}) { + if (!chatOpen) return false; + return switch (inlineChatTarget) { + rust.BridgeMessageTarget_Poke(:final field0) || + rust.BridgeMessageTarget_Client(:final field0) => field0 == senderId, + _ => false, + }; +} + class ChanoraThemeModeMenu extends StatelessWidget { const ChanoraThemeModeMenu({ super.key, @@ -302,18 +315,6 @@ class _BetaHome extends StatefulWidget { State<_BetaHome> createState() => _BetaHomeState(); } -class _ReceivedPoke { - const _ReceivedPoke({ - required this.senderName, - required this.message, - required this.receivedAt, - }); - - final String senderName; - final String message; - final DateTime receivedAt; -} - class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { final _hostCtl = TextEditingController(text: 'cn.teamspeak.app'); final _nickCtl = TextEditingController(text: 'ChanoraBeta'); @@ -412,11 +413,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { /// previous conversation when the user reopens chat. rust.BridgeMessageTarget? _lastDismissedTarget; String _lastDismissedClientName = ''; - final ValueNotifier> _pokeSnackBarPokes = ValueNotifier( - const [], - ); - bool _pokeSnackBarVisible = false; - // SDD-106 / SRS-209: Android RECORD_AUDIO runtime permission service. // Constructed at startup so cold-launch state is captured before the // first voice_join attempt. On non-Android hosts the service @@ -431,6 +427,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { // MethodChannel. final MacOSPermissionsService _macOSPermissions = MacOSPermissionsService(); final UiPreferencesService _uiPreferences = const UiPreferencesService(); + final PokeNotificationService _pokeNotifications = PokeNotificationService(); + final PokePreferencesService _pokePreferences = PokePreferencesService(); + late final Future _pokePreferencesReady; @override void initState() { @@ -464,6 +463,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { _onMacOSPttCapabilityChanged, ); _macOSPermissions.checkInitialStates(); + unawaited(_pokeNotifications.init()); + _pokePreferencesReady = _pokePreferences.load(); WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(_requestRecordAudioOnStartup()); }); @@ -867,10 +868,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { :final senderName, :final message, :final target, + :final pokeStrength, ): - // Skip echo of self-sent messages (already added locally). - if (senderId == _snapshot?.ownClientId) return; final isPoke = target is rust.BridgeMessageTarget_Poke; + // Skip echo of self-sent non-poke messages (already added locally). + if (!isPoke && senderId == _snapshot?.ownClientId) return; final receivedAt = DateTime.now(); setState(() { _appendChatEntryUnlocked( @@ -885,10 +887,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { ); }); if (isPoke) { - _showPokeSnackBar( - senderName: senderName, - message: message, - receivedAt: receivedAt, + unawaited( + _handleIncomingPoke( + senderId: senderId, + senderName: senderName, + message: message, + strength: pokeStrength, + ), ); return; } @@ -1088,7 +1093,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { _nickCtl.dispose(); _passwordCtl.dispose(); _chatFeedRevision.dispose(); - _pokeSnackBarPokes.dispose(); _androidPermissions.recordAudioState.removeListener( _onRecordAudioPermissionChanged, ); @@ -1105,6 +1109,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { _androidPermissions.stop(); _iosPermissions.stop(); _macOSPermissions.stop(); + _pokePreferences.dispose(); super.dispose(); } @@ -1138,7 +1143,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { ); if (accessState == MacOSLocalNetworkState.denied) { if (!mounted) return; - setState(() { _phase = ConnectionPhase.idle; }); + setState(() { + _phase = ConnectionPhase.idle; + }); _showLocalNetworkDeniedSnackBar(); return; } @@ -1520,6 +1527,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { } } + Future _onOpenPokeSettings() async { + await _pokePreferences.load(); + if (!mounted) return; + await showDialog( + context: context, + builder: (ctx) => + PokeNotificationSettingsDialog(preferences: _pokePreferences), + ); + } + Future _askChannelPassword(AppL10n l10n) async { // Same pattern as _onAddCurrentBookmark: route the dialog // through a dedicated StatefulWidget so its @@ -1804,9 +1821,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || _inlineChatTarget == null) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppL10n.of(context).chatPanelCollapsedHint), - ), + SnackBar(content: Text(AppL10n.of(context).chatPanelCollapsedHint)), ); }); } @@ -1827,52 +1842,68 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { ); } - void _showPokeSnackBar({ + Future _handleIncomingPoke({ + required BigInt senderId, required String senderName, required String message, - required DateTime receivedAt, - }) { - _pokeSnackBarPokes.value = [ - ..._pokeSnackBarPokes.value, - _ReceivedPoke( - senderName: senderName, - message: message, - receivedAt: receivedAt, - ), - ]; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _renderPokeSnackBar(); - }); + required rust.BridgePokeStrength? strength, + }) async { + if (senderId == _snapshot?.ownClientId) return; + await _pokePreferencesReady; + if (!_pokePreferences.pokesEnabled.value) return; + if (_pokePreferences.isMuted(senderId)) return; + final pokeStrength = strength ?? rust.BridgePokeStrength.suppressed; + if (pokeStrength == rust.BridgePokeStrength.suppressedOverflow && mounted) { + _showPokeOverflowMutePrompt(senderId: senderId, senderName: senderName); + } + if (_isPokeSenderActiveChat(senderId)) return; + await _pokeNotifications.show( + senderName: senderName, + message: message, + senderId: senderId, + strength: pokeStrength, + ); } - void _renderPokeSnackBar() { - if (_pokeSnackBarPokes.value.isEmpty || _pokeSnackBarVisible) return; - _pokeSnackBarVisible = true; + bool _isPokeSenderActiveChat(BigInt senderId) { + return isPokeSenderActiveChat( + chatOpen: _chatOpen, + inlineChatTarget: _inlineChatTarget, + senderId: senderId, + ); + } + + void _showPokeOverflowMutePrompt({ + required BigInt senderId, + required String senderName, + }) { + final l10n = AppL10n.of(context); final messenger = ScaffoldMessenger.of(context); - final controller = messenger.showSnackBar( + messenger.showSnackBar( SnackBar( behavior: SnackBarBehavior.floating, margin: _chatSnackBarMargin(), - duration: const Duration(days: 365), - dismissDirection: DismissDirection.none, - content: _PokeSnackBarContent(pokes: _pokeSnackBarPokes), + duration: const Duration(seconds: 8), + content: Text( + l10n.pokeOverflowMutePrompt(senderName), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), action: SnackBarAction( - label: AppL10n.of(context).pokeSnackBarClearAction, + label: l10n.pokeOverflowMuteAction, onPressed: () { - _pokeSnackBarPokes.value = const []; - _pokeSnackBarVisible = false; + unawaited(_pokePreferences.muteSender(senderId)); + messenger.showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + margin: _chatSnackBarMargin(), + content: Text(l10n.pokeMutedSenderConfirmation(senderName)), + ), + ); }, ), ), ); - controller.closed.then((_) { - if (!mounted) return; - _pokeSnackBarVisible = false; - if (_pokeSnackBarPokes.value.isEmpty) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _renderPokeSnackBar(); - }); - }); } void _showChatMessageSnackBar({ @@ -1880,7 +1911,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { required String message, required rust.BridgeMessageTarget target, }) { - if (_pokeSnackBarPokes.value.isNotEmpty) return; final messenger = ScaffoldMessenger.of(context); messenger.hideCurrentSnackBar(); messenger.showSnackBar( @@ -2377,6 +2407,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { themeMode: widget.themeMode, onThemeModeChanged: widget.onThemeModeChanged, ), + IconButton( + tooltip: l10n.pokeSettingsAction, + icon: const Icon(Icons.notifications_outlined), + onPressed: () => unawaited(_onOpenPokeSettings()), + ), if (_phase.canOpenChatWithSnapshot(hasSnapshot: _snapshot != null)) ...[ Padding( padding: const EdgeInsetsDirectional.only(end: 12), @@ -2856,83 +2891,3 @@ class _LiveDiagnosticsDialogState extends State<_LiveDiagnosticsDialog> { ); } } - -class _PokeSnackBarContent extends StatelessWidget { - const _PokeSnackBarContent({required this.pokes}); - - final ValueListenable> pokes; - - @override - Widget build(BuildContext context) { - return ValueListenableBuilder>( - valueListenable: pokes, - builder: (context, entries, _) { - final l10n = AppL10n.of(context); - final visible = entries.length <= 3 - ? entries - : entries.sublist(entries.length - 3); - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (entries.length > 3) - Padding( - padding: const EdgeInsetsDirectional.only(bottom: 2), - child: Text( - l10n.pokeSnackBarMoreIndicator, - style: const TextStyle(fontWeight: FontWeight.w600), - ), - ), - for (final poke in visible) _PokeSnackBarRow(poke: poke), - ], - ); - }, - ); - } -} - -class _PokeSnackBarRow extends StatelessWidget { - const _PokeSnackBarRow({required this.poke}); - - final _ReceivedPoke poke; - - @override - Widget build(BuildContext context) { - final l10n = AppL10n.of(context); - final message = poke.message.trim(); - final text = message.isEmpty - ? l10n.pokeSnackBarIncomingNoMessage(poke.senderName) - : l10n.pokeSnackBarIncomingWithMessage(poke.senderName, message); - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 1), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Text( - text, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontWeight: FontWeight.w600), - ), - ), - const SizedBox(width: 12), - Text( - pokeSnackBarTimeLabel(poke.receivedAt), - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onInverseSurface.withValues(alpha: 0.72), - ), - ), - ], - ), - ); - } -} - -String pokeSnackBarTimeLabel(DateTime timestamp) { - String two(int value) => value.toString().padLeft(2, '0'); - return '${two(timestamp.hour)}:${two(timestamp.minute)}'; -} diff --git a/apps/chanora_flutter/test/services/poke_active_chat_test.dart b/apps/chanora_flutter/test/services/poke_active_chat_test.dart new file mode 100644 index 0000000..7f48092 --- /dev/null +++ b/apps/chanora_flutter/test/services/poke_active_chat_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/main.dart'; +import 'package:chanora_flutter/src/rust/api.dart' as rust; + +void main() { + test('active poke chat suppresses same sender notification only', () { + final sender = BigInt.from(42); + + expect( + isPokeSenderActiveChat( + chatOpen: true, + inlineChatTarget: rust.BridgeMessageTarget.poke(sender), + senderId: sender, + ), + isTrue, + ); + expect( + isPokeSenderActiveChat( + chatOpen: true, + inlineChatTarget: rust.BridgeMessageTarget.poke(BigInt.from(7)), + senderId: sender, + ), + isFalse, + ); + }); + + test('active private chat also suppresses same sender poke notification', () { + final sender = BigInt.from(42); + + expect( + isPokeSenderActiveChat( + chatOpen: true, + inlineChatTarget: rust.BridgeMessageTarget.client(sender), + senderId: sender, + ), + isTrue, + ); + expect( + isPokeSenderActiveChat( + chatOpen: false, + inlineChatTarget: rust.BridgeMessageTarget.client(sender), + senderId: sender, + ), + isFalse, + ); + }); +} From 82bfa0ea1f1ede9acfe53fdb64891002bfb20bdf Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Mon, 8 Jun 2026 23:32:18 +0900 Subject: [PATCH 13/14] docs: remove trailing whitespace from continuation design --- .../specs/2026-06-08-maintainability-continuation-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-06-08-maintainability-continuation-design.md b/docs/superpowers/specs/2026-06-08-maintainability-continuation-design.md index ee43502..d75aab5 100644 --- a/docs/superpowers/specs/2026-06-08-maintainability-continuation-design.md +++ b/docs/superpowers/specs/2026-06-08-maintainability-continuation-design.md @@ -1,6 +1,6 @@ # Maintainability Continuation Design -**Date:** 2026-06-08 +**Date:** 2026-06-08 **Status:** Approved design for implementation and full code-review remediation; Task 0 and focused audio-realtime fixes landed, documentation/governance alignment in progress **Scope:** Continue the current working-branch maintainability pass, add full code-review findings, and fix high-risk bugs before broad rewrites. From 7922eabcf0387eec1e10aafb4203aeabb11d8a78 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Mon, 8 Jun 2026 23:38:55 +0900 Subject: [PATCH 14/14] build(android): keep notification icon resource --- apps/chanora_flutter/android/app/src/main/res/raw/keep.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 apps/chanora_flutter/android/app/src/main/res/raw/keep.xml diff --git a/apps/chanora_flutter/android/app/src/main/res/raw/keep.xml b/apps/chanora_flutter/android/app/src/main/res/raw/keep.xml new file mode 100644 index 0000000..a4a69b8 --- /dev/null +++ b/apps/chanora_flutter/android/app/src/main/res/raw/keep.xml @@ -0,0 +1,3 @@ + +