feat(core): A.6.1 — use OS connectivity signals to drive reconnect

Extends the A.6 supervisor with an OS-level connectivity hint so a
returning network triggers a redial immediately instead of waiting
out the current backoff slot (up to 60 s). The watchdog remains the
authoritative loss detector — the OS signal is advisory.

* `chanora_core::NetworkState` (Unknown / Online / Offline) is owned
  by `ChanoraSession` via a `tokio::sync::watch::Sender`.
  `set_network_state()` / `network_state()` are the public accessors.
* The supervisor's watch-phase `select!` gains a `network_rx`
  branch: Offline pre-charges watchdog misses (capped at
  `MAX_MISSES - 1`) so the next probe failure trips immediately;
  Online clears stale misses. This shrinks UI-banner latency on a
  Wi-Fi drop from ~15 s to ~5 s.
* The reconnect-loop's backoff sleep races against Online: a
  transition cuts the sleep short and resets the attempt counter so
  future losses start at the smallest backoff window again.
* `chanora_bridge` adds `BridgeNetworkState` (mirror enum) and a
  sync `set_network_state(state)` function. On platforms with no
  signal wired the supervisor stays at Unknown and falls back to
  pure watchdog/backoff — no behavioural regression vs A.6.
* Flutter adds `connectivity_plus ^6.1.0` and wires
  `_wireConnectivity()` in `main()`: seeds with `checkConnectivity()`
  then forwards every `onConnectivityChanged` to the bridge,
  mapping any non-`none` transport to Online.

Verified on Moto G Stylus 5G (Android 14): `svc wifi disable && svc
data disable` for ~40 s — reconnect banner appeared promptly
because the watchdog was pre-charged. After `svc wifi enable && svc
data enable` the supervisor woke from its 15 s backoff slot and
reconnected within seconds; the channel tree re-rendered without
user action.
This commit is contained in:
EdisonJwa
2026-05-15 01:07:21 +08:00
parent 0bef61aea2
commit f52d702e27
10 changed files with 485 additions and 63 deletions
+33
View File
@@ -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<BridgeNetworkState> 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
+103 -18
View File
@@ -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::SseCodec, _>(
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 = <crate::api::BridgeNetworkState>::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 = <i32>::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::<NativeEndian>().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::<NativeEndian>().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<crate::api::BridgeEvent> 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<crate::api::BridgeNetworkState>
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) {
<i32>::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::<NativeEndian>(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::<NativeEndian>(self).unwrap();
}
}
#[cfg(not(target_family = "wasm"))]
mod io {
// This file is automatically generated, so please do not edit it.