diff --git a/app/lib/features/connect/hotspot_screen.dart b/app/lib/features/connect/hotspot_screen.dart index 8788a07..b0ed2d6 100644 --- a/app/lib/features/connect/hotspot_screen.dart +++ b/app/lib/features/connect/hotspot_screen.dart @@ -7,6 +7,7 @@ library; import 'dart:async'; +import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform; import 'package:flutter/material.dart'; import '../../app.dart'; @@ -28,6 +29,9 @@ class _HotspotScreenState extends State { late final Connection _c; bool _pinTab = false; + /// Android names the network itself; a computer's screen needs a larger code. + bool get _phone => defaultTargetPlatform == TargetPlatform.android; + @override void initState() { super.initState(); @@ -203,7 +207,7 @@ class _HotspotScreenState extends State { decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)), // A computer's screen shows it at about 1 mm a module at 230 (real test of // 29 October 2026): the camera needs to be close, where it cannot focus. - child: SizedBox.square(dimension: _c.hotspot.canJoin ? 230 : 340, child: QrView(key: const Key('my-qr'), data: _c.qrData!)), + child: SizedBox.square(dimension: _phone ? 230 : 340, child: QrView(key: const Key('my-qr'), data: _c.qrData!)), ), ), const SizedBox(height: 14), @@ -219,7 +223,7 @@ class _HotspotScreenState extends State { _Field(label: l.networkLabel, value: h.ssid ?? '', valueKey: const Key('hotspot-ssid')), _Field(label: l.passwordLabel, value: h.passphrase ?? '', valueKey: const Key('hotspot-password')), const SizedBox(height: 4), - Text(_c.hotspot.canJoin ? l.chosenByAndroid : l.chosenByApp, style: TextStyle(color: c.muted, fontSize: 12)), + Text(_phone ? l.chosenByAndroid : l.chosenByApp, style: TextStyle(color: c.muted, fontSize: 12)), ], ), ), diff --git a/app/lib/platform/ble.dart b/app/lib/platform/ble.dart index 2444a3c..98016ab 100644 --- a/app/lib/platform/ble.dart +++ b/app/lib/platform/ble.dart @@ -78,9 +78,10 @@ abstract interface class Ble { Future wifiBands(); } -/// Android: android.bluetooth.le, through the app's Kotlin (Ble.kt). -class AndroidBle implements Ble { - AndroidBle({this.channel = const MethodChannel('dev.medxfer.app/ble')}) { +/// Android (android.bluetooth.le, the app's Kotlin: Ble.kt) and Windows (Windows.Devices.Bluetooth, +/// the runner's C++/WinRT: ble.cpp), by one channel. +class ChannelBle implements Ble { + ChannelBle({this.channel = const MethodChannel('dev.medxfer.app/ble')}) { channel.setMethodCallHandler((call) async { if (call.method != 'event') return; final m = Map.from(call.arguments as Map); @@ -100,7 +101,7 @@ class AndroidBle implements Ble { final _events = StreamController.broadcast(); @override - bool get supported => Platform.isAndroid; + bool get supported => Platform.isAndroid || Platform.isWindows; @override Future enabled() async => await channel.invokeMethod('enabled') ?? false; @@ -175,4 +176,4 @@ class NoBle implements Ble { Future wifiBands() async => (create: 0, join: 0); } -Ble systemBle() => Platform.isAndroid ? AndroidBle() : const NoBle(); +Ble systemBle() => Platform.isAndroid || Platform.isWindows ? ChannelBle() : const NoBle(); diff --git a/app/lib/platform/hotspot.dart b/app/lib/platform/hotspot.dart index 5849ce6..e9c4e8d 100644 --- a/app/lib/platform/hotspot.dart +++ b/app/lib/platform/hotspot.dart @@ -3,8 +3,8 @@ /// hotspot (WP-D.4 of the J6d plan). Windows and Linux (WP-C.2 of the J6c plan): a network /// the computer creates at 2.4 GHz, its cards refusing 5 GHz on their own (probe WP-C.1); /// Windows by Wi-Fi Direct with a name and passphrase of the app's (decision C4), Linux by -/// NetworkManager. A computer does not join another's network itself: the user does, in the -/// system's settings ([Hotspot.canJoin]). +/// NetworkManager. Joining another's network: Android and Windows (WP-D.3 of the J6d plan) do it +/// themselves; on Linux the user does, in the system's settings ([Hotspot.canJoin]). library; import 'dart:io'; @@ -97,8 +97,8 @@ abstract interface class Hotspot { /// This device creates a network ([start]). bool get supported; - /// This device joins another's network itself ([join]): Android. A desktop's user joins it - /// in the system's settings. + /// This device joins another's network itself ([join]): Android, Windows. Linux's user joins + /// it in the system's settings. bool get canJoin; /// The Wi-Fi this device is on is left while the network runs, and comes back at [stop]: @@ -281,6 +281,8 @@ class NoHotspot implements Hotspot { /// Windows serves the addresses on it, through its connection sharing, and accepts the phones. /// When that sharing is taken by another card, it serves none: [DhcpServer] does, on the group's /// address only, and Windows drops each phone after 2 minutes ([HotspotInfo.dropsClients]). +/// Another device's network is joined through Windows' Wi-Fi API (wifi_join.cpp), with a profile +/// of this user's that leaving deletes; the computer then goes back to its network. class WindowsHotspot implements Hotspot { WindowsHotspot({this.channel = const MethodChannel('dev.medxfer.app/hotspot'), AddressServer? addressServer}) : _addressServer = addressServer ?? _dhcpServer; @@ -293,7 +295,7 @@ class WindowsHotspot implements Hotspot { bool get supported => true; @override - bool get canJoin => false; + bool get canJoin => true; @override bool get leavesWifi => false; @@ -339,11 +341,26 @@ class WindowsHotspot implements Hotspot { } @override - Future join(String ssid, String passphrase, {int security = 1, WifiBand? band}) => throw UnsupportedError('a computer joins in the system settings'); + Future join(String ssid, String passphrase, {int security = 1, WifiBand? band}) async { + final m = (await channel.invokeMapMethod( + 'join', {'ssid': ssid, 'passphrase': passphrase, 'security': security, if (band != null) 'band': band.code}))!; + final address = '${m['address']}'; + final prefix = (m['prefix'] as num?)?.toInt() ?? 24; + return HotspotInfo( + ssid: ssid, + passphrase: passphrase, + security: security, + address: address, + interfaces: ['${m['name']}'], + net: NetInterface(name: '${m['name']}', index: (m['index'] as num?)?.toInt() ?? 0, addrs: ['$address/$prefix']), + band: WifiBand.of((m['frequency'] as num?)?.toInt() ?? 0), + ); + } @override - Future leave() async {} + Future leave() => channel.invokeMethod('leave'); + /// The core's sockets follow Windows' routes: the card that joined is the computer's Wi-Fi. @override Future joinedNetwork() async => 0; diff --git a/app/test/platform/hotspot_test.dart b/app/test/platform/hotspot_test.dart index cf4118b..d50e407 100644 --- a/app/test/platform/hotspot_test.dart +++ b/app/test/platform/hotspot_test.dart @@ -131,6 +131,26 @@ void main() { expect(served.last, 'closed'); expect(calls, ['start', 'stop']); }); + + test('WP-D.3: joins another device\'s network itself, its band said; leaving goes back', () async { + Object? asked; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call.method); + if (call.method == 'join') asked = call.arguments; + return switch (call.method) { + 'join' => {'name': 'Wi-Fi', 'index': 12, 'address': '192.168.49.23', 'prefix': 24, 'frequency': 5745}, + _ => null, + }; + }); + final h = WindowsHotspot(addressServer: (server, prefix) async => () {}); + expect(h.canJoin, isTrue); + final info = await h.join('DIRECT-ab-Galaxy', 'K3PQ8Z2M', security: 1, band: WifiBand.ghz5); + expect(asked, {'ssid': 'DIRECT-ab-Galaxy', 'passphrase': 'K3PQ8Z2M', 'security': 1, 'band': '5'}); + expect((info.address, info.band, info.net!.index), ('192.168.49.23', WifiBand.ghz5, 12)); + expect(info.net!.addrs, ['192.168.49.23/24']); + await h.leave(); + expect(calls, ['join', 'leave']); + }); }); group('WP-D.4: Android, the Wi-Fi Direct group at 5 GHz or the local hotspot', () { diff --git a/app/windows/runner/CMakeLists.txt b/app/windows/runner/CMakeLists.txt index 7ca399c..d22ea18 100644 --- a/app/windows/runner/CMakeLists.txt +++ b/app/windows/runner/CMakeLists.txt @@ -7,11 +7,13 @@ project(runner LANGUAGES CXX) # # Any new source files that you add to the application should be added here. add_executable(${BINARY_NAME} WIN32 + "ble.cpp" "firewall.cpp" "flutter_window.cpp" "hotspot.cpp" "main.cpp" "utils.cpp" + "wifi_join.cpp" "win32_window.cpp" "wired.cpp" "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" @@ -42,6 +44,8 @@ target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") target_link_libraries(${BINARY_NAME} PRIVATE "windowsapp.lib" "iphlpapi.lib" "ws2_32.lib") # Windows' firewall and its networks (firewall.cpp): COM, BSTR and VARIANT. target_link_libraries(${BINARY_NAME} PRIVATE "ole32.lib" "oleaut32.lib") +# Joining another device's network (wifi_join.cpp): Windows' Wi-Fi API. +target_link_libraries(${BINARY_NAME} PRIVATE "wlanapi.lib") target_compile_features(${BINARY_NAME} PRIVATE cxx_std_20) target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/app/windows/runner/ble.cpp b/app/windows/runner/ble.cpp new file mode 100644 index 0000000..2cd3720 --- /dev/null +++ b/app/windows/runner/ble.cpp @@ -0,0 +1,485 @@ +#include "ble.h" + +#include +#include + +#include +#include + +#include +#include +#include + +using namespace winrt; +using namespace Windows::Devices::Bluetooth; +using namespace Windows::Devices::Bluetooth::Advertisement; +using namespace Windows::Devices::Bluetooth::GenericAttributeProfile; +using namespace Windows::Devices::Radios; +using namespace Windows::Storage::Streams; + +namespace { + +// Bible §6.6, BLE-02: medXfer's service and its one characteristic (as in Ble.kt). +const guid kService{0xc46b12c3, 0x1dee, 0x482d, {0xa4, 0x90, 0xb1, 0x85, 0x21, 0x23, 0x12, 0xfb}}; +const guid kMessage{0xc46b12c4, 0x1dee, 0x482d, {0xa4, 0x90, 0xb1, 0x85, 0x21, 0x23, 0x12, 0xfb}}; +constexpr size_t kMaxMessage = 512; // BLE-03 +constexpr auto kAnswerWithin = std::chrono::seconds(25); // BLE-11: under the 30 s of a GATT transaction +constexpr int64_t kSeenEveryMs = 5000; + +std::vector ToBytes(IBuffer const& b) { + std::vector v(b ? b.Length() : 0); + if (!v.empty()) DataReader::FromBuffer(b).ReadBytes(v); + return v; +} + +IBuffer ToBuffer(const std::vector& v, size_t from = 0) { + DataWriter w; + if (from < v.size()) w.WriteBytes(array_view(v.data() + from, v.data() + v.size())); + return w.DetachBuffer(); +} + +std::string Address(uint64_t a) { + char t[24]; + std::snprintf(t, sizeof(t), "%012llx", static_cast(a)); + return t; +} + +int64_t NowMs() { + return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count(); +} + +flutter::EncodableValue V(const std::string& s) { return flutter::EncodableValue(s); } + +Windows::Foundation::IAsyncOperation BluetoothRadio() { + auto adapter = co_await BluetoothAdapter::GetDefaultAsync(); + if (!adapter) co_return nullptr; + co_return co_await adapter.GetRadioAsync(); +} + +} // namespace + +// A read that came before the core's answer: answered by Reply, or failed after kAnswerWithin. +struct Ble::Read { + GattReadRequest request{nullptr}; + Windows::Foundation::Deferral deferral{nullptr}; + uint32_t offset = 0; + bool answered = false; +}; + +// A device connected to this one's service. +struct Ble::Served { + std::vector writing; // the message written, in pieces for a long write + bool written = false; // a message written and not yet given to the core + std::optional> reply; + std::vector> waiting; + GattSession session{nullptr}; + event_token status{}; +}; + +// This computer connected to another device's service: one exchange at a time. +struct Ble::Client { + BluetoothLEDevice device{nullptr}; + GattSession session{nullptr}; + GattCharacteristic characteristic{nullptr}; + bool busy = false; + + void Close() { + if (session) session.Close(); + if (device) device.Close(); + session = nullptr; + device = nullptr; + characteristic = nullptr; + } +}; + +Ble::Ble(Emit emit) : emit_(std::move(emit)) {} + +Ble::~Ble() { + *alive_ = false; + Stop(); +} + +fire_and_forget Ble::Enabled(std::function done) { + apartment_context ui; + bool on = false; + try { + if (Radio radio = co_await BluetoothRadio()) on = radio.State() == RadioState::On; + } catch (...) { + } + co_await ui; + done(on); +} + +fire_and_forget Ble::TurnOn(std::function done) { + apartment_context ui; + bool on = false; + try { + if (co_await Radio::RequestAccessAsync() == RadioAccessStatus::Allowed) { + if (Radio radio = co_await BluetoothRadio()) { + on = radio.State() == RadioState::On || co_await radio.SetStateAsync(RadioState::On) == RadioAccessStatus::Allowed; + } + } + } catch (...) { + } + co_await ui; + // Not allowed to: the user turns it on in Windows' settings. + if (!on) ShellExecuteW(nullptr, L"open", L"ms-settings:bluetooth", nullptr, nullptr, SW_SHOWNORMAL); + done(on); +} + +fire_and_forget Ble::Start(bool advertise, bool scan, Done done) { + ui_.emplace(); + apartment_context ui = *ui_; + auto alive = alive_; + std::optional error; + try { + auto adapter = co_await BluetoothAdapter::GetDefaultAsync(); + Radio radio = adapter ? co_await adapter.GetRadioAsync() : nullptr; + if (!adapter || !adapter.IsLowEnergySupported()) { + error = "unsupported"; + } else if (!radio || radio.State() != RadioState::On) { + error = "bluetooth_off"; + } else if (advertise && !provider_ && !adapter.IsPeripheralRoleSupported()) { + error = "no_advertiser"; + } + if (!error && advertise && !provider_) { + auto created = co_await GattServiceProvider::CreateAsync(kService); + co_await ui; + if (!*alive) co_return; + if (created.Error() != BluetoothError::Success) { + error = "no_gatt_server"; + } else { + auto provider = created.ServiceProvider(); + GattLocalCharacteristicParameters p; + p.CharacteristicProperties(GattCharacteristicProperties::Write | GattCharacteristicProperties::Read); + p.WriteProtectionLevel(GattProtectionLevel::Plain); + p.ReadProtectionLevel(GattProtectionLevel::Plain); + auto made = co_await provider.Service().CreateCharacteristicAsync(kMessage, p); + co_await ui; + if (!*alive) co_return; + if (made.Error() != BluetoothError::Success) { + error = "no_gatt_server"; + } else { + auto characteristic = made.Characteristic(); + characteristic.WriteRequested([this, alive](auto&&, GattWriteRequestedEventArgs const& args) { + if (*alive) OnWrite(args); + }); + characteristic.ReadRequested([this, alive](auto&&, GattReadRequestedEventArgs const& args) { + if (*alive) OnRead(args); + }); + provider.AdvertisementStatusChanged([this, alive](auto&&, GattServiceProviderAdvertisementStatusChangedEventArgs const& e) { + if (*alive && e.Status() == GattServiceProviderAdvertisementStatus::Aborted) { + EmitOnUi("advertise_failed", {{V("code"), flutter::EncodableValue(static_cast(e.Error()))}}); + } + }); + // Hidden (WP-D.0): not discoverable, so no computer name in the advertisement and a + // random address that changes; the UUID alone (BLE-02). + GattServiceProviderAdvertisingParameters params; + params.IsConnectable(true); + params.IsDiscoverable(false); + provider.StartAdvertising(params); + provider_ = provider; + } + } + } + if (!error && scan && !watcher_) { + BluetoothLEAdvertisementWatcher watcher; + watcher.ScanningMode(BluetoothLEScanningMode::Active); + watcher.AdvertisementFilter().Advertisement().ServiceUuids().Append(kService); + watcher.Received([this, alive](auto&&, BluetoothLEAdvertisementReceivedEventArgs const& a) { + if (*alive) OnSeen(a.BluetoothAddress(), a.RawSignalStrengthInDBm()); + }); + watcher.Stopped([this, alive](auto&&, BluetoothLEAdvertisementWatcherStoppedEventArgs const& e) { + if (*alive && e.Error() != BluetoothError::Success) { + EmitOnUi("scan_failed", {{V("code"), flutter::EncodableValue(static_cast(e.Error()))}}); + } + }); + watcher.Start(); + watcher_ = watcher; + } + } catch (hresult_error const& e) { + char t[16]; + std::snprintf(t, sizeof(t), "%08x", static_cast(e.code())); + error = std::string("error_") + t; + } + co_await ui; + if (*alive) done(error); +} + +void Ble::Stop() { + if (provider_) { + try { + provider_.StopAdvertising(); + } catch (...) { + } + provider_ = nullptr; + } + if (watcher_) { + try { + watcher_.Stop(); + } catch (...) { + } + watcher_ = nullptr; + } + std::lock_guard lock(mu_); + for (auto& [conn, s] : served_) { + for (auto& r : s->waiting) { + if (r->answered) continue; + r->answered = true; + try { + r->request.RespondWithProtocolError(GattProtocolError::UnlikelyError()); + } catch (...) { + } + r->deferral.Complete(); + } + if (s->session) s->session.SessionStatusChanged(s->status); + } + served_.clear(); + seen_at_.clear(); + for (auto& [device, c] : clients_) c->Close(); + clients_.clear(); +} + +fire_and_forget Ble::EmitOnUi(std::string event, flutter::EncodableMap args) { + if (!ui_) co_return; + auto alive = alive_; + co_await *ui_; + if (*alive) emit_(event, std::move(args)); +} + +fire_and_forget Ble::OnSeen(uint64_t address, int16_t rssi) { + { + std::lock_guard lock(mu_); + auto now = NowMs(); + auto it = seen_at_.find(address); + if (it != seen_at_.end() && now - it->second < kSeenEveryMs) co_return; + seen_at_[address] = now; + } + EmitOnUi("seen", {{V("device"), V(Address(address))}, {V("rssi"), flutter::EncodableValue(static_cast(rssi))}}); + co_return; +} + +// The state of the device connected by session; mu_ held. Its going is said ("closed"). +Ble::Served& Ble::ServedLocked(GattSession const& session) { + auto conn = to_string(session.DeviceId().Id()); + auto& s = served_[conn]; + if (!s) { + s = std::make_shared(); + s->session = session; + auto alive = alive_; + s->status = session.SessionStatusChanged([this, alive, conn](GattSession const&, GattSessionStatusChangedEventArgs const& e) { + if (!*alive || e.Status() != GattSessionStatus::Closed) return; + { + std::lock_guard lock(mu_); + auto it = served_.find(conn); + if (it == served_.end()) return; + for (auto& r : it->second->waiting) { + if (r->answered) continue; + r->answered = true; + r->deferral.Complete(); + } + served_.erase(it); + } + EmitOnUi("closed", {{V("conn"), V(conn)}}); + }); + } + return *s; +} + +// A write from a device connected to this one: kept, in pieces for a long write, until it reads +// the answer, which marks the end of the message. +fire_and_forget Ble::OnWrite(GattWriteRequestedEventArgs args) { + auto deferral = args.GetDeferral(); + auto alive = alive_; + auto request = co_await args.GetRequestAsync(); + if (!*alive || !request) { + deferral.Complete(); + co_return; + } + auto value = ToBytes(request.Value()); + bool ok = true; + { + std::lock_guard lock(mu_); + auto& s = ServedLocked(args.Session()); + if (request.Offset() == 0) { + s.writing.clear(); + s.reply.reset(); + } + if (request.Offset() != s.writing.size() || s.writing.size() + value.size() > kMaxMessage) { + ok = false; + s.writing.clear(); + } else { + s.writing.insert(s.writing.end(), value.begin(), value.end()); + s.written = true; + } + } + try { + if (!ok) { + request.RespondWithProtocolError(GattProtocolError::InvalidOffset()); + } else if (request.Option() == GattWriteOption::WriteWithResponse) { + request.Respond(); + } + } catch (...) { + } + deferral.Complete(); +} + +// A read: the core's answer when it is there; else the message written goes to the core, and the +// read waits for its answer (Reply), up to kAnswerWithin. +fire_and_forget Ble::OnRead(GattReadRequestedEventArgs args) { + auto deferral = args.GetDeferral(); + auto alive = alive_; + auto request = co_await args.GetRequestAsync(); + if (!*alive || !request) { + deferral.Complete(); + co_return; + } + auto conn = to_string(args.Session().DeviceId().Id()); + auto read = std::make_shared(); + read->request = request; + read->deferral = deferral; + read->offset = request.Offset(); + std::optional> reply; + std::optional> message; + { + std::lock_guard lock(mu_); + auto& s = ServedLocked(args.Session()); + if (s.reply) { + reply = s.reply; + } else { + s.waiting.push_back(read); + if (s.written) { + message = s.writing; + s.written = false; + } + } + } + if (reply) { + try { + request.RespondWithValue(ToBuffer(*reply, read->offset)); + } catch (...) { + } + deferral.Complete(); + co_return; + } + if (message) EmitOnUi("message", {{V("conn"), V(conn)}, {V("data"), flutter::EncodableValue(*message)}}); + co_await resume_after(kAnswerWithin); + if (!*alive) co_return; + std::lock_guard lock(mu_); + if (read->answered) co_return; + read->answered = true; + try { + request.RespondWithProtocolError(GattProtocolError::UnlikelyError()); + } catch (...) { + } + deferral.Complete(); +} + +void Ble::Reply(const std::string& conn, std::vector data) { + std::lock_guard lock(mu_); + auto it = served_.find(conn); + if (it == served_.end()) return; + auto& s = *it->second; + s.reply = data; + for (auto& r : s.waiting) { + if (r->answered) continue; + r->answered = true; + try { + r->request.RespondWithValue(ToBuffer(data, r->offset)); + } catch (...) { + } + r->deferral.Complete(); + } + s.waiting.clear(); +} + +fire_and_forget Ble::Exchange(std::string device, std::vector message, Answer done) { + apartment_context ui; + auto alive = alive_; + std::shared_ptr c; + { + std::lock_guard lock(mu_); + auto& slot = clients_[device]; + if (!slot) slot = std::make_shared(); + c = slot; + if (c->busy) { + done("busy", {}); + co_return; + } + c->busy = true; + } + std::optional error; + std::vector answer; + co_await resume_background(); + const char* step = "device"; // where an exception came from + try { + if (!c->characteristic) { + c->device = co_await BluetoothLEDevice::FromBluetoothAddressAsync(std::stoull(device, nullptr, 16)); + if (!c->device) { + error = "not_found"; + } else { + step = "session"; + c->session = co_await GattSession::FromDeviceIdAsync(c->device.BluetoothDeviceId()); + c->session.MaintainConnection(true); + // A full discovery first: asking for one service straight away failed against the + // Galaxy A57 (WP-D.0). A phone paired in Bluetooth with this computer fails here, with + // E_UNEXPECTED or "unreachable", where the same phone unpaired answers (real test of + // 3 November 2026). + step = "services"; + auto all = co_await c->device.GetGattServicesAsync(BluetoothCacheMode::Uncached); + auto services = co_await c->device.GetGattServicesForUuidAsync(kService, BluetoothCacheMode::Uncached); + if (services.Status() != GattCommunicationStatus::Success || services.Services().Size() == 0) { + error = "no_service:" + std::to_string(static_cast(all.Status())) + "/" + + std::to_string(all.Status() == GattCommunicationStatus::Success ? all.Services().Size() : 0) + "/" + + std::to_string(static_cast(services.Status())); + } else { + step = "characteristic"; + auto found = co_await services.Services().GetAt(0).GetCharacteristicsForUuidAsync(kMessage, BluetoothCacheMode::Uncached); + if (found.Status() != GattCommunicationStatus::Success || found.Characteristics().Size() == 0) { + error = "no_characteristic:" + std::to_string(static_cast(found.Status())); + } else { + c->characteristic = found.Characteristics().GetAt(0); + } + } + } + } + // Longer than the MTU, Windows writes and reads in pieces (long write, long read). + if (!error) { + step = "write"; + auto written = co_await c->characteristic.WriteValueWithResultAsync(ToBuffer(message), GattWriteOption::WriteWithResponse); + if (written.Status() != GattCommunicationStatus::Success) { + error = "write_status:" + std::to_string(static_cast(written.Status())); + } else { + step = "read"; + auto read = co_await c->characteristic.ReadValueAsync(BluetoothCacheMode::Uncached); + if (read.Status() != GattCommunicationStatus::Success) { + error = "read_status:" + std::to_string(static_cast(read.Status())); + } else { + answer = ToBytes(read.Value()); + } + } + } + } catch (hresult_error const& e) { + char t[16]; + std::snprintf(t, sizeof(t), "%08x", static_cast(e.code())); + error = std::string(step) + "_" + t; + } catch (...) { + error = std::string(step) + "_error"; + } + if (error) c->Close(); // the next exchange connects again + c->busy = false; + co_await ui; + if (*alive) done(error, std::move(answer)); +} + +void Ble::Disconnect(const std::string& device) { + std::shared_ptr c; + { + std::lock_guard lock(mu_); + auto it = clients_.find(device); + if (it == clients_.end()) return; + c = it->second; + clients_.erase(it); + } + c->Close(); +} diff --git a/app/windows/runner/ble.h b/app/windows/runner/ble.h new file mode 100644 index 0000000..c9877a9 --- /dev/null +++ b/app/windows/runner/ble.h @@ -0,0 +1,78 @@ +#ifndef RUNNER_BLE_H_ +#define RUNNER_BLE_H_ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +// The path by BLE (bible §6.6, WP-D.3 of the J6d plan), Windows' side: the same channel as +// Android's Ble.kt (lib/platform/ble.dart). This computer advertises medXfer's service, its UUID +// alone and hidden (no computer name, a random address: WP-D.0); answers the devices that connect +// to it (GATT server); finds the others (a watcher filtered on the service); and carries a message +// to one of them (GATT client: a write, then a read). The core makes and reads every message: +// this class only carries bytes. System APIs only (D5). +// +// Its methods are called on the window's thread, and its callbacks come back on it. +class Ble { + public: + // An event for Dart (seen, message, closed, advertise_failed, scan_failed), on the window's thread. + using Emit = std::function; + using Done = std::function error)>; + using Answer = std::function error, std::vector answer)>; + + explicit Ble(Emit emit); + ~Ble(); + + // Whether the Bluetooth radio is on; asking Windows to turn it on, which it lets an app do + // only if the user allows apps to control the radios (otherwise its settings open). + winrt::fire_and_forget Enabled(std::function done); + winrt::fire_and_forget TurnOn(std::function done); + + // Advertises and answers ([advertise]), scans ([scan]); done gets an error code, or nothing. + winrt::fire_and_forget Start(bool advertise, bool scan, Done done); + void Stop(); + + // The answer [conn] reads next; a read already waiting gets it now. + void Reply(const std::string& conn, std::vector data); + + // Writes [message] to [device]'s service and reads its answer; connects first when needed. + winrt::fire_and_forget Exchange(std::string device, std::vector message, Answer done); + void Disconnect(const std::string& device); + + private: + struct Read; // a read waiting for the core's answer + struct Served; // a device connected to this one + struct Client; // this one connected to a device + + winrt::fire_and_forget OnWrite(winrt::Windows::Devices::Bluetooth::GenericAttributeProfile::GattWriteRequestedEventArgs args); + winrt::fire_and_forget OnRead(winrt::Windows::Devices::Bluetooth::GenericAttributeProfile::GattReadRequestedEventArgs args); + winrt::fire_and_forget OnSeen(uint64_t address, int16_t rssi); + winrt::fire_and_forget EmitOnUi(std::string event, flutter::EncodableMap args); + Served& ServedLocked(const winrt::Windows::Devices::Bluetooth::GenericAttributeProfile::GattSession& session); + + Emit emit_; + std::optional ui_; + std::shared_ptr alive_ = std::make_shared(true); // false once destroyed: late callbacks stop + + winrt::Windows::Devices::Bluetooth::GenericAttributeProfile::GattServiceProvider provider_{nullptr}; + winrt::Windows::Devices::Bluetooth::Advertisement::BluetoothLEAdvertisementWatcher watcher_{nullptr}; + + std::mutex mu_; // the maps below: the server's events come on Windows' threads + std::map> served_; + std::map seen_at_; // ms of the last "seen" of each address + std::map> clients_; +}; + +#endif // RUNNER_BLE_H_ diff --git a/app/windows/runner/flutter_window.cpp b/app/windows/runner/flutter_window.cpp index e68ff5d..4a8feb9 100644 --- a/app/windows/runner/flutter_window.cpp +++ b/app/windows/runner/flutter_window.cpp @@ -62,6 +62,43 @@ bool FlutterWindow::OnCreate() { } else if (call.method_name() == "stop") { hotspot_.Stop(); result->Success(); + } else if (call.method_name() == "join") { + const auto* args = std::get_if(call.arguments()); + auto text = [args](const char* key) -> std::string { + if (!args) return ""; + auto it = args->find(flutter::EncodableValue(key)); + if (it == args->end()) return ""; + const auto* s = std::get_if(&it->second); + return s ? *s : ""; + }; + int security = 1; + if (args) { + auto it = args->find(flutter::EncodableValue("security")); + if (it != args->end()) { + if (const auto* n = std::get_if(&it->second)) security = *n; + } + } + auto band = text("band"); + std::string ssid = text("ssid"); + std::shared_ptr> reply = std::move(result); + wifi_join_.Join(ssid, text("passphrase"), security, band.empty() ? 0 : band[0], + [reply, ssid](std::optional error, JoinedNetwork joined) { + if (error) { + reply->Error(*error, *error); + return; + } + reply->Success(flutter::EncodableValue(flutter::EncodableMap{ + {flutter::EncodableValue("ssid"), flutter::EncodableValue(ssid)}, + {flutter::EncodableValue("name"), flutter::EncodableValue(joined.name)}, + {flutter::EncodableValue("index"), flutter::EncodableValue(static_cast(joined.index))}, + {flutter::EncodableValue("address"), flutter::EncodableValue(joined.address)}, + {flutter::EncodableValue("prefix"), flutter::EncodableValue(static_cast(joined.prefix))}, + {flutter::EncodableValue("frequency"), flutter::EncodableValue(static_cast(joined.frequency))}, + })); + }); + } else if (call.method_name() == "leave") { + wifi_join_.Leave(); + result->Success(); } else if (call.method_name() == "alive") { result->Success(flutter::EncodableValue(hotspot_.Alive())); } else if (call.method_name() == "wifiOn" || call.method_name() == "turnWifiOn") { @@ -116,6 +153,83 @@ bool FlutterWindow::OnCreate() { result->NotImplemented(); } }); + // Bluetooth Low Energy (lib/platform/ble.dart, ChannelBle): the same channel as Android's Ble.kt. + ble_channel_ = std::make_unique>( + flutter_controller_->engine()->messenger(), "dev.medxfer.app/ble", + &flutter::StandardMethodCodec::GetInstance()); + ble_ = std::make_unique([this](const std::string& event, flutter::EncodableMap args) { + if (!ble_channel_) return; + args[flutter::EncodableValue("event")] = flutter::EncodableValue(event); + ble_channel_->InvokeMethod("event", std::make_unique(std::move(args))); + }); + ble_channel_->SetMethodCallHandler([this](const auto& call, auto result) { + const auto* args = std::get_if(call.arguments()); + auto arg = [args](const char* key) -> const flutter::EncodableValue* { + if (!args) return nullptr; + auto it = args->find(flutter::EncodableValue(key)); + return it == args->end() ? nullptr : &it->second; + }; + auto text = [&arg](const char* key) -> std::string { + const auto* v = arg(key); + const auto* s = v ? std::get_if(v) : nullptr; + return s ? *s : ""; + }; + auto bytes = [&arg](const char* key) -> std::vector { + const auto* v = arg(key); + const auto* b = v ? std::get_if>(v) : nullptr; + return b ? *b : std::vector{}; + }; + auto flag = [&arg](const char* key) { + const auto* v = arg(key); + const auto* b = v ? std::get_if(v) : nullptr; + return b ? *b : true; + }; + const auto& m = call.method_name(); + std::shared_ptr> reply = std::move(result); + if (m == "enabled" || m == "turnOn") { + auto answer = [reply](bool on) { reply->Success(flutter::EncodableValue(on)); }; + if (m == "enabled") { + ble_->Enabled(answer); + } else { + ble_->TurnOn(answer); + } + } else if (m == "permission") { + reply->Success(flutter::EncodableValue(true)); // a desktop program asks none + } else if (m == "start") { + ble_->Start(flag("advertise"), flag("scan"), [reply](std::optional error) { + if (error) { + reply->Error(*error, *error); + } else { + reply->Success(); + } + }); + } else if (m == "stop") { + ble_->Stop(); + reply->Success(); + } else if (m == "reply") { + ble_->Reply(text("conn"), bytes("data")); + reply->Success(); + } else if (m == "exchange") { + ble_->Exchange(text("device"), bytes("data"), [reply](std::optional error, std::vector answer) { + if (error) { + reply->Error("exchange", *error); + } else { + reply->Success(flutter::EncodableValue(std::move(answer))); + } + }); + } else if (m == "disconnect") { + ble_->Disconnect(text("device")); + reply->Success(); + } else if (m == "bands") { + auto b = OwnWifiBands(); + reply->Success(flutter::EncodableValue(flutter::EncodableMap{ + {flutter::EncodableValue("create"), flutter::EncodableValue(b.create)}, + {flutter::EncodableValue("join"), flutter::EncodableValue(b.join)}, + })); + } else { + reply->NotImplemented(); + } + }); SetChildContent(flutter_controller_->view()->GetNativeWindow()); flutter_controller_->engine()->SetNextFrameCallback([&]() { @@ -132,6 +246,9 @@ bool FlutterWindow::OnCreate() { void FlutterWindow::OnDestroy() { hotspot_.Stop(); + wifi_join_.Leave(); + ble_ = nullptr; + ble_channel_ = nullptr; hotspot_channel_ = nullptr; firewall_channel_ = nullptr; network_channel_ = nullptr; diff --git a/app/windows/runner/flutter_window.h b/app/windows/runner/flutter_window.h index c5aa134..d8a71ab 100644 --- a/app/windows/runner/flutter_window.h +++ b/app/windows/runner/flutter_window.h @@ -8,9 +8,11 @@ #include +#include "ble.h" #include "firewall.h" #include "hotspot.h" #include "win32_window.h" +#include "wifi_join.h" #include "wired.h" // A window that does nothing but host a Flutter view. @@ -36,6 +38,8 @@ class FlutterWindow : public Win32Window { // The network this computer creates (lib/platform/hotspot.dart, WindowsHotspot). WifiDirectHotspot hotspot_; + // Another device's network, joined by this computer (WindowsHotspot.join). + WifiJoin wifi_join_; std::unique_ptr> hotspot_channel_; // Windows' firewall (lib/platform/firewall.dart, WindowsFirewall). @@ -43,6 +47,10 @@ class FlutterWindow : public Win32Window { // The wired cards' addresses set by hand (lib/platform/cable.dart, WindowsCableLink). std::unique_ptr> network_channel_; + + // Bluetooth Low Energy, for the path by BLE (lib/platform/ble.dart, ChannelBle). + std::unique_ptr ble_; + std::unique_ptr> ble_channel_; }; #endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/app/windows/runner/wifi_join.cpp b/app/windows/runner/wifi_join.cpp new file mode 100644 index 0000000..6bb06d9 --- /dev/null +++ b/app/windows/runner/wifi_join.cpp @@ -0,0 +1,283 @@ +#include "wifi_join.h" + +// winsock2 before windows.h: iphlpapi needs it. +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +// An open handle to Windows' Wi-Fi service, closed at the end of its scope. +class Wlan { + public: + Wlan() { + DWORD version = 0; + if (WlanOpenHandle(2, nullptr, &version, &h_) != ERROR_SUCCESS) h_ = nullptr; + } + ~Wlan() { + if (h_) WlanCloseHandle(h_, nullptr); + } + HANDLE get() const { return h_; } + + // The computer's Wi-Fi card: the one connected, else the first. Wi-Fi Direct's virtual + // interfaces are not listed. + std::optional Card() const { + if (!h_) return std::nullopt; + WLAN_INTERFACE_INFO_LIST* list = nullptr; + if (WlanEnumInterfaces(h_, nullptr, &list) != ERROR_SUCCESS || !list) return std::nullopt; + std::optional card; + for (DWORD i = 0; i < list->dwNumberOfItems; i++) { + if (!card || list->InterfaceInfo[i].isState == wlan_interface_state_connected) card = list->InterfaceInfo[i].InterfaceGuid; + if (list->InterfaceInfo[i].isState == wlan_interface_state_connected) break; + } + WlanFreeMemory(list); + return card; + } + + bool RadioOn(const GUID& card) const { + DWORD size = 0; + WLAN_RADIO_STATE* state = nullptr; + if (WlanQueryInterface(h_, &card, wlan_intf_opcode_radio_state, nullptr, &size, reinterpret_cast(&state), nullptr) != ERROR_SUCCESS) { + return true; // not said: Windows refuses the connection if it is off + } + bool on = false; + for (DWORD i = 0; i < state->dwNumberOfPhys; i++) { + on |= state->PhyRadioState[i].dot11SoftwareRadioState == dot11_radio_state_on && + state->PhyRadioState[i].dot11HardwareRadioState == dot11_radio_state_on; + } + WlanFreeMemory(state); + return on; + } + + // Whether the card joins 5 GHz: 802.11a, ac or ax among its kinds. + bool Has5GHz(const GUID& card) const { + WLAN_INTERFACE_CAPABILITY* cap = nullptr; + if (WlanGetInterfaceCapability(h_, &card, nullptr, &cap) != ERROR_SUCCESS || !cap) return false; + bool five = false; + for (DWORD i = 0; i < cap->dwNumberOfSupportedPhys && i < WLAN_MAX_PHY_INDEX; i++) { + auto t = cap->dot11PhyTypes[i]; + five |= t == dot11_phy_type_ofdm || t == dot11_phy_type_vht || t == dot11_phy_type_he; + } + WlanFreeMemory(cap); + return five; + } + + // The profile of the network the card is connected to; empty for none. + std::wstring Connected(const GUID& card) const { + DWORD size = 0; + WLAN_CONNECTION_ATTRIBUTES* c = nullptr; + if (WlanQueryInterface(h_, &card, wlan_intf_opcode_current_connection, nullptr, &size, reinterpret_cast(&c), nullptr) != ERROR_SUCCESS) { + return L""; + } + std::wstring profile = c->isState == wlan_interface_state_connected ? c->strProfileName : L""; + WlanFreeMemory(c); + return profile; + } + + uint32_t Frequency(const GUID& card) const { + DWORD size = 0; + ULONG* channel = nullptr; + if (WlanQueryInterface(h_, &card, wlan_intf_opcode_channel_number, nullptr, &size, reinterpret_cast(&channel), nullptr) != ERROR_SUCCESS) { + return 0; + } + ULONG ch = *channel; + WlanFreeMemory(channel); + if (ch == 14) return 2484; + return ch < 14 ? 2407 + 5 * ch : 5000 + 5 * ch; + } + + bool Connect(const GUID& card, const std::wstring& profile) const { + WLAN_CONNECTION_PARAMETERS p{}; + p.wlanConnectionMode = wlan_connection_mode_profile; + p.strProfile = profile.c_str(); + p.dot11BssType = dot11_BSS_type_infrastructure; + return WlanConnect(h_, &card, &p, nullptr) == ERROR_SUCCESS; + } + + private: + HANDLE h_ = nullptr; +}; + +std::wstring Wide(const std::string& s) { + if (s.empty()) return L""; + int n = MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast(s.size()), nullptr, 0); + std::wstring w(n, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast(s.size()), w.data(), n); + return w; +} + +std::string Narrow(const wchar_t* w) { + int n = WideCharToMultiByte(CP_UTF8, 0, w, -1, nullptr, 0, nullptr, nullptr); + if (n <= 1) return ""; + std::string s(n - 1, '\0'); + WideCharToMultiByte(CP_UTF8, 0, w, -1, s.data(), n, nullptr, nullptr); + return s; +} + +std::string Xml(const std::string& s) { + std::string out; + for (char c : s) { + switch (c) { + case '&': out += "&"; break; + case '<': out += "<"; break; + case '>': out += ">"; break; + case '"': out += """; break; + case '\'': out += "'"; break; + default: out += c; + } + } + return out; +} + +// A profile of this user's for [ssid]: joined only when the app asks (manual), its passphrase +// in clear for Windows to protect when it stores it. +std::string Profile(const std::string& name, const std::string& ssid, const std::string& passphrase, int security) { + std::string hex; + for (unsigned char c : ssid) { + char t[3]; + std::snprintf(t, sizeof(t), "%02X", c); + hex += t; + } + // WPA3 transition networks take WPA2 too. + const char* auth = security == 3 ? "WPA3SAE" : "WPA2PSK"; + return "" + "" + "" + Xml(name) + "" + "" + hex + "" + Xml(ssid) + "" + "ESSmanual" + "" + auth + "AES" + "falsepassPhrasefalse" + "" + Xml(passphrase) + "" + ""; +} + +// The card's IPv4 address, once the network's address server gave it one. +std::optional AddressOf(const GUID& card) { + NET_LUID luid{}; + if (ConvertInterfaceGuidToLuid(&card, &luid) != NO_ERROR) return std::nullopt; + NET_IFINDEX index = 0; + if (ConvertInterfaceLuidToIndex(&luid, &index) != NO_ERROR) return std::nullopt; + ULONG size = 16 * 1024; + std::vector buffer(size); + auto* list = reinterpret_cast(buffer.data()); + ULONG flags = GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER; + ULONG rc = GetAdaptersAddresses(AF_INET, flags, nullptr, list, &size); + if (rc == ERROR_BUFFER_OVERFLOW) { + buffer.resize(size); + list = reinterpret_cast(buffer.data()); + rc = GetAdaptersAddresses(AF_INET, flags, nullptr, list, &size); + } + if (rc != NO_ERROR) return std::nullopt; + for (auto* a = list; a; a = a->Next) { + if (a->IfIndex != index) continue; + for (auto* u = a->FirstUnicastAddress; u; u = u->Next) { + char text[INET_ADDRSTRLEN] = {}; + inet_ntop(AF_INET, &reinterpret_cast(u->Address.lpSockaddr)->sin_addr, text, sizeof(text)); + std::string address(text); + if (address.rfind("169.254.", 0) == 0) continue; // none served yet + JoinedNetwork j; + j.name = Narrow(a->FriendlyName); + j.index = index; + j.address = address; + j.prefix = u->OnLinkPrefixLength; + return j; + } + } + return std::nullopt; +} + +} // namespace + +WifiBandMask OwnWifiBands() { + Wlan wlan; + auto card = wlan.Card(); + if (!card) return {}; + WifiBandMask m; + m.create = 1; + if (wlan.RadioOn(*card)) m.join = 1 | (wlan.Has5GHz(*card) ? 2 : 0); + return m; +} + +WifiJoin::~WifiJoin() { Leave(); } + +winrt::fire_and_forget WifiJoin::Join(std::string ssid, std::string passphrase, int security, char band, + std::function error, JoinedNetwork joined)> done) { + winrt::apartment_context ui; + Leave(); + std::wstring profile = Wide("medXfer " + ssid); + std::wstring previous; + GUID card{}; + std::optional error; + JoinedNetwork joined; + co_await winrt::resume_background(); + { + Wlan wlan; + auto found = wlan.Card(); + if (!wlan.get() || !found) { + error = "no_wifi"; + } else if (card = *found; !wlan.RadioOn(card)) { + error = "wifi_off"; + } else if (band == '5' && !wlan.Has5GHz(card)) { + error = "no_band"; + } else { + previous = wlan.Connected(card); + DWORD reason = 0; + auto xml = Wide(Profile(Narrow(profile.c_str()), ssid, passphrase, security)); + if (WlanSetProfile(wlan.get(), &card, WLAN_PROFILE_USER, xml.c_str(), nullptr, TRUE, nullptr, &reason) != ERROR_SUCCESS || + !wlan.Connect(card, profile)) { + error = "join_failed"; + } else { + // Connected within 20 s, then an address within 15 more. + auto until = std::chrono::steady_clock::now() + std::chrono::seconds(20); + while (wlan.Connected(card) != profile && std::chrono::steady_clock::now() < until) { + std::this_thread::sleep_for(std::chrono::milliseconds(250)); + } + if (wlan.Connected(card) != profile) { + error = "join_failed"; + } else { + until = std::chrono::steady_clock::now() + std::chrono::seconds(15); + std::optional got; + while (!(got = AddressOf(card)) && std::chrono::steady_clock::now() < until) { + std::this_thread::sleep_for(std::chrono::milliseconds(250)); + } + if (!got) { + error = "no_address"; + } else { + joined = *got; + joined.frequency = wlan.Frequency(card); + } + } + } + if (error) { + WlanDeleteProfile(wlan.get(), &card, profile.c_str(), nullptr); + if (!previous.empty()) wlan.Connect(card, previous); + } + } + } + co_await ui; + if (!error) { + profile_ = profile; + previous_ = previous; + card_ = card; + } + done(error, joined); +} + +void WifiJoin::Leave() { + if (profile_.empty()) return; + Wlan wlan; + if (wlan.get()) { + WlanDisconnect(wlan.get(), &card_, nullptr); + WlanDeleteProfile(wlan.get(), &card_, profile_.c_str(), nullptr); + if (!previous_.empty() && previous_ != profile_) wlan.Connect(card_, previous_); + } + profile_.clear(); + previous_.clear(); +} diff --git a/app/windows/runner/wifi_join.h b/app/windows/runner/wifi_join.h new file mode 100644 index 0000000..cdea1e3 --- /dev/null +++ b/app/windows/runner/wifi_join.h @@ -0,0 +1,50 @@ +#ifndef RUNNER_WIFI_JOIN_H_ +#define RUNNER_WIFI_JOIN_H_ + +#include +#include +#include +#include +#include + +#include + +// The network another device created, joined by this computer itself (WP-D.3 of the J6d plan), +// as Android's WifiJoin.kt does: through Windows' Wi-Fi API (wlanapi), with a profile of this +// user's, no administrator rights. The profile is the app's for the time of the session: leaving +// deletes it, and the computer goes back to the network it was on. +struct JoinedNetwork { + std::string name; // the interface's + uint32_t index = 0; + std::string address; + uint8_t prefix = 0; + uint32_t frequency = 0; // MHz +}; + +// The bands this computer creates a network on and joins one on (bit 0: 2.4 GHz, bit 1: 5 GHz), +// as the core's ble_server and ble_client take them. Its own network is at 2.4 GHz (probe WP-C.1); +// none is joined with Wi-Fi off. +struct WifiBandMask { + int create = 0; + int join = 0; +}; +WifiBandMask OwnWifiBands(); + +class WifiJoin { + public: + ~WifiJoin(); + + // Joins [ssid]; [security] as the QR code gives it (1: WPA2, 2: WPA3 transition, 3: WPA3); + // [band] '5' when the network is at 5 GHz, '2' at 2.4, 0 unknown. Errors: wifi_off, no_wifi, + // no_band, join_failed, no_address. done is called on the calling thread, the window's. + winrt::fire_and_forget Join(std::string ssid, std::string passphrase, int security, char band, + std::function error, JoinedNetwork joined)> done); + void Leave(); + + private: + std::wstring profile_; // the app's profile, while joined + std::wstring previous_; // the profile of the network the computer was on + GUID card_{}; +}; + +#endif // RUNNER_WIFI_JOIN_H_ diff --git a/docs/medxfer-bible-technique-v8.md b/docs/medxfer-bible-technique-v8.md index 3949925..bfda92f 100644 --- a/docs/medxfer-bible-technique-v8.md +++ b/docs/medxfer-bible-technique-v8.md @@ -223,7 +223,7 @@ Aujourd'hui (`session/tls.go`) le certificat est régénéré à chaque démarra - Windows : un groupe Wi-Fi Direct en réglages *legacy* (`WiFiDirectAdvertisementPublisher`, module natif de l'application), nom et mot de passe tirés par l'application. Windows y distribue les adresses par son partage de connexion (ICS, `192.168.137.1`), et l'application accepte chaque téléphone qui le rejoint (`WiFiDirectConnectionListener`) : sans cela, Windows le coupe au bout de 2 minutes. Quand le partage de connexion est pris par une autre carte, ou que celle-ci en garde l'adresse, Windows n'en distribue aucune : l'application le fait alors, par un petit serveur DHCP lié à la seule adresse du groupe, et dit que les téléphones seront coupés toutes les 2 minutes, avec la carte en cause (plan J6c, WP-C.5) ; - Linux : NetworkManager (`nmcli`, arguments séparés, sans shell). -Le PC crée son réseau à 2,4 GHz : ses cartes n'émettent pas d'elles-mêmes à 5 GHz (sonde J6c). L'appareil qui rejoint par le QR (Android) donne au cœur le réseau rejoint, auquel ses sockets sont liées (`pkg/netbind`), et le rejoint seul s'il le perd. Une session perdue ne fait plus arrêter le réseau à celui qui l'a créé : il le garde 10 minutes (`rejoinFor`), le temps que l'autre revienne ; une déconnexion voulue l'arrête aussitôt (essai réel du 2 novembre 2026). Une plateforme = un adaptateur ; aucun script ni commande du système construit à partir de texte. | ✅ côté cœur (A1, #156) : `pkg/hotspot`, `xfer send --create-network` et les actions `hotspot_*` sont retirés, schéma v3. ✅ côté plateformes : Android (J6b), Windows et Linux (J6c) ; groupe à 5 GHz d'Android (J6d, WP-D.4) | +Le PC crée son réseau à 2,4 GHz : ses cartes n'émettent pas d'elles-mêmes à 5 GHz (sonde J6c). Rejoindre le réseau d'un autre : Android, et Windows par son API Wi-Fi (`wlanapi`, profil de l'utilisateur supprimé au départ, retour au réseau d'avant ; plan J6d, WP-D.3) ; sous Linux, l'utilisateur le rejoint dans les réglages. L'appareil Android qui rejoint par le QR donne au cœur le réseau rejoint, auquel ses sockets sont liées (`pkg/netbind`), et le rejoint seul s'il le perd. Une session perdue ne fait plus arrêter le réseau à celui qui l'a créé : il le garde 10 minutes (`rejoinFor`), le temps que l'autre revienne ; une déconnexion voulue l'arrête aussitôt (essai réel du 2 novembre 2026). Une plateforme = un adaptateur ; aucun script ni commande du système construit à partir de texte. | ✅ côté cœur (A1, #156) : `pkg/hotspot`, `xfer send --create-network` et les actions `hotspot_*` sont retirés, schéma v3. ✅ côté plateformes : Android (J6b), Windows et Linux (J6c) ; groupe à 5 GHz d'Android (J6d, WP-D.4) | | `DISC-13` | **Plusieurs chemins, le câble.** Un appareil vu sur plusieurs réseaux de cet appareil (un câble et un Wi-Fi) garde un chemin par réseau, avec la nature de l'interface locale : `ethernet`, `wifi` ou `other`, d'après le type de la carte sous Windows, `/sys/class/net` sous Linux, sinon le nom. L'ordre est stable, le câble d'abord ; `host_ip` est l'adresse du premier. `peers_list` porte `paths`, et l'interface laisse l'utilisateur choisir (décision E2 du plan du câble). L'énumération écarte les interfaces sans lien et les ponts virtuels (`lxcbr0`, `br-…`, `docker0`…). L'écran du PIN montre une adresse par réseau (`pair_pin_shown` porte `addresses`), les adresses `169.254` comprises : sur un câble direct, ce sont les seules. | ✅ côté cœur (WP-E.1) : `pkg/discovery` (`Path`, `LocalPaths`, `usable`), `pair_pin_shown.addresses` ; l'écran en WP-E.3 | --- @@ -461,8 +461,8 @@ Seul circule un réseau créé par medXfer pour l'occasion, au mot de passe tir | Id | Exigence | Statut | |---|---|---| -| `BLE-01` | **Rôle.** BLE ne sert qu'à mettre deux appareils sur un même réseau. Il ne porte ni fichier, ni alias, ni nom (`DISC-06`) ; la session s'ouvre sur le Wi-Fi, en TLS (§7). Le cœur produit et lit les messages ; la plateforme les porte. Sans BLE : le QR et le PIN. | ✅ cœur et API (WP-D.2 : `pkg/pairing/ble.go`, actions `ble_server`, `ble_client`, `pair_ble`) ; plateformes : WP-D.3 | -| `BLE-02` | **Annonce.** L'annonce ne porte que l'UUID de service `c46b12c3-1dee-482d-a490-b185212312fb` : ni nom (Windows : `IsDiscoverable` faux), ni donnée de service, ni identifiant stable. L'adresse est l'adresse aléatoire du système. La caractéristique « message » est `c46b12c4-1dee-482d-a490-b185212312fb`. Annonce et balayage, filtré sur l'UUID, suivent `DISC-05` : seulement quand l'utilisateur cherche ou attend un appareil, l'application ouverte (plan J6d, D3). | ✅ Android (WP-D.3, `Ble.kt`) : UUID seul, recherche filtrée sur le service, en pause pendant une session ; Windows : à venir | +| `BLE-01` | **Rôle.** BLE ne sert qu'à mettre deux appareils sur un même réseau. Il ne porte ni fichier, ni alias, ni nom (`DISC-06`) ; la session s'ouvre sur le Wi-Fi, en TLS (§7). Le cœur produit et lit les messages ; la plateforme les porte. Sans BLE : le QR et le PIN. | ✅ cœur et API (WP-D.2 : `pkg/pairing/ble.go`, actions `ble_server`, `ble_client`, `pair_ble`) ; plateformes : Android et Windows (WP-D.3) | +| `BLE-02` | **Annonce.** L'annonce ne porte que l'UUID de service `c46b12c3-1dee-482d-a490-b185212312fb` : ni nom (Windows : `IsDiscoverable` faux), ni donnée de service, ni identifiant stable. L'adresse est l'adresse aléatoire du système. La caractéristique « message » est `c46b12c4-1dee-482d-a490-b185212312fb`. Annonce et balayage, filtré sur l'UUID, suivent `DISC-05` : seulement quand l'utilisateur cherche ou attend un appareil, l'application ouverte (plan J6d, D3). | ✅ Android (WP-D.3, `Ble.kt`) : UUID seul, recherche filtrée sur le service, en pause pendant une session ; Windows (WP-D.3, `ble.cpp`) : `GattServiceProvider` caché, recherche filtrée ; un téléphone appairé en Bluetooth avec le PC n'est pas lu (plan J6d) | | `BLE-03` | **Messages.** Format, tailles et réponses du §6.6 ; 512 octets au plus. Une version inconnue, une taille fausse, un champ de trop ou de moins : `refuse`, connexion fermée. Aucune taille de MTU n'est exigée. R répond dans la version de I s'il la connaît. | ✅ cœur (WP-D.2) : `pkg/pairing/ble.go`, `FuzzBLEReceive` | | `BLE-04` | **Reconnaissance.** `hello` porte un défi frais. `hello_reply` porte toujours 16 étiquettes, dans un ordre aléatoire, le suffixe et `c_R`. Seuls les appareils de confiance mutuelle qui ont une clé `K_AB` ont une étiquette ; oublier un appareil retire la sienne. `c_R` vaut pour cette connexion, 60 s au plus. | ✅ cœur (WP-D.2) | | `BLE-05` | **Appareil de confiance.** `trusted` porte `tag(fp_I, c_R)` et la demande chiffrée. Chaque côté ne calcule qu'avec la clé de sa propre liste : l'échange exige la confiance des deux côtés (`PAIR-09`). Aucune question à l'utilisateur de R, qui voit l'appareil se connecter. | ✅ cœur (WP-D.2) | @@ -471,7 +471,7 @@ Seul circule un réseau créé par medXfer pour l'occasion, au mot de passe tir | `BLE-08` | **Chiffrement.** AES-256-GCM sous les clés du §6.6, une par sens, nonce compté et non transmis. Tout échec (étiquette inconnue, déchiffrement, confirmation) : `refuse`, neutre, et connexion fermée. Le mot de passe du réseau ne circule jamais en clair et n'est jamais journalisé (§13). | ✅ cœur et API (WP-D.2) : mot de passe masqué dans le journal (`TestBLEByPINThenPairBLE`) ; revue G4 | | `BLE-09` | **Le réseau.** R décide qui le crée, selon la règle du §6.6. Seul circule un réseau créé par medXfer pour l'occasion, au mot de passe tiré à chaque démarrage (`DISC-12`), jamais un réseau de l'utilisateur. Le code de pays ne circule pas. Un réseau que personne n'a rejoint en 2 minutes est arrêté. | ✅ règle et contrôle du réseau dans le cœur (WP-D.2, `Creator`) ; création à la bande convenue : Android (WP-D.3) ; **à faire** : l'arrêt d'un réseau que personne n'a rejoint en 2 minutes | | `BLE-10` | **Sur le Wi-Fi.** Celui qui rejoint épingle l'empreinte du créateur. Sur le chemin du PIN, il envoie `pair_ble`, et le créateur n'accepte que l'empreinte authentifiée par BLE, une fois, dans les 2 minutes (`CHAN-04`). Rien n'est écrit (`PAIR-08`). Un seul PIN sert au réseau et à l'appairage. | ✅ (WP-D.2) : `BLEAuthenticated`, `pair_ble` (`TestBLEByPINThenPairBLE`, `TestPairBLEWithoutBLEIsRefused`) | -| `BLE-11` | **Limites** (des paramètres, règle R4). 4 clients GATT à la fois ; 4 Kio reçus au plus par connexion (comme `LIM-03`). R attend `pin` au plus la vie du PIN (2 min), tout autre message 10 s, et répond en 25 s au plus, création du réseau comprise : une transaction GATT expire à 30 s. L'état d'une connexion disparaît avec elle. Aucune limite par adresse : les adresses BLE changent. | Cœur et API (WP-D.2) : 4 Kio par connexion, défi de 60 s, saisie du PIN, 4 connexions ; Android (WP-D.3) : réponse attendue 25 s au plus ; 📏 en WP-D.6 | +| `BLE-11` | **Limites** (des paramètres, règle R4). 4 clients GATT à la fois ; 4 Kio reçus au plus par connexion (comme `LIM-03`). R attend `pin` au plus la vie du PIN (2 min), tout autre message 10 s, et répond en 25 s au plus, création du réseau comprise : une transaction GATT expire à 30 s. L'état d'une connexion disparaît avec elle. Aucune limite par adresse : les adresses BLE changent. | Cœur et API (WP-D.2) : 4 Kio par connexion, défi de 60 s, saisie du PIN, 4 connexions ; Android et Windows (WP-D.3) : réponse attendue 25 s au plus ; 📏 en WP-D.6 | | `BLE-12` | **Permissions et radio.** Les permissions sont demandées quand BLE sert, avec leur raison. Android 12 et plus : `BLUETOOTH_SCAN` (sans localisation), `BLUETOOTH_ADVERTISE`, `BLUETOOTH_CONNECT`. Android 11 : `BLUETOOTH`, `BLUETOOTH_ADMIN` et la localisation précise, que le système exige pour balayer. Bluetooth éteint (3 appareils sur 4 au début de la sonde) : l'application le dit et propose de l'allumer. | ✅ Android (WP-D.3) : permissions demandées à l'usage, demande du système pour allumer le Bluetooth ; Windows : à venir | --- diff --git a/docs/medxfer-plan-j6d.md b/docs/medxfer-plan-j6d.md index 5fea9e7..d5b5ea9 100644 --- a/docs/medxfer-plan-j6d.md +++ b/docs/medxfer-plan-j6d.md @@ -159,8 +159,32 @@ Essayé, sans effet : un verrou Wi-Fi « haute performance » sur le TECNO. Le j **Reste ouvert** : - la coupure, écran éteint. Elle ne tient pas à BLE : le réseau est le même après un QR. Pistes : le groupe Wi-Fi Direct à 5 GHz de l'A57 au lieu de son point d'accès local, garder l'écran allumé pendant un transfert, un autre téléphone ; -- l'arrêt d'un réseau créé pour BLE que personne n'a rejoint en 2 minutes (`BLE-09`) ; -- l'adaptateur Windows. +- l'arrêt d'un réseau créé pour BLE que personne n'a rejoint en 2 minutes (`BLE-09`). + +**Windows, fait (3 novembre)** : +- `ble.cpp`, dans le runner, en C++/WinRT : le même canal que `Ble.kt`. `GattServiceProvider` en mode caché, une réponse gardée jusqu'à ce que l'autre la lise (25 s au plus), un balayage filtré sur le service, un client qui fait une découverte complète puis écrit et lit. Une erreur du client dit l'étape où elle survient. +- `wifi_join.cpp` : **le PC rejoint seul le réseau d'un autre appareil**, par l'API Wi-Fi de Windows (`wlanapi`), avec un profil de l'utilisateur, sans droits d'administrateur. Le profil est supprimé au départ et le PC revient au réseau où il était. Il sert aussi au QR : le PC n'envoie plus l'utilisateur dans les réglages de Windows. +- Bandes annoncées : création à 2,4 GHz (sonde WP-C.1) ; rejoindre à 2,4 GHz, et à 5 GHz si la carte le fait ; rien Wi-Fi éteint. +- `platform/ble.dart` : un seul adaptateur à canal, `ChannelBle`, pour Android et Windows. + +**Essai réel du 3 novembre**, PC Windows 11 (Intel AC 8265), Galaxy A57, TECNO BF6 ; le PC gardait Internet par le partage de connexion USB du TECNO : + +| Mesure | Résultat | +|---|---| +| PC et TECNO (inconnus l'un de l'autre) : se trouver par BLE | ✅ dans les deux sens | +| **Chemin du PIN**, premier essai réel : PIN montré par le TECNO, tapé sur le PC | ✅ | +| Le TECNO crée son réseau, le PC le rejoint seul | ✅ point d'accès local à 2,4 GHz, canal 6 ; session ouverte, un PDF passe | +| PC et Galaxy A57 **appairés en Bluetooth** dans les réglages | ❌ le PC voit l'A57, mais la découverte de ses services échoue à chaque fois (`E_UNEXPECTED`, ou « injoignable »), la sonde de WP-D.0 aussi | +| Le même A57, l'appairage Bluetooth supprimé des deux côtés | ✅ reconnu ; l'A57 crée son groupe à **5 GHz** (canal 36, 866,7 Mbit/s), le PC le rejoint seul, session de confiance sans PIN | +| **1 Gio du PC vers l'A57**, écran allumé | ✅ **42,4 Mo/s** en moyenne, 62,8 Mo/s en pointe, 25,3 s ; empreinte SHA-256 identique | +| Fermeture de l'application sur le PC | ✅ réseau quitté, profil supprimé | +| 1 Gio du PC vers le TECNO, écran du TECNO éteint | ❌ coupé vers 44 % ; l'application affiche pourtant « le transfert continue » et sa barre avance | + +**Reste ouvert** : +- un téléphone appairé en Bluetooth avec le PC : le PC n'en lit pas le service. Les utilisateurs appairent souvent leur téléphone ; à corriger avant WP-D.6 ; +- l'A57 ne voit pas l'annonce de l'application Windows, alors qu'il voit celle de la sonde et que le TECNO voit celle de l'application. Le PC touchant l'A57 suffit pour l'instant ; +- un transfert mort affiché comme en cours, après une coupure ; +- la coupure écran éteint (#256), cette fois le TECNO créant le réseau. ### WP-D.4 — Le groupe Wi-Fi Direct à 5 GHz derrière `Hotspot` · M (avancé de J8, décision D1)