diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 1f0d865..a07fd78 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -11,6 +11,7 @@ import 'dart:async'; +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; import 'l10n/generated/app_localizations.dart'; @@ -20,9 +21,42 @@ import 'src/rust/frb_generated.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await RustLib.init(); + // Push the OS-reported connectivity state into the core supervisor. + // The supervisor uses this to short-circuit reconnect backoff when + // the network comes back, and to pre-charge the loss watchdog when + // the OS already knows we're offline (A.6.1). + unawaited(_wireConnectivity()); runApp(const ChanoraApp()); } +/// Map `connectivity_plus`' list-of-results to our coarse tri-state. +/// We consider the device "Online" if any of the reported transports +/// is non-`none`. This is intentionally permissive — the supervisor's +/// watchdog still verifies reachability against the actual server. +rust.BridgeNetworkState _mapConnectivity(List results) { + if (results.isEmpty) return rust.BridgeNetworkState.unknown; + final allNone = results.every((r) => r == ConnectivityResult.none); + if (allNone) return rust.BridgeNetworkState.offline; + return rust.BridgeNetworkState.online; +} + +Future _wireConnectivity() async { + final connectivity = Connectivity(); + // Seed with the current value so the supervisor has a real reading + // before the first transition. + try { + final initial = await connectivity.checkConnectivity(); + rust.setNetworkState(state: _mapConnectivity(initial)); + } catch (_) { + // Best effort; if the plugin isn't available on this platform + // we stay at Unknown and the supervisor falls back to its + // watchdog-only behaviour. + } + connectivity.onConnectivityChanged.listen((results) { + rust.setNetworkState(state: _mapConnectivity(results)); + }); +} + class ChanoraApp extends StatelessWidget { const ChanoraApp({super.key}); diff --git a/apps/chanora_flutter/lib/src/rust/api.dart b/apps/chanora_flutter/lib/src/rust/api.dart index beb4595..e605985 100644 --- a/apps/chanora_flutter/lib/src/rust/api.dart +++ b/apps/chanora_flutter/lib/src/rust/api.dart @@ -10,7 +10,7 @@ import 'package:freezed_annotation/freezed_annotation.dart' hide protected; part 'api.freezed.dart'; // These functions are ignored because they are not marked as `pub`: `runtime`, `session` -// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `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): `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from` /// Connect to a TeamSpeak-compatible server and return the initial /// state snapshot. Honours the DEC-006 single-connection invariant @@ -37,6 +37,13 @@ Future startAudio() => RustLib.instance.api.crateApiStartAudio(); Future setPtt({required bool active}) => RustLib.instance.api.crateApiSetPtt(active: active); +/// Notify the core of the latest OS-reported connectivity state. +/// Called by the Flutter side from `connectivity_plus` callbacks. +/// The core's supervisor uses this to (a) pre-charge the watchdog +/// on Offline and (b) short-circuit reconnect backoff on Online. +void setNetworkState({required BridgeNetworkState state}) => + RustLib.instance.api.crateApiSetNetworkState(state: state); + /// Subscribe to lifecycle events. Each call yields a fresh /// subscription; multiple subscribers are supported. On slow /// consumers, events are dropped rather than blocking the supervisor @@ -185,6 +192,19 @@ sealed class BridgeEvent with _$BridgeEvent { const factory BridgeEvent.audioStopped() = BridgeEvent_AudioStopped; } +/// Coarse OS-reported network state. Mirrors +/// [`chanora_core::NetworkState`] across the bridge. +enum BridgeNetworkState { + /// No signal seen yet. + unknown, + + /// OS reports a usable network. + online, + + /// OS reports no network. + offline, +} + /// Server snapshot as seen by Dart. class BridgeSnapshot { /// Server name. diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.dart index 4c3b1f0..0fb2f0a 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.dart @@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => -1896742393; + int get rustContentHash => -1212711005; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -94,6 +94,8 @@ abstract class RustLibApi extends BaseApi { Future crateApiIsConnected(); + void crateApiSetNetworkState({required BridgeNetworkState state}); + Future crateApiSetPtt({required bool active}); Future crateApiSnapshot(); @@ -281,6 +283,29 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiIsConnectedConstMeta => const TaskConstMeta(debugName: "is_connected", argNames: []); + @override + void crateApiSetNetworkState({required BridgeNetworkState state}) { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_bridge_network_state(state, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 7)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiSetNetworkStateConstMeta, + argValues: [state], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiSetNetworkStateConstMeta => + const TaskConstMeta(debugName: "set_network_state", argNames: ["state"]); + @override Future crateApiSetPtt({required bool active}) { return handler.executeNormal( @@ -291,7 +316,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 7, + funcId: 8, port: port_, ); }, @@ -318,7 +343,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 8, + funcId: 9, port: port_, ); }, @@ -345,7 +370,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 9, + funcId: 10, port: port_, ); }, @@ -477,6 +502,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + BridgeNetworkState dco_decode_bridge_network_state(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return BridgeNetworkState.values[raw as int]; + } + @protected BridgeSnapshot dco_decode_bridge_snapshot(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -493,6 +524,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + int dco_decode_i_32(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + @protected PlatformInt64 dco_decode_i_64(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -665,6 +702,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + BridgeNetworkState sse_decode_bridge_network_state( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_i_32(deserializer); + return BridgeNetworkState.values[inner]; + } + @protected BridgeSnapshot sse_decode_bridge_snapshot(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -684,6 +730,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + int sse_decode_i_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getInt32(); + } + @protected PlatformInt64 sse_decode_i_64(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -748,12 +800,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { // Codec=Sse (Serialization based), see doc to use other codecs } - @protected - int sse_decode_i_32(SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - return deserializer.buffer.getInt32(); - } - @protected void sse_encode_AnyhowException( AnyhowException self, @@ -871,6 +917,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + void sse_encode_bridge_network_state( + BridgeNetworkState 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_snapshot( BridgeSnapshot self, @@ -885,6 +940,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_list_bridge_client(self.clients, serializer); } + @protected + void sse_encode_i_32(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putInt32(self); + } + @protected void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -947,10 +1008,4 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { void sse_encode_unit(void self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs } - - @protected - void sse_encode_i_32(int self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - serializer.buffer.putInt32(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 8ec8270..3005cf5 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart @@ -48,9 +48,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BridgeEvent dco_decode_bridge_event(dynamic raw); + @protected + BridgeNetworkState dco_decode_bridge_network_state(dynamic raw); + @protected BridgeSnapshot dco_decode_bridge_snapshot(dynamic raw); + @protected + int dco_decode_i_32(dynamic raw); + @protected PlatformInt64 dco_decode_i_64(dynamic raw); @@ -104,9 +110,17 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BridgeEvent sse_decode_bridge_event(SseDeserializer deserializer); + @protected + BridgeNetworkState sse_decode_bridge_network_state( + SseDeserializer deserializer, + ); + @protected BridgeSnapshot sse_decode_bridge_snapshot(SseDeserializer deserializer); + @protected + int sse_decode_i_32(SseDeserializer deserializer); + @protected PlatformInt64 sse_decode_i_64(SseDeserializer deserializer); @@ -135,9 +149,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_decode_unit(SseDeserializer deserializer); - @protected - int sse_decode_i_32(SseDeserializer deserializer); - @protected void sse_encode_AnyhowException( AnyhowException self, @@ -174,12 +185,21 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_bridge_event(BridgeEvent self, SseSerializer serializer); + @protected + void sse_encode_bridge_network_state( + BridgeNetworkState self, + SseSerializer serializer, + ); + @protected void sse_encode_bridge_snapshot( BridgeSnapshot self, SseSerializer serializer, ); + @protected + void sse_encode_i_32(int self, SseSerializer serializer); + @protected void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer); @@ -212,9 +232,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_unit(void self, SseSerializer serializer); - - @protected - void sse_encode_i_32(int self, SseSerializer serializer); } // Section: wire_class 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 ad79de9..5e189b5 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart @@ -50,9 +50,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BridgeEvent dco_decode_bridge_event(dynamic raw); + @protected + BridgeNetworkState dco_decode_bridge_network_state(dynamic raw); + @protected BridgeSnapshot dco_decode_bridge_snapshot(dynamic raw); + @protected + int dco_decode_i_32(dynamic raw); + @protected PlatformInt64 dco_decode_i_64(dynamic raw); @@ -106,9 +112,17 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BridgeEvent sse_decode_bridge_event(SseDeserializer deserializer); + @protected + BridgeNetworkState sse_decode_bridge_network_state( + SseDeserializer deserializer, + ); + @protected BridgeSnapshot sse_decode_bridge_snapshot(SseDeserializer deserializer); + @protected + int sse_decode_i_32(SseDeserializer deserializer); + @protected PlatformInt64 sse_decode_i_64(SseDeserializer deserializer); @@ -137,9 +151,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_decode_unit(SseDeserializer deserializer); - @protected - int sse_decode_i_32(SseDeserializer deserializer); - @protected void sse_encode_AnyhowException( AnyhowException self, @@ -176,12 +187,21 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_bridge_event(BridgeEvent self, SseSerializer serializer); + @protected + void sse_encode_bridge_network_state( + BridgeNetworkState self, + SseSerializer serializer, + ); + @protected void sse_encode_bridge_snapshot( BridgeSnapshot self, SseSerializer serializer, ); + @protected + void sse_encode_i_32(int self, SseSerializer serializer); + @protected void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer); @@ -214,9 +234,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_unit(void self, SseSerializer serializer); - - @protected - void sse_encode_i_32(int self, SseSerializer serializer); } // Section: wire_class diff --git a/apps/chanora_flutter/pubspec.lock b/apps/chanora_flutter/pubspec.lock index efd4404..3b8043c 100644 --- a/apps/chanora_flutter/pubspec.lock +++ b/apps/chanora_flutter/pubspec.lock @@ -129,6 +129,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec + url: "https://pub.dev" + source: hosted + version: "6.1.5" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" + url: "https://pub.dev" + source: hosted + version: "2.1.0" convert: dependency: transitive description: @@ -161,6 +177,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.7" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" fake_async: dependency: transitive description: @@ -169,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" file: dependency: transitive description: @@ -216,6 +248,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" freezed: dependency: "direct dev" description: @@ -360,6 +397,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" package_config: dependency: transitive description: @@ -376,6 +421,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" pool: dependency: transitive description: @@ -541,6 +602,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" yaml: dependency: transitive description: diff --git a/apps/chanora_flutter/pubspec.yaml b/apps/chanora_flutter/pubspec.yaml index 66f44e1..dd7e8bb 100644 --- a/apps/chanora_flutter/pubspec.yaml +++ b/apps/chanora_flutter/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: cupertino_icons: ^1.0.8 flutter_rust_bridge: 2.12.0 freezed_annotation: ^3.1.0 + connectivity_plus: ^6.1.0 dev_dependencies: flutter_test: diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index 6d0bba5..f5d2d98 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -41,7 +41,7 @@ use std::sync::Arc; use std::time::Duration; use thiserror::Error; -use tokio::sync::{broadcast, oneshot, Mutex}; +use tokio::sync::{broadcast, oneshot, watch, Mutex}; use tokio::task::JoinHandle; use tracing::{info, warn}; @@ -121,6 +121,20 @@ pub enum SessionEvent { AudioStopped, } +/// Coarse OS-reported network state. Populated by the Flutter side +/// via `connectivity_plus`; on platforms where no signal is wired +/// we stay at `Unknown` forever and the supervisor falls back to +/// pure watchdog/backoff behaviour. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NetworkState { + /// No signal seen yet — treat as ambiguous; don't change behaviour. + Unknown, + /// OS reports at least one network with internet capability. + Online, + /// OS reports no networks available. + Offline, +} + /// Channel capacity for the broadcast events. Generous because /// reconnect cycles emit several events per attempt; if subscribers /// fall behind we'd rather skip than block the supervisor. @@ -161,18 +175,39 @@ struct ConnectedState { pub struct ChanoraSession { inner: Arc>>, events_tx: broadcast::Sender, + /// OS-reported network state. Updated by the bridge from + /// `connectivity_plus` callbacks. Supervisor observes via + /// [`watch::Receiver`]. + network_tx: watch::Sender, } impl ChanoraSession { /// Construct an empty session. Performs no I/O. pub fn new() -> Self { let (events_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY); + let (network_tx, _) = watch::channel(NetworkState::Unknown); Self { inner: Arc::new(Mutex::new(None)), events_tx, + network_tx, } } + /// Push an OS connectivity update. Called by the bridge when + /// `connectivity_plus` fires. Safe to call from any thread. + pub fn set_network_state(&self, state: NetworkState) { + // `send_if_modified` would suppress duplicate sends, but + // `watch::Sender::send` already drops sends with no + // subscribers gracefully. Using `send_replace` so the value + // is updated even before any subscriber attaches. + let _ = self.network_tx.send_replace(state); + } + + /// Read the latest OS connectivity state. Useful for diagnostics. + pub fn network_state(&self) -> NetworkState { + *self.network_tx.borrow() + } + /// Subscribe to lifecycle events. The returned receiver fires /// on connect / lost / reconnecting / disconnected / /// audio-started / audio-stopped transitions. Multiple @@ -213,6 +248,7 @@ impl ChanoraSession { probe, cancel_rx, sup_inner.clone(), + self.network_tx.subscribe(), )); let _ = self.events_tx.send(SessionEvent::Connected { @@ -355,6 +391,7 @@ async fn supervisor_loop( initial_probe: chanora_protocol::SnapshotProbe, mut cancel_rx: oneshot::Receiver<()>, sup_inner: Arc>, + mut network_rx: watch::Receiver, ) { let mut lost_rx = initial_lost_rx; let mut probe = initial_probe; @@ -390,6 +427,39 @@ async fn supervisor_loop( } } } + changed = network_rx.changed() => { + if changed.is_err() { + // The session was dropped; nothing more to do. + return; + } + let new_state = *network_rx.borrow_and_update(); + if new_state == NetworkState::Offline { + // OS says we're offline. Don't fabricate a + // loss outright — captive portals and + // transient flicker can produce false + // negatives — but pre-charge the watchdog + // so the next probe failure trips it + // immediately. This shrinks UI latency from + // ~15s to ~5s in the common case. + misses = misses.saturating_add(1).min(WATCHDOG_MAX_MISSES - 1); + info!( + target: "chanora_core", + misses, + "connectivity: OS reports offline; pre-charging watchdog" + ); + } else if new_state == NetworkState::Online { + // Reset on confirmed online so we don't + // carry stale Offline charges into the + // healthy state. + if misses > 0 { + info!( + target: "chanora_core", + "connectivity: OS reports online; clearing watchdog misses" + ); + } + misses = 0; + } + } _ = watchdog.tick() => { match tokio::time::timeout(WATCHDOG_PROBE_TIMEOUT, probe.probe()).await { Ok(Ok(_)) => { @@ -426,14 +496,6 @@ async fn supervisor_loop( misses, "watchdog: declaring connection lost" ); - // Replace the dead protocol client with a - // dropped slot so the reconnect path below - // doesn't accidentally keep using it. We - // also stop audio here (the reconnect path - // does this too, but doing it now ensures - // the mic / playback engine stops talking - // to a stale voice_out_tx as quickly as - // possible). break chanora_protocol::DisconnectReason::Error( "watchdog: server stopped responding".to_string(), ); @@ -490,17 +552,46 @@ async fn supervisor_loop( "reconnect: sleeping before next attempt" ); - // Sleep with cancellation support. - let slept = tokio::select! { + // Sleep with cancellation support. An OS + // "online" notification short-circuits the + // sleep and resets the attempt counter so the + // next outage starts with the smallest backoff + // window again. + enum SleepOutcome { Elapsed, NetworkUp, Cancelled } + let outcome = tokio::select! { biased; - _ = &mut cancel_rx => { + _ = &mut cancel_rx => SleepOutcome::Cancelled, + changed = network_rx.changed() => { + if changed.is_err() { + SleepOutcome::Cancelled + } else if *network_rx.borrow_and_update() == NetworkState::Online { + SleepOutcome::NetworkUp + } else { + // Offline / Unknown transition — keep waiting the rest of the slot. + tokio::select! { + _ = &mut cancel_rx => SleepOutcome::Cancelled, + _ = tokio::time::sleep(Duration::from_secs(delay_secs as u64)) => SleepOutcome::Elapsed, + } + } + } + _ = tokio::time::sleep(Duration::from_secs(delay_secs as u64)) => SleepOutcome::Elapsed, + }; + match outcome { + SleepOutcome::Cancelled => { info!(target: "chanora_core", "supervisor cancelled during backoff"); return; } - _ = tokio::time::sleep(Duration::from_secs(delay_secs as u64)) => true, - }; - if !slept { - return; + SleepOutcome::NetworkUp => { + info!( + target: "chanora_core", + attempt, + "connectivity: OS reports online; redialling immediately" + ); + // Reset attempt counter so future losses + // start the backoff schedule fresh. + attempt = 0; + } + SleepOutcome::Elapsed => {} } info!(target: "chanora_core", attempt, "reconnect: dialling"); diff --git a/crates/chanora_bridge/src/api.rs b/crates/chanora_bridge/src/api.rs index 7edd0e8..bf2bb4b 100644 --- a/crates/chanora_bridge/src/api.rs +++ b/crates/chanora_bridge/src/api.rs @@ -233,6 +233,39 @@ pub struct BridgeAudioStats { pub ptt_active: bool, } +// ---------- Connectivity (A.6.1) ---------- + +/// Coarse OS-reported network state. Mirrors +/// [`chanora_core::NetworkState`] across the bridge. +#[derive(Debug, Clone, Copy)] +pub enum BridgeNetworkState { + /// No signal seen yet. + Unknown, + /// OS reports a usable network. + Online, + /// OS reports no network. + Offline, +} + +impl From for chanora_core::NetworkState { + fn from(s: BridgeNetworkState) -> Self { + match s { + BridgeNetworkState::Unknown => chanora_core::NetworkState::Unknown, + BridgeNetworkState::Online => chanora_core::NetworkState::Online, + BridgeNetworkState::Offline => chanora_core::NetworkState::Offline, + } + } +} + +/// Notify the core of the latest OS-reported connectivity state. +/// Called by the Flutter side from `connectivity_plus` callbacks. +/// The core's supervisor uses this to (a) pre-charge the watchdog +/// on Offline and (b) short-circuit reconnect backoff on Online. +#[frb(sync)] +pub fn set_network_state(state: BridgeNetworkState) { + session().set_network_state(state.into()); +} + // ---------- Events (A.6) ---------- /// Lifecycle event surfaced to Dart. Schema-controlled mirror of diff --git a/crates/chanora_bridge/src/frb_generated.rs b/crates/chanora_bridge/src/frb_generated.rs index 7967217..f55de7d 100644 --- a/crates/chanora_bridge/src/frb_generated.rs +++ b/crates/chanora_bridge/src/frb_generated.rs @@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1896742393; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1212711005; // Section: executor @@ -258,6 +258,38 @@ fn wire__crate__api__is_connected_impl( }, ) } +fn wire__crate__api__set_network_state_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "set_network_state", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_state = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok({ + crate::api::set_network_state(api_state); + })?; + Ok(output_ok) + })()) + }, + ) +} fn wire__crate__api__set_ptt_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -522,6 +554,19 @@ impl SseDecode for crate::api::BridgeEvent { } } +impl SseDecode for crate::api::BridgeNetworkState { + // 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::BridgeNetworkState::Unknown, + 1 => crate::api::BridgeNetworkState::Online, + 2 => crate::api::BridgeNetworkState::Offline, + _ => unreachable!("Invalid variant for BridgeNetworkState: {}", inner), + }; + } +} + impl SseDecode for crate::api::BridgeSnapshot { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -542,6 +587,13 @@ impl SseDecode for crate::api::BridgeSnapshot { } } +impl SseDecode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_i32::().unwrap() + } +} + impl SseDecode for i64 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -611,13 +663,6 @@ impl SseDecode for () { fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {} } -impl SseDecode for i32 { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { - deserializer.cursor.read_i32::().unwrap() - } -} - fn pde_ffi_dispatcher_primary_impl( func_id: i32, port: flutter_rust_bridge::for_generated::MessagePort, @@ -633,9 +678,9 @@ fn pde_ffi_dispatcher_primary_impl( 4 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len), 5 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len), 6 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len), - 7 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len), - 8 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len), - 9 => wire__crate__api__start_audio_impl(port, ptr, rust_vec_len, data_len), + 8 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len), + 9 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len), + 10 => wire__crate__api__start_audio_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -648,6 +693,7 @@ fn pde_ffi_dispatcher_sync_impl( ) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { // Codec=Pde (Serialization + dispatch), see doc to use other codecs match func_id { + 7 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -778,6 +824,28 @@ impl flutter_rust_bridge::IntoIntoDart for crate::api:: } } // Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::BridgeNetworkState { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + match self { + Self::Unknown => 0.into_dart(), + Self::Online => 1.into_dart(), + Self::Offline => 2.into_dart(), + _ => unreachable!(), + } + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::BridgeNetworkState +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::BridgeNetworkState +{ + fn into_into_dart(self) -> crate::api::BridgeNetworkState { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::BridgeSnapshot { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { [ @@ -927,6 +995,23 @@ impl SseEncode for crate::api::BridgeEvent { } } +impl SseEncode for crate::api::BridgeNetworkState { + // 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::BridgeNetworkState::Unknown => 0, + crate::api::BridgeNetworkState::Online => 1, + crate::api::BridgeNetworkState::Offline => 2, + _ => { + unimplemented!(""); + } + }, + serializer, + ); + } +} + impl SseEncode for crate::api::BridgeSnapshot { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -939,6 +1024,13 @@ impl SseEncode for crate::api::BridgeSnapshot { } } +impl SseEncode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_i32::(self).unwrap(); + } +} + impl SseEncode for i64 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -1002,13 +1094,6 @@ impl SseEncode for () { fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {} } -impl SseEncode for i32 { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { - serializer.cursor.write_i32::(self).unwrap(); - } -} - #[cfg(not(target_family = "wasm"))] mod io { // This file is automatically generated, so please do not edit it.