From 59a6c2b460ad1ee68f544c45c646ad719fd18953 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Fri, 9 Oct 2026 06:01:49 +0200 Subject: [PATCH 1/2] Hand the hotspot over as a WIFI: payload beside its three lines (R76 slice 7) --- crates/connetto-client/src/hotspot.rs | 3 + .../src/hotspot/wifi_payload.rs | 257 ++++++++++++++++++ crates/connetto-client/src/lib.rs | 2 +- .../src/bin/connetto-android-proof.rs | 97 ++++++- examples/dioxus-desktop-demo/Cargo.lock | 9 +- examples/dioxus-desktop-demo/Cargo.toml | 4 +- examples/dioxus-desktop-demo/src/main.rs | 60 ++++ plans/master-implementation-plan.md | 4 +- 8 files changed, 420 insertions(+), 16 deletions(-) create mode 100644 crates/connetto-client/src/hotspot/wifi_payload.rs diff --git a/crates/connetto-client/src/hotspot.rs b/crates/connetto-client/src/hotspot.rs index 0cef1db6..d85b00f8 100644 --- a/crates/connetto-client/src/hotspot.rs +++ b/crates/connetto-client/src/hotspot.rs @@ -21,6 +21,9 @@ use crate::ClientEvent; #[cfg(target_os = "android")] pub(crate) mod android; +mod wifi_payload; + +pub use wifi_payload::WifiPayloadError; #[cfg(target_os = "android")] pub(crate) use android::{AndroidHotspotBackend, JoinedBind}; diff --git a/crates/connetto-client/src/hotspot/wifi_payload.rs b/crates/connetto-client/src/hotspot/wifi_payload.rs new file mode 100644 index 00000000..c8ee803b --- /dev/null +++ b/crates/connetto-client/src/hotspot/wifi_payload.rs @@ -0,0 +1,257 @@ +//! The hotspot offer as a `WIFI:` payload, the string a QR code or a +//! message carries (R76 decision 23). + +use zeroize::Zeroizing; + +use super::{HotspotOffer, HotspotSecurity}; + +/// Why a string is not a hotspot offer's payload (R76 decision 23). +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum WifiPayloadError { + /// The string does not start with `WIFI:`. + #[error("the string is not a WIFI: payload")] + NotWifi, + /// The payload ends before its closing `;;`, as a cut string does. + #[error("the payload ends before its closing ;;")] + Unterminated, + /// A field is not a letter, a colon and a value. + #[error("a field of the payload is not a letter, a colon and a value")] + Malformed, + /// A field appears twice. + #[error("the payload names its {0} field twice")] + Repeated(char), + /// The payload names no network. + #[error("the payload names no network")] + NoSsid, + /// The payload carries no password. + #[error("the payload carries no password")] + NoPassword, + /// The payload's security is not one a hotspot offers. + #[error("the payload's security {0} is not WPA or SAE")] + Security(String), + /// The payload's port is not a port. + #[error("the payload's port {0} is not a port")] + Port(String), +} + +impl HotspotOffer { + /// The offer as a `WIFI:` payload, which a phone's camera joins and + /// [`HotspotOffer::from_wifi_payload`] reads back with the port. + #[must_use] + pub fn to_wifi_payload(&self) -> Zeroizing { + let mut payload = Zeroizing::new(String::from("WIFI:T:")); + payload.push_str(match self.security { + HotspotSecurity::Wpa2 => "WPA", + HotspotSecurity::Wpa3 => "SAE", + }); + payload.push_str(";S:"); + escape_into(&mut payload, &self.ssid); + payload.push_str(";P:"); + escape_into(&mut payload, &self.passphrase); + payload.push(';'); + if let Some(port) = self.port { + payload.push_str("X:"); + payload.push_str(&port.to_string()); + payload.push(';'); + } + payload.push(';'); + payload + } + + /// The offer a `WIFI:` payload carries. A missing security reads as WPA2, + /// fields the format knows beyond these are skipped, and the payload must + /// end in its `;;`, so a string cut short is refused rather than read + /// with a shortened password. + /// + /// # Errors + /// + /// [`WifiPayloadError`] naming what the string lacks or carries wrong. + pub fn from_wifi_payload(payload: &str) -> Result { + let body = payload + .strip_prefix("WIFI:") + .ok_or(WifiPayloadError::NotWifi)?; + let mut security = None; + let mut ssid = None; + let mut passphrase: Option> = None; + let mut port = None; + let mut chars = body.chars(); + loop { + let Some(name) = chars.next() else { + return Err(WifiPayloadError::Unterminated); + }; + // The empty field closes the payload. + if name == ';' { + break; + } + if chars.next() != Some(':') { + return Err(WifiPayloadError::Malformed); + } + let value = Zeroizing::new(read_value(&mut chars)?); + let slot = match name { + 'T' => &mut security, + 'S' => &mut ssid, + 'P' => { + if passphrase.replace(value).is_some() { + return Err(WifiPayloadError::Repeated('P')); + } + continue; + } + 'X' => &mut port, + _ => continue, + }; + if slot.replace(value.to_string()).is_some() { + return Err(WifiPayloadError::Repeated(name)); + } + } + let security = match security.as_deref() { + None | Some("WPA") => HotspotSecurity::Wpa2, + Some("SAE") => HotspotSecurity::Wpa3, + Some(other) => return Err(WifiPayloadError::Security(other.to_owned())), + }; + let ssid = ssid + .filter(|ssid| !ssid.is_empty()) + .ok_or(WifiPayloadError::NoSsid)?; + let passphrase = passphrase + .filter(|passphrase| !passphrase.is_empty()) + .ok_or(WifiPayloadError::NoPassword)?; + let port = port + .map(|text| match text.parse::() { + Ok(port) if port != 0 => Ok(port), + _ => Err(WifiPayloadError::Port(text)), + }) + .transpose()?; + Ok(Self::new(ssid, passphrase.as_str(), security, port)) + } +} + +/// The characters the format escapes with a backslash. +const ESCAPED: [char; 5] = ['\\', ';', ',', ':', '"']; + +fn escape_into(payload: &mut String, value: &str) { + for c in value.chars() { + if ESCAPED.contains(&c) { + payload.push('\\'); + } + payload.push(c); + } +} + +/// One field's value, up to its unescaped `;`. +fn read_value(chars: &mut std::str::Chars<'_>) -> Result { + let mut value = String::new(); + loop { + match chars.next() { + Some(';') => return Ok(value), + Some('\\') => match chars.next() { + Some(escaped) => value.push(escaped), + None => return Err(WifiPayloadError::Unterminated), + }, + Some(c) => value.push(c), + None => return Err(WifiPayloadError::Unterminated), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn offer( + ssid: &str, + passphrase: &str, + security: HotspotSecurity, + port: Option, + ) -> HotspotOffer { + HotspotOffer::new(ssid, passphrase, security, port) + } + + #[test] + fn an_offer_writes_the_standard_fields_and_its_port() { + assert_eq!( + offer( + "AndroidShare_1234", + "k7v2pq9x", + HotspotSecurity::Wpa2, + Some(41_234) + ) + .to_wifi_payload() + .as_str(), + "WIFI:T:WPA;S:AndroidShare_1234;P:k7v2pq9x;X:41234;;" + ); + assert_eq!( + offer("camp", "secret pass", HotspotSecurity::Wpa3, None) + .to_wifi_payload() + .as_str(), + "WIFI:T:SAE;S:camp;P:secret pass;;" + ); + } + + #[test] + fn the_format_s_special_characters_are_escaped_and_read_back() { + let tricky = offer(r#"a;b,c:d\e"f"#, r"p;w\d:,", HotspotSecurity::Wpa2, Some(1)); + let payload = tricky.to_wifi_payload(); + assert_eq!( + payload.as_str(), + r#"WIFI:T:WPA;S:a\;b\,c\:d\\e\"f;P:p\;w\\d\:\,;X:1;;"# + ); + assert_eq!(HotspotOffer::from_wifi_payload(&payload), Ok(tricky)); + } + + #[test] + fn a_payload_reads_in_any_field_order_and_skips_unknown_fields() { + assert_eq!( + HotspotOffer::from_wifi_payload("WIFI:S:camp;H:false;X:7;R:1;T:SAE;P:pw;;"), + Ok(offer("camp", "pw", HotspotSecurity::Wpa3, Some(7))) + ); + assert_eq!( + HotspotOffer::from_wifi_payload("WIFI:P:pw;S:camp;T:WPA;;"), + Ok(offer("camp", "pw", HotspotSecurity::Wpa2, None)) + ); + } + + #[test] + fn a_payload_the_offer_cannot_stand_on_is_refused_by_name() { + for (payload, refusal) in [ + ("https://example.com", WifiPayloadError::NotWifi), + ("WIFI:T:WPA;P:pw;;", WifiPayloadError::NoSsid), + ("WIFI:T:WPA;S:camp;;", WifiPayloadError::NoPassword), + ( + "WIFI:T:nopass;S:camp;P:pw;;", + WifiPayloadError::Security("nopass".into()), + ), + ( + "WIFI:T:WEP;S:camp;P:pw;;", + WifiPayloadError::Security("WEP".into()), + ), + ( + "WIFI:T:WPA;S:camp;P:pw;X:70000;;", + WifiPayloadError::Port("70000".into()), + ), + ( + "WIFI:T:WPA;S:camp;P:pw;X:0;;", + WifiPayloadError::Port("0".into()), + ), + ("WIFI:T:WPA;S:camp;P:pw\\", WifiPayloadError::Unterminated), + ("WIFI:T:WPA;S:camp;P:pw;", WifiPayloadError::Unterminated), + ( + "WIFI:T:WPA;S:camp;P:pw;S:other;;", + WifiPayloadError::Repeated('S'), + ), + ("WIFI:T:WPA;Scamp;P:pw;;", WifiPayloadError::Malformed), + ] { + assert_eq!( + HotspotOffer::from_wifi_payload(payload), + Err(refusal), + "{payload}" + ); + } + } + + #[test] + fn a_missing_security_reads_as_wpa2() { + assert_eq!( + HotspotOffer::from_wifi_payload("WIFI:S:camp;P:pw;;"), + Ok(offer("camp", "pw", HotspotSecurity::Wpa2, None)) + ); + } +} diff --git a/crates/connetto-client/src/lib.rs b/crates/connetto-client/src/lib.rs index 217b2e1b..745cc7e7 100644 --- a/crates/connetto-client/src/lib.rs +++ b/crates/connetto-client/src/lib.rs @@ -107,7 +107,7 @@ pub use bluetooth::{ #[cfg(feature = "device-identity")] pub use enrolment::{CertificateError, DeviceEntry}; #[cfg(feature = "peer")] -pub use hotspot::{HotspotError, HotspotOffer, HotspotSecurity, JoinError}; +pub use hotspot::{HotspotError, HotspotOffer, HotspotSecurity, JoinError, WifiPayloadError}; pub use subscriptions::{DEFAULT_GRACE, MAX_GRACE}; pub mod teardown; diff --git a/crates/connetto-test-harness/src/bin/connetto-android-proof.rs b/crates/connetto-test-harness/src/bin/connetto-android-proof.rs index a15ff812..9a3c5b3e 100644 --- a/crates/connetto-test-harness/src/bin/connetto-android-proof.rs +++ b/crates/connetto-test-harness/src/bin/connetto-android-proof.rs @@ -46,6 +46,10 @@ //! `--peer-serial` as well, the second phone finds the first one's beacon and //! joins through the exchange behind it in place of the join form (R76 slice //! 6), and both phones end with their Bluetooth off. +//! +//! With `--payload` and `--peer-serial`, the second phone joins through the +//! first one's `WIFI:` payload pasted into its join form, the first phone's +//! Bluetooth left off as a host whose beacon is down (R76 slice 7). use std::path::{Path, PathBuf}; use std::time::Duration; @@ -91,6 +95,16 @@ const PEER_USER: &str = "bob"; /// the locales the proof phones run, which the driver taps in place of the /// user (R76 decision 16). const CONNECT_BUTTONS: [&str; 3] = ["Connect", "Connetti", "Verbinden"]; +/// The Bluetooth enable dialog's refusal in the runner's locales, which a +/// host whose beacon stays down taps (R76 decision 21). +const DENY_BUTTONS: [&str; 6] = [ + "Deny", + "Don't allow", + "Rifiuta", + "Non consentire", + "Ablehnen", + "Nicht zulassen", +]; /// The silence a link gets to end after the phones leave the hotspot, on /// either side. const LINK_END_BOUND: Duration = Duration::from_secs(120); @@ -116,6 +130,7 @@ async fn main() -> Result<()> { unlock_pin, peer_serial, bluetooth, + payload, } = cli_arguments()?; let device = Device::pick(serial).await?; let evidence = repo_path(&["target", "android-proof"])?.join(format!( @@ -151,10 +166,10 @@ async fn main() -> Result<()> { match (bluetooth, peer_serial, outcome) { (true, None, Ok(())) => bluetooth_proof(&device, &evidence).await, (bluetooth, Some(serial), Ok(())) => { - let join_by = if bluetooth { - JoinBy::Beacon - } else { - JoinBy::Form + let join_by = match (bluetooth, payload) { + (true, _) => JoinBy::Beacon, + (false, true) => JoinBy::Payload, + (false, false) => JoinBy::Form, }; peer_proof( &device, @@ -438,6 +453,8 @@ struct HotspotOfferLines { passphrase: String, /// The host's peer port. port: u16, + /// The offer as one `WIFI:` payload, where the page shows it. + payload: Option, } /// The offer a hosting phone shows, its lines read off the page, bounded. @@ -465,6 +482,9 @@ enum JoinBy { Form, /// The first phone's Bluetooth beacon and the exchange behind it (R76). Beacon, + /// The first phone's `WIFI:` payload pasted into the second's join form + /// (R76 decision 24). + Payload, } /// The two-phone peer proof (R76 slice 4): `host` hosts a hotspot from the @@ -605,6 +625,14 @@ async fn run_peer_proof( ]) .await?; } + if join_by == JoinBy::Payload { + // The host's user granted Bluetooth before, so only the enable + // dialog stands between its beacon and the radio. + for permission in BT_PERMISSIONS { + host.adb(&["shell", "pm", "grant", PACKAGE, permission]) + .await?; + } + } if join_by == JoinBy::Beacon { step("turn Bluetooth on and grant its permissions on both phones"); for phone in [host, joiner] { @@ -672,6 +700,13 @@ async fn host_and_link( join_by: JoinBy, ) -> Result { app.click("Host a hotspot").await?; + if join_by == JoinBy::Payload { + step("decline Bluetooth on the first phone"); + host.tap_dialog_button(&DENY_BUTTONS, "the Bluetooth enable dialog's refusal") + .await?; + app.wait_for_text("hosting, the beacon is off", Duration::from_secs(30)) + .await?; + } let offer = read_hotspot_offer(app).await?; host.screenshot(evidence, "host-offer").await?; @@ -683,6 +718,23 @@ async fn host_and_link( peer_app.set_input("port", &offer.port.to_string()).await?; peer_app.click("Join the hotspot").await?; } + JoinBy::Payload => { + let payload = offer + .payload + .as_deref() + .context("the host shows no WIFI: payload")?; + step("paste the payload on the second phone"); + peer_app.set_input("payload", payload).await?; + peer_app.click("Join from the payload").await?; + if peer_app + .wait_for_text("joining the hotspot", Duration::from_secs(10)) + .await + .is_err() + { + let page = peer_app.page_text().await.unwrap_or_default(); + bail!("the second phone did not take the payload, its page shows:\n{page}"); + } + } JoinBy::Beacon => { let beacon = read_beacon(app).await?; let prefix = beacon @@ -1136,6 +1188,9 @@ struct Arguments { /// The Bluetooth beacon proof, host-side alone, and the beacon join with /// `--peer-serial`. bluetooth: bool, + /// The two-phone join through the host's pasted `WIFI:` payload, with + /// `--peer-serial`. + payload: bool, } fn cli_arguments() -> Result { @@ -1146,6 +1201,10 @@ fn cli_arguments() -> Result { arguments.bluetooth = true; continue; } + if arg == "--payload" { + arguments.payload = true; + continue; + } let value = args.next().ok_or_else(|| anyhow!("{arg} needs a value"))?; match arg.as_str() { "--serial" => arguments.serial = Some(value), @@ -1463,6 +1522,13 @@ impl Device { /// Tap the system's network-approval dialog button, in the user's place /// (R76 decision 16), waiting for it to show. async fn tap_connect_button(&self) -> Result<()> { + self.tap_dialog_button(&CONNECT_BUTTONS, "the network approval's connect button") + .await + } + + /// Tap the system's dialog button `labels` name in one of the runner's + /// locales, waiting for it to show. + async fn tap_dialog_button(&self, labels: &[&str], what: &str) -> Result<()> { let locale = self .adb(&["shell", "getprop", "persist.sys.locale"]) .await?; @@ -1471,7 +1537,7 @@ impl Device { loop { // A dump can crash while the screen changes under it, so a failed // one waits for the next look. - match self.connect_button_bounds().await { + match self.dialog_button_bounds(labels).await { Ok(Some((left, top, right, bottom))) => { self.adb(&[ "shell", @@ -1488,17 +1554,18 @@ impl Device { } if Instant::now() >= deadline { bail!( - "the network approval's connect button never showed on {} (locale {locale})", - self.serial + "{what} never showed on {} (locale {})", + self.serial, + locale.trim() ); } sleep(Duration::from_secs(2)).await; } } - /// The bounds of the approval dialog's connect button, or none while the - /// dialog is not up, matched by the localized "Connect" text. - async fn connect_button_bounds(&self) -> Result> { + /// The bounds of a system dialog's button, or none while the dialog is + /// not up, matched by the localized texts `labels` name. + async fn dialog_button_bounds(&self, labels: &[&str]) -> Result> { self.adb(&["shell", "uiautomator", "dump", "/sdcard/connetto-ui.xml"]) .await?; let xml = self @@ -1508,7 +1575,7 @@ impl Device { let Some(text) = attribute(node, "text") else { continue; }; - if !CONNECT_BUTTONS.contains(&text.as_str()) { + if !labels.contains(&text.as_str()) { continue; } let Some(clickable) = attribute(node, "clickable") else { @@ -1519,7 +1586,7 @@ impl Device { } let bounds = attribute(node, "bounds") .and_then(|bounds| parse_bounds(&bounds)) - .ok_or_else(|| anyhow!("the connect button {text:?} has no bounds"))?; + .ok_or_else(|| anyhow!("the dialog button {text:?} has no bounds"))?; return Ok(Some(bounds)); } Ok(None) @@ -1706,6 +1773,7 @@ fn parse_offer_lines(page: &str) -> Option { ssid, passphrase, port: port.parse::().ok()?, + payload: line("hotspot payload: "), }) } @@ -1868,11 +1936,16 @@ echo "adb: device offline" >&2; exit 1"#, hotspot ssid: My Hotspot\n\ hotspot passphrase: s3cr3t pass\n\ hotspot port: 41234\n\ + hotspot payload: WIFI:T:WPA;S:My Hotspot;P:s3cr3t pass;X:41234;;\n\ hotspot: hosting\n"; let offer = parse_offer_lines(page).expect("all three lines are there"); assert_eq!(offer.ssid, "My Hotspot"); assert_eq!(offer.passphrase, "s3cr3t pass"); assert_eq!(offer.port, 41234); + assert_eq!( + offer.payload.as_deref(), + Some("WIFI:T:WPA;S:My Hotspot;P:s3cr3t pass;X:41234;;") + ); assert!(parse_offer_lines("hotspot ssid: only\n").is_none()); assert!(parse_offer_lines("hotspot port: none\n").is_none()); } diff --git a/examples/dioxus-desktop-demo/Cargo.lock b/examples/dioxus-desktop-demo/Cargo.lock index 75d8f844..75bbc3d0 100644 --- a/examples/dioxus-desktop-demo/Cargo.lock +++ b/examples/dioxus-desktop-demo/Cargo.lock @@ -1323,6 +1323,7 @@ dependencies = [ "dioxus", "manganis", "pg2sqlite", + "qrcode", "robius-directories", "rosetta-uuid", "tempfile", @@ -3998,7 +3999,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -6762,6 +6763,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quick-error" version = "2.0.1" diff --git a/examples/dioxus-desktop-demo/Cargo.toml b/examples/dioxus-desktop-demo/Cargo.toml index 13558aa7..d1f0bc98 100644 --- a/examples/dioxus-desktop-demo/Cargo.toml +++ b/examples/dioxus-desktop-demo/Cargo.toml @@ -8,6 +8,8 @@ license = "MIT" publish = false [dependencies] +# The hosted hotspot's `WIFI:` payload drawn as a QR code (R76 decision 23). +qrcode = { version = "0.14.1", default-features = false, features = ["svg"], optional = true } # Startup failures are reported in the window rather than aborting the process, so # the whole setup path is fallible. anyhow = "1" @@ -63,7 +65,7 @@ mobile = ["dioxus/mobile"] device-identity = ["connetto-client/device-identity", "connetto-auth-session/device-identity"] # The peer link and the hotspot it hosts and joins (R76): the device identity # the link proves with, and the demo's peer panel. -peer = ["connetto-client/peer"] +peer = ["connetto-client/peer", "dep:qrcode"] [dev-dependencies] connetto-test-harness = { path = "../../crates/connetto-test-harness" } diff --git a/examples/dioxus-desktop-demo/src/main.rs b/examples/dioxus-desktop-demo/src/main.rs index e54aea74..4838d5ff 100644 --- a/examples/dioxus-desktop-demo/src/main.rs +++ b/examples/dioxus-desktop-demo/src/main.rs @@ -669,6 +669,20 @@ struct OfferLines { passphrase: String, /// The device's peer port, present while it serves it. port: Option, + /// The offer as one `WIFI:` payload, which the panel also draws as a QR + /// code (R76 decision 23). + payload: String, +} + +/// The payload as an SVG QR code, `None` where it does not fit one. +#[cfg(feature = "peer")] +fn payload_qr(payload: &str) -> Option { + let code = qrcode::QrCode::new(payload.as_bytes()).ok()?; + Some( + code.render::>() + .min_dimensions(200, 200) + .build(), + ) } /// The host's beacon standing as the panel's line. @@ -742,6 +756,8 @@ fn PeerPanel(parts: SessionParts) -> Element { let mut join_ssid: Signal = use_signal(String::new); let mut join_passphrase: Signal = use_signal(String::new); let mut join_port: Signal = use_signal(String::new); + // A pasted or scanned `WIFI:` payload (R76 decision 24). + let mut join_payload: Signal = use_signal(String::new); // The link's and the hotspot's events, which the panel owns. let client = parts.0.client.clone(); @@ -820,6 +836,9 @@ fn PeerPanel(parts: SessionParts) -> Element { let mut join_outcome = outcome; let join_missing = missing; let leave_parts = parts.clone(); + let payload_parts = parts.clone(); + let payload_outcome = outcome; + let payload_missing = missing; let leave_outcome = outcome; let bt_parts = parts.clone(); let bt_outcome = outcome; @@ -857,6 +876,10 @@ fn PeerPanel(parts: SessionParts) -> Element { p { "hotspot ssid: " {offer.ssid.clone()} } p { "hotspot passphrase: " {offer.passphrase.clone()} } p { "hotspot port: " {port_line(offer.port)} } + p { "hotspot payload: " {offer.payload.clone()} } + if let Some(svg) = payload_qr(&offer.payload) { + div { dangerous_inner_html: svg } + } } p { "hotspot: " {outcome} } button { @@ -874,6 +897,7 @@ fn PeerPanel(parts: SessionParts) -> Element { ssid: hosted.offer.ssid.clone(), passphrase: hosted.offer.passphrase.clone(), port: hosted.offer.port, + payload: hosted.offer.to_wifi_payload().to_string(), })); outcome.set(match hosted.beacon { Ok(()) => "hosting".to_owned(), @@ -988,6 +1012,42 @@ fn PeerPanel(parts: SessionParts) -> Element { }, "Join the hotspot" } + input { + name: "payload", + placeholder: "WIFI: payload", + value: "{join_payload}", + oninput: move |event| join_payload.set(event.value()), + style: "font-family: monospace;", + } + button { + onclick: move |_| { + let mut outcome = payload_outcome; + let offer = match HotspotOffer::from_wifi_payload(join_payload.peek().trim()) { + Ok(offer) => offer, + Err(err) => { + outcome.set(err.to_string()); + return; + } + }; + let parts = payload_parts.clone(); + let mut missing = payload_missing; + spawn(async move { + outcome.set("joining the hotspot".to_owned()); + match parts.0.native.join_hotspot(&offer).await { + Ok(gateway) => { + outcome.set(format!("joined through the gateway {gateway}")); + } + Err(err) => { + if let JoinError::MissingPermission(name) = &err { + missing.set(Some(name.clone())); + } + outcome.set(err.to_string()); + } + } + }); + }, + "Join from the payload" + } button { onclick: move |_| { let parts = leave_parts.clone(); diff --git a/plans/master-implementation-plan.md b/plans/master-implementation-plan.md index cc383a9d..b233e06e 100644 --- a/plans/master-implementation-plan.md +++ b/plans/master-implementation-plan.md @@ -5314,6 +5314,8 @@ The local data plane R25 decided, discovery by gateway-probe first (a hotspot ho 20. **Every joiner's central is btleplug, Android's included, once btleplug's Android `init` resolves its classes through the context class loader** (2026-10-08). Measured between the two A35s inside the demo with btleplug 0.13.4: scanning saw the advertised UUID and service data within a second, and connecting, service discovery, an MTU of 517, writes with response and notifications all worked, once one call in btleplug's `init` looked up `Adapter` through `LoaderContext` instead of raw `FindClass`, which on a thread manganis attached never sees application classes. That fix is deviceplug/btleplug#495, released in btleplug 0.13.5 with #497's fix of the Java notification queue, and the workspace depends on that release. `connetto-peer-android` bundles btleplug's Java in its Gradle module with R8 keep rules for `com.nonpolynomial.**` and `io.github.gedgygedgy.**`, sets each calling thread's context class loader to the application's, and hands btleplug the virtual machine under its jni 0.22. A Kotlin GATT client in `connetto-peer-android` was rejected, since it serves Android alone and Apple, Windows and Linux joiners would need a second central, and so was waiting for a btleplug release before building the Android central. deviceplug/btleplug#480, a use-after-free in `FnAdapter` on notifications reported at about 1.1% of sessions, stays open, and the emi runs watch for it. 21. **Every backend reports one Bluetooth readiness and prompts only inside a call the user triggered** (2026-10-08). `BluetoothState::{Ready, Off, NotPermitted, Unsupported}` is read with `bluetooth_state()` and reported as `ClientEvent::BluetoothChanged`. The background scan and the host's beacon never prompt, wait while the state is not `Ready` and resume when it is, and the hotspot runs throughout. `host_hotspot`, `join_nearby`, `fetch_offer` and `enable_bluetooth()` run the platform's strongest action once inside the call, the system dialog on Android, switching the radio on through `Windows.Devices.Radios` or BlueZ's `Powered`, and Core Bluetooth's power alert or the Bluetooth settings pane on Apple, then go on or answer `BluetoothOff` or `BluetoothNotPermitted` saying whether the user declined, was sent to settings or met a block. `set_bluetooth_prompt(false)` leaves that action to the application's own `enable_bluetooth()` call, for an application that explains first. `host_hotspot` answers `Hosted { offer, beacon: Result<(), BluetoothError> }`, so the offer reaches the application for the text and QR fallback while the beacon is down, and an error carrying the offer was rejected, since a started hotspot would read as a failure. No system switches Bluetooth on silently everywhere, and every system can report the state and offer one action inside a user gesture, so this is the shape every backend shares. Prompting from the background scan was rejected, and so was answering a typed error alone, which leaves every application to write its own prompt per platform. 22. **`connetto-client` implements the btleplug central for every system, and `connetto-peer-android` only bridges Android's virtual machine to it** (2026-10-09). btleplug's Android `init` takes a jni 0.22 environment, connetto and manganis stand on jni 0.21, and jni 0.22 learns the running virtual machine only through the `unsafe` `JavaVM::from_raw`. So `connetto-peer-android` offers a safe `java_vm()` building it from the virtual machine manganis hands out, its one `unsafe` call documented under the crate's own lints as decision 17 places it, and ships btleplug's Java in its Gradle module. `connetto-client` depends on it on Android only and runs btleplug on one dedicated thread, attached, its class loader set to the application's, `init` called once, and its own runtime, and the polled `CentralBackend` reaches that thread over channels. Slice 8's other systems run the same central without the bridge. The central inside `connetto-peer-android`, handed to the builder by the application, was rejected, since it would keep Android's copy apart from every other system's and cost every application a line. The central starts when Bluetooth first reads `Ready`, never while the client is built (decided with the maintainer 2026-10-11). On iPhone and iPad btleplug's start waits for the system's Bluetooth state, which arrives only once the user answers the permission alert, so a start while the client was built passed its 10-second bound whenever the alert waited longer and left the joiner without a central until the application restarted. A longer bound tried once was rejected, since an alert answered after it still left no central. +23. **The QR code carries the standard `WIFI:` fields and the peer port, and the application draws and scans it** (2026-10-09). The payload is `WIFI:T:;S:;P:;X:;;` with the format's backslash escapes, so a phone's system camera joins the Wi-Fi and skips `X`, which parsers of the format pass over, and an application that scans the code or takes the string learns the port and dials the gateway at once (decision 11). `HotspotOffer::to_wifi_payload` writes it and `HotspotOffer::from_wifi_payload` reads it with a typed refusal, and the application renders and scans the code with its own tools. A plain `WIFI:` code was rejected, since a device joining through its application would lose the port, and so was a QR renderer inside connetto, which every application's own toolkit already has. +24. **The text form is the offer's three lines, and the payload travels as one string** (2026-10-09). A person on a machine without a camera types the network and password into the system's own settings, which ask for exactly those, and the port into the application, while a chat message or a scanner carries the payload whole into `from_wifi_payload`. A readable sentence was rejected, since its wording needs translation and belongs to the application. ### Design @@ -5475,7 +5477,7 @@ One pull request each, in this order, each green and useful on its own (2026-10- 4. **Android hosting and joining** (step 3's Android part and step 6's Wi-Fi permissions, decisions 11 and 14 to 17). `startLocalOnlyHotspot` and the joining network request through `connetto-peer-android`'s Kotlin plugin, the hotspot tables, the per-socket binding, the gateway probe, the typed refusals naming a missing permission, and the demo hosting, joining and showing its peers. Proven on emi, one A35 hosting and the other joining through its application, the runner tapping the approval (decision 16). **Built (R76, 2026-10-08)**, the two-phone run green on emi, the Android 15 A35 hosting and the Android 14 one joining, both `chip-proven`, the joiner reaching the host at the gateway. Measured on the way: an A35 refuses to start an access point while its own Wi-Fi sits on eduroam's 5 GHz channel 56, hostapd failing on its beacon on both phones, and starts it once its Wi-Fi is off, so the run turns the host's Wi-Fi off first, as a host in the field has no network. Whether mDNS works on a joined network without binding its sockets, which `mdns-sd` offers no way to do, is not measured yet, since the gateway dial links the two first. 5. **The Bluetooth beacon and exchange on Android, without the central** (step 4's Android host part and step 6's Bluetooth permissions, decisions 18, 19 and 21). `BluetoothGattServer` and the legacy advertisement as host, decision 21's readiness on Android, the beacon codec, the framed exchange with rustls over a transport trait, `HotspotOffer` only after both sides proved their identity, and both tables, the joiner's driven through a scanning and connecting backend trait. Proven by the exchange and both machines over in-memory transports, and on emi by one A35 hosting with its beacon in its own Bluetooth dump. **Built (R76, 2026-10-09)** as `connetto-peer`'s `bluetooth` feature (`beacon.rs`, `exchange.rs` with `Node::offer_over` and `Node::fetch_over` over `ChunkStream`) and `connetto-client`'s `bluetooth` module, whose machine runs beside the hotspot machine and takes the central as an option, so a system without one neither scans nor joins and `join_nearby` answers `Unsupported`, which is Android until slice 6. On emi the Android 15 A35 hosted from the demo, its panel showed `beacon: advertising` with the prefix, and `dumpsys bluetooth_manager` listed the demo's set as legacy and connectable with 9 bytes of service data under the beacon's UUID beside the started GATT service with both characteristics. Measured on the way: Android's `adb` offers no LE scan without an application, so the second phone's sighting of the beacon moves to slice 6 with the central, and the platform's dump masks the service data UUID past its first group and never shows the data. 6. **The Android central** (step 4's Android joiner part, decision 20). The backend trait over btleplug 0.13.5, with btleplug's Java in `connetto-peer-android`. Proven on emi between the two A35s, one hosting and the other joining through the beacon and exchange. **Built (R76, 2026-10-09)** as `connetto-client`'s `BtleplugCentral` over btleplug 0.13.5, with `connetto-peer-android`'s `java_vm` and `use_application_class_loader` and btleplug's Java in its Gradle module (decision 22). On emi the Android 14 A35 saw the Android 15 one's beacon on its panel, joined through `Join the nearby host`, the exchange handing over the offer, the runner tapping the approval, and both phones named each other linked. Measured on the way: the joiner's chunk is the smaller of the packet less its 3-byte header and the 512 bytes GATT allows an attribute value, since Android refuses a longer notification even at an MTU of 517, and a joiner holds no Bluetooth central where btleplug's thread does not start, so it neither scans nor joins there. -7. **The text and QR fallback** (step 5). The `WIFI:` payload and its text form, the text path proven on emi and the camera scan left to the home run. +7. **The text and QR fallback** (step 5, decisions 23 and 24). The `WIFI:` payload, its parser and the offer's three lines, the demo showing the code and the copyable payload and joining from a pasted one, proven on emi by a host with Bluetooth off whose payload the other A35 pastes and links through, the camera scan left to the home run. **Built (R76, 2026-10-09)** as `HotspotOffer::to_wifi_payload` and `from_wifi_payload` with `WifiPayloadError`, the demo drawing the payload with the `qrcode` crate beside its copyable line and joining from a pasted one. On emi the Android 15 A35 hosted with its Bluetooth off, the runner declined the system's enable dialog so the call answered the offer with its beacon off, the Android 14 one joined from the pasted payload, and both phones named each other linked. The reader requires the closing `;;`, so a payload cut short in a message is refused and never read with a shortened password. 8. **The unattended emi run** (step 7's first half). A schedule on emi running slices 4 to 7 from `main`, never from a pull request, with its evidence kept on emi. 9. **Every other machine** (step 3, 4 and 6 for Windows, Linux, macOS, iPhone and iPad, and step 7's home run). Split further when it starts. From 7bbdf4df03e9eb445d1afbe77fa3146dc3486a11 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Fri, 9 Oct 2026 15:23:50 +0200 Subject: [PATCH 2/2] Refuse --payload without a second phone --- crates/connetto-test-harness/src/bin/connetto-android-proof.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/connetto-test-harness/src/bin/connetto-android-proof.rs b/crates/connetto-test-harness/src/bin/connetto-android-proof.rs index 9a3c5b3e..207d8b85 100644 --- a/crates/connetto-test-harness/src/bin/connetto-android-proof.rs +++ b/crates/connetto-test-harness/src/bin/connetto-android-proof.rs @@ -1214,6 +1214,9 @@ fn cli_arguments() -> Result { other => bail!("unknown argument {other}"), } } + if arguments.payload && arguments.peer_serial.is_none() { + bail!("--payload needs --peer-serial"); + } Ok(arguments) }