From 8fbafb113a6e6b06bf0b08e553e00bd04190567e Mon Sep 17 00:00:00 2001 From: Jaren Date: Thu, 8 Oct 2026 12:42:08 +0800 Subject: [PATCH] feat(wifi_iot): add connectWithResult with native error details Expose connection outcomes through an additive WiFiConnectResult API with stable codes and available native code, domain and message information. Preserve connect and findAndConnect boolean signatures and legacy channel behavior so existing clients remain compatible. Add Android and iOS result adapters, validate new API arguments and cover the method-channel contract with regression tests. Document platform limits without exposing a network handle or changing package release metadata. --- packages/wifi_iot/README.md | 30 +++ .../alternadom/wifiiot/WifiConnectCodes.java | 38 +++ .../com/alternadom/wifiiot/WifiIotPlugin.java | 180 +++++++++++--- .../Sources/wifi_iot/WifiConnectCodes.swift | 63 +++++ .../Sources/wifi_iot/WifiIotPlugin.swift | 89 ++++--- packages/wifi_iot/lib/wifi_connect_codes.dart | 70 ++++++ .../wifi_iot/lib/wifi_connect_result.dart | 34 +++ packages/wifi_iot/lib/wifi_iot.dart | 72 ++++++ packages/wifi_iot/pubspec.yaml | 4 + packages/wifi_iot/test/connect_test.dart | 228 ++++++++++++++++++ 10 files changed, 747 insertions(+), 61 deletions(-) create mode 100644 packages/wifi_iot/android/src/main/java/com/alternadom/wifiiot/WifiConnectCodes.java create mode 100644 packages/wifi_iot/ios/wifi_iot/Sources/wifi_iot/WifiConnectCodes.swift create mode 100644 packages/wifi_iot/lib/wifi_connect_codes.dart create mode 100644 packages/wifi_iot/lib/wifi_connect_result.dart create mode 100644 packages/wifi_iot/test/connect_test.dart diff --git a/packages/wifi_iot/README.md b/packages/wifi_iot/README.md index 9b3da102..259eb23a 100644 --- a/packages/wifi_iot/README.md +++ b/packages/wifi_iot/README.md @@ -115,6 +115,36 @@ Permission to configure WiFi AP SSID and password: ``` ---> +## Connection results + +`connect()` continues to return `Future`. Use `connectWithResult()` with +the same arguments when you need a stable error code and available native +details: + +```dart +final result = await WiFiForIoTPlugin.connectWithResult( + 'My network', + password: 'My password', + security: NetworkSecurity.WPA, +); +if (!result.isSuccess) { + print(result.code); + print(result.nativeCode); + print(result.nativeDomain); + print(result.message); +} +``` + +`WiFiConnectCode.ok` (an empty string) means success. Native details are nullable: +Android suggestion results include the original `WifiManager` status, and iOS +errors include their `NSError` code, domain and localized message. Other failures +use stable plugin codes; Android `onUnavailable()` cannot reliably distinguish +user rejection, wrong credentials and other connection failures. + +On Android 10 or later, `withInternet: true` uses the suggestion API. A successful +result means the suggestion was accepted, and does not guarantee that the system +has connected to the network. + ## Troubleshooting Don't hesitate and come [here](https://github.com/flutternetwork/WiFiFlutter/issues), we will be happy to help you! diff --git a/packages/wifi_iot/android/src/main/java/com/alternadom/wifiiot/WifiConnectCodes.java b/packages/wifi_iot/android/src/main/java/com/alternadom/wifiiot/WifiConnectCodes.java new file mode 100644 index 00000000..a363c7ca --- /dev/null +++ b/packages/wifi_iot/android/src/main/java/com/alternadom/wifiiot/WifiConnectCodes.java @@ -0,0 +1,38 @@ +package com.alternadom.wifiiot; + +/** + * Unified Wi-Fi connect result codes; values must match {@code lib/wifi_connect_codes.dart} and iOS + * {@code WifiConnectCodes.swift}. Empty string means success. + */ +public final class WifiConnectCodes { + private WifiConnectCodes() {} + + public static final String OK = ""; + + public static final String INVALID_SSID = "invalid_ssid"; + + public static final String INVALID_BSSID = "invalid_bssid"; + public static final String WEP_NOT_SUPPORTED = "wep_not_supported"; + public static final String UNKNOWN = "unknown"; + + public static final String NETWORK_CONFIGURATION_FAILED = "network_configuration_failed"; + public static final String DISCONNECT_FAILED = "disconnect_failed"; + public static final String ENABLE_NETWORK_FAILED = "enable_network_failed"; + public static final String CONNECTION_TIMEOUT = "connection_timeout"; + public static final String CONNECTION_UNAVAILABLE = "connection_unavailable"; + + public static final String NETWORK_SUGGESTION_INTERNAL = "network_suggestion_internal"; + public static final String NETWORK_SUGGESTION_APP_DISALLOWED = + "network_suggestion_app_disallowed"; + public static final String NETWORK_SUGGESTION_DUPLICATE = "network_suggestion_duplicate"; + public static final String NETWORK_SUGGESTION_EXCEEDS_MAX_PER_APP = + "network_suggestion_exceeds_max_per_app"; + public static final String NETWORK_SUGGESTION_REMOVE_INVALID = + "network_suggestion_remove_invalid"; + public static final String NETWORK_SUGGESTION_ADD_NOT_ALLOWED = + "network_suggestion_add_not_allowed"; + public static final String NETWORK_SUGGESTION_INVALID = "network_suggestion_invalid"; + public static final String NETWORK_SUGGESTION_RESTRICTED_BY_ADMIN = + "network_suggestion_restricted_by_admin"; + public static final String NETWORK_SUGGESTION_FAILED = "network_suggestion_failed"; +} diff --git a/packages/wifi_iot/android/src/main/java/com/alternadom/wifiiot/WifiIotPlugin.java b/packages/wifi_iot/android/src/main/java/com/alternadom/wifiiot/WifiIotPlugin.java index 455839e1..f5201b7a 100644 --- a/packages/wifi_iot/android/src/main/java/com/alternadom/wifiiot/WifiIotPlugin.java +++ b/packages/wifi_iot/android/src/main/java/com/alternadom/wifiiot/WifiIotPlugin.java @@ -42,7 +42,9 @@ import io.flutter.plugin.common.MethodChannel.Result; import io.flutter.plugin.common.PluginRegistry; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -233,13 +235,16 @@ public void onMethodCall(MethodCall poCall, Result poResult) { setEnabled(poCall, poResult); break; case "connect": - connect(poCall, poResult); + connect(poCall, connectResultAdapter(poResult, false)); + break; + case "connectWithResult": + connect(poCall, connectResultAdapter(poResult, true)); break; case "registerWifiNetwork": registerWifiNetwork(poCall, poResult); break; case "findAndConnect": - findAndConnect(poCall, poResult); + findAndConnect(poCall, connectResultAdapter(poResult, false)); break; case "isConnected": isConnected(poResult); @@ -878,16 +883,26 @@ public void run() { Boolean isHidden = poCall.argument("is_hidden"); Integer timeoutInSeconds = poCall.argument("timeout_in_seconds"); - connectTo( - poResult, - ssid, - bssid, - password, - security, - joinOnce, - withInternet, - isHidden, - timeoutInSeconds); + try { + connectTo( + poResult, + ssid, + bssid, + password, + security, + joinOnce, + withInternet, + isHidden, + timeoutInSeconds); + } catch (RuntimeException error) { + new Handler(Looper.getMainLooper()) + .post( + () -> + poResult.error( + error.getClass().getName(), + error.getMessage(), + error.getClass().getName())); + } } }.start(); } @@ -1255,6 +1270,103 @@ private static String longToIP(int longIp) { return sb.toString(); } + private static String networkSuggestionStatusToCode(int status) { + if (status == WifiManager.STATUS_NETWORK_SUGGESTIONS_SUCCESS) { + return WifiConnectCodes.OK; + } + if (status == WifiManager.STATUS_NETWORK_SUGGESTIONS_ERROR_INTERNAL) { + return WifiConnectCodes.NETWORK_SUGGESTION_INTERNAL; + } + if (status == WifiManager.STATUS_NETWORK_SUGGESTIONS_ERROR_APP_DISALLOWED) { + return WifiConnectCodes.NETWORK_SUGGESTION_APP_DISALLOWED; + } + if (status == WifiManager.STATUS_NETWORK_SUGGESTIONS_ERROR_ADD_DUPLICATE) { + return WifiConnectCodes.NETWORK_SUGGESTION_DUPLICATE; + } + if (status == WifiManager.STATUS_NETWORK_SUGGESTIONS_ERROR_ADD_EXCEEDS_MAX_PER_APP) { + return WifiConnectCodes.NETWORK_SUGGESTION_EXCEEDS_MAX_PER_APP; + } + if (status == WifiManager.STATUS_NETWORK_SUGGESTIONS_ERROR_REMOVE_INVALID) { + return WifiConnectCodes.NETWORK_SUGGESTION_REMOVE_INVALID; + } + if (status == WifiManager.STATUS_NETWORK_SUGGESTIONS_ERROR_ADD_NOT_ALLOWED) { + return WifiConnectCodes.NETWORK_SUGGESTION_ADD_NOT_ALLOWED; + } + if (status == WifiManager.STATUS_NETWORK_SUGGESTIONS_ERROR_ADD_INVALID) { + return WifiConnectCodes.NETWORK_SUGGESTION_INVALID; + } + if (status == WifiManager.STATUS_NETWORK_SUGGESTIONS_ERROR_RESTRICTED_BY_ADMIN) { + return WifiConnectCodes.NETWORK_SUGGESTION_RESTRICTED_BY_ADMIN; + } + return WifiConnectCodes.NETWORK_SUGGESTION_FAILED; + } + + private static Map buildConnectResult(String code) { + return buildConnectResult(code, null, null, null); + } + + private static Map buildConnectResult( + String code, Object nativeCode, String nativeDomain, String message) { + Map value = new HashMap<>(); + value.put("code", code); + if (nativeCode != null) value.put("nativeCode", nativeCode); + if (nativeDomain != null) value.put("nativeDomain", nativeDomain); + if (message != null) value.put("message", message); + return value; + } + + private Result connectResultAdapter(final Result result, final boolean detailed) { + return new Result() { + @Override + public void success(Object value) { + if (detailed) { + // Allow shared connection paths to report a boolean without losing + // the detailed method's map contract. + if (value instanceof Boolean) { + result.success( + buildConnectResult( + (Boolean) value ? WifiConnectCodes.OK : WifiConnectCodes.UNKNOWN)); + } else { + result.success(value); + } + return; + } + if (!(value instanceof Map)) { + result.success(value); + return; + } + final Object code = ((Map) value).get("code"); + if (WifiConnectCodes.INVALID_BSSID.equals(code)) { + result.error("Error", "Invalid BSSID representation", ""); + } else if (WifiConnectCodes.WEP_NOT_SUPPORTED.equals(code)) { + result.error( + "Error", "WEP is not supported for Android SDK " + Build.VERSION.SDK_INT, ""); + } else { + result.success(WifiConnectCodes.OK.equals(code)); + } + } + + @Override + public void error(String code, String message, Object details) { + if (detailed) { + result.success( + buildConnectResult( + "platform_error", + code, + details instanceof String ? (String) details : null, + message)); + } else { + result.error(code, message, details); + } + } + + @Override + public void notImplemented() { + result.notImplemented(); + } + }; + } + /// Method to connect to WIFI Network private void connectTo( final Result poResult, @@ -1268,13 +1380,12 @@ private void connectTo( final Integer timeoutInSeconds) { final Handler handler = new Handler(Looper.getMainLooper()); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - final boolean connected = - connectToDeprecated(ssid, bssid, password, security, joinOnce, isHidden); + final String code = connectToDeprecated(ssid, bssid, password, security, joinOnce, isHidden); handler.post( new Runnable() { @Override public void run() { - poResult.success(connected); + poResult.success(buildConnectResult(code)); } }); } else { @@ -1284,8 +1395,7 @@ public void run() { new Runnable() { @Override public void run() { - poResult.error( - "Error", "WEP is not supported for Android SDK " + Build.VERSION.SDK_INT, ""); + poResult.success(buildConnectResult(WifiConnectCodes.WEP_NOT_SUPPORTED)); } }); return; @@ -1304,7 +1414,7 @@ public void run() { new Runnable() { @Override public void run() { - poResult.error("Error", "Invalid BSSID representation", ""); + poResult.success(buildConnectResult(WifiConnectCodes.INVALID_BSSID)); } }); return; @@ -1338,7 +1448,12 @@ public void run() { new Runnable() { @Override public void run() { - poResult.success(status == WifiManager.STATUS_NETWORK_SUGGESTIONS_SUCCESS); + poResult.success( + buildConnectResult( + networkSuggestionStatusToCode(status), + status, + "android.net.wifi.WifiManager", + null)); } }); } else { @@ -1354,7 +1469,7 @@ public void run() { new Runnable() { @Override public void run() { - poResult.error("Error", "Invalid BSSID representation", ""); + poResult.success(buildConnectResult(WifiConnectCodes.INVALID_BSSID)); } }); return; @@ -1388,7 +1503,7 @@ public void onAvailable(@NonNull Network network) { super.onAvailable(network); if (!resultSent) { joinedNetwork = network; - poResult.success(true); + poResult.success(buildConnectResult(WifiConnectCodes.OK)); resultSent = true; } } @@ -1400,7 +1515,7 @@ public void onUnavailable() { connectivityManager.unregisterNetworkCallback(this); } if (!resultSent) { - poResult.success(false); + poResult.success(buildConnectResult(WifiConnectCodes.CONNECTION_UNAVAILABLE)); resultSent = true; } } @@ -1502,7 +1617,7 @@ private android.net.wifi.WifiConfiguration generateConfiguration( } @SuppressWarnings("deprecation") - private Boolean connectToDeprecated( + private String connectToDeprecated( String ssid, String bssid, String password, @@ -1516,7 +1631,7 @@ private Boolean connectToDeprecated( int updateNetwork = registerWifiNetworkDeprecated(conf); if (updateNetwork == -1) { - return false; + return WifiConnectCodes.NETWORK_CONFIGURATION_FAILED; } if (joinOnce != null && joinOnce.booleanValue()) { @@ -1525,13 +1640,14 @@ private Boolean connectToDeprecated( boolean disconnect = moWiFi.disconnect(); if (!disconnect) { - return false; + return WifiConnectCodes.DISCONNECT_FAILED; } boolean enabled = moWiFi.enableNetwork(updateNetwork, true); - if (!enabled) return false; + if (!enabled) { + return WifiConnectCodes.ENABLE_NETWORK_FAILED; + } - boolean connected = false; for (int i = 0; i < 20; i++) { WifiInfo currentNet = moWiFi.getConnectionInfo(); int networkId = currentNet.getNetworkId(); @@ -1540,16 +1656,18 @@ private Boolean connectToDeprecated( // Wait for connection to reach state completed // to discard false positives like auth error if (networkId != -1 && netState == SupplicantState.COMPLETED) { - connected = networkId == updateNetwork; - break; + if (networkId == updateNetwork) { + return WifiConnectCodes.OK; + } + return WifiConnectCodes.CONNECTION_TIMEOUT; } try { Thread.sleep(500); } catch (InterruptedException ignored) { - break; + return WifiConnectCodes.CONNECTION_TIMEOUT; } } - return connected; + return WifiConnectCodes.CONNECTION_TIMEOUT; } } diff --git a/packages/wifi_iot/ios/wifi_iot/Sources/wifi_iot/WifiConnectCodes.swift b/packages/wifi_iot/ios/wifi_iot/Sources/wifi_iot/WifiConnectCodes.swift new file mode 100644 index 00000000..96b878ea --- /dev/null +++ b/packages/wifi_iot/ios/wifi_iot/Sources/wifi_iot/WifiConnectCodes.swift @@ -0,0 +1,63 @@ +import Foundation +import NetworkExtension + +/// Unified Wi-Fi connect result codes; values must match `lib/wifi_connect_codes.dart` and Android `WifiConnectCodes.java`. Empty string means success. +enum WifiConnectCodes { + static let ok = "" + static let invalidSsid = "invalid_ssid" + static let invalidConfiguration = "invalid_configuration" + static let invalidSsidNative = "invalid_ssid_native" + static let invalidWpaPassphrase = "invalid_wpa_passphrase" + static let invalidWepPassphrase = "invalid_wep_passphrase" + static let invalidEapSettings = "invalid_eap_settings" + static let invalidHs20Settings = "invalid_hs20_settings" + static let internalError = "internal_error" + static let operationPending = "operation_pending" + static let systemConfigurationError = "system_configuration_error" + static let unknownError = "unknown_error" + static let joinOnceNotSupported = "join_once_not_supported" + static let notInForeground = "not_in_foreground" + static let userDenied = "user_denied" + + static let postApplySsidUnavailable = "post_apply_ssid_unavailable" + static let postApplySsidMismatch = "post_apply_ssid_mismatch" + + static let iosVersionUnsupported = "ios_version_unsupported" + static let pluginInternal = "plugin_internal" + + @available(iOS 11.0, *) + static func from(neError: NEHotspotConfigurationError) -> String { + switch neError { + case .invalid: + return invalidConfiguration + case .invalidSSID: + return invalidSsidNative + case .invalidWPAPassphrase: + return invalidWpaPassphrase + case .invalidWEPPassphrase: + return invalidWepPassphrase + case .invalidEAPSettings: + return invalidEapSettings + case .invalidHS20Settings: + return invalidHs20Settings + case .internal: + return internalError + case .pending: + return operationPending + case .systemConfiguration: + return systemConfigurationError + case .unknown: + return unknownError + case .joinOnceNotSupported: + return joinOnceNotSupported + case .alreadyAssociated: + return ok + case .applicationIsNotInForeground: + return notInForeground + case .userDenied: + return userDenied + default: + return unknownError + } + } +} diff --git a/packages/wifi_iot/ios/wifi_iot/Sources/wifi_iot/WifiIotPlugin.swift b/packages/wifi_iot/ios/wifi_iot/Sources/wifi_iot/WifiIotPlugin.swift index 65073906..47da5838 100755 --- a/packages/wifi_iot/ios/wifi_iot/Sources/wifi_iot/WifiIotPlugin.swift +++ b/packages/wifi_iot/ios/wifi_iot/Sources/wifi_iot/WifiIotPlugin.swift @@ -29,6 +29,15 @@ public class WifiIotPlugin: NSObject, FlutterPlugin { findAndConnect(call: call, result: result) break case "connect": // OK + connect(call: call) { value in + guard let value = value as? [String: Any], let code = value["code"] as? String else { + result(false) + return + } + result(code == WifiConnectCodes.ok) + } + break + case "connectWithResult": connect(call: call, result: result) break case "isConnected": // OK @@ -118,47 +127,67 @@ public class WifiIotPlugin: NSObject, FlutterPlugin { } } + private func connectResult( + _ code: String, nativeError: NSError? = nil + ) -> [String: Any] { + var value: [String: Any] = ["code": code] + if let nativeError = nativeError { + value["nativeCode"] = nativeError.code + value["nativeDomain"] = nativeError.domain + value["message"] = nativeError.localizedDescription + } + return value + } + private func connect(call: FlutterMethodCall, result: @escaping FlutterResult) { - let sSSID = (call.arguments as? [String: AnyObject])?["ssid"] as! String - let _ = (call.arguments as? [String: AnyObject])?["bssid"] as? String? // not used - let sPassword = (call.arguments as? [String: AnyObject])?["password"] as? String? ?? nil - let bJoinOnce = (call.arguments as? [String: AnyObject])?["join_once"] as! Bool? - let sSecurity = (call.arguments as? [String: AnyObject])?["security"] as! String? + let arguments = call.arguments as? [String: Any] + guard let ssid = arguments?["ssid"] as? String else { + result(connectResult(WifiConnectCodes.invalidSsid)) + return + } + let password = arguments?["password"] as? String + let security = arguments?["security"] as? String + let joinOnce = arguments?["join_once"] as? Bool ?? false + if let security = security, security.uppercased() != "NONE", password == nil { + result(connectResult(WifiConnectCodes.invalidConfiguration)) + return + } if #available(iOS 11.0, *) { let configuration = initHotspotConfiguration( - ssid: sSSID, passphrase: sPassword, security: sSecurity) - configuration.joinOnce = bJoinOnce ?? false - - NEHotspotConfigurationManager.shared.apply(configuration) { [weak self] (error) in - guard let this = self else { - print("WiFi network not found") - result(false) + ssid: ssid, passphrase: password, security: security) + configuration.joinOnce = joinOnce + NEHotspotConfigurationManager.shared.apply(configuration) { [weak self] error in + guard let self = self else { + result(["code": WifiConnectCodes.pluginInternal]) + return + } + if let error = error { + let nativeError = error as NSError + let code: String + if nativeError.domain == NEHotspotConfigurationErrorDomain, + let hotspotError = NEHotspotConfigurationError(rawValue: nativeError.code) + { + code = WifiConnectCodes.from(neError: hotspotError) + } else { + code = WifiConnectCodes.unknownError + } + result(self.connectResult(code, nativeError: nativeError)) return } - this.getSSID { (connectedSSID) -> Void in - if error != nil { - if error?.localizedDescription == "already associated." { - print("Connected to '\(connectedSSID ?? "")'") - result(true) - } else { - print("Not Connected") - result(false) - } - } else if let connectedSSID = connectedSSID { - print("Connected to " + connectedSSID) - // Emit result of [isConnected] by checking if targetSSID is the same as connectedSSID. - result(sSSID == connectedSSID) + self.getSSID { connectedSSID in + let code: String + if let connectedSSID = connectedSSID { + code = + ssid == connectedSSID ? WifiConnectCodes.ok : WifiConnectCodes.postApplySsidMismatch } else { - print("WiFi network not found") - result(false) + code = WifiConnectCodes.postApplySsidUnavailable } + result(self.connectResult(code)) } } } else { - print("Not Connected") - result(nil) - return + result(connectResult(WifiConnectCodes.iosVersionUnsupported)) } } diff --git a/packages/wifi_iot/lib/wifi_connect_codes.dart b/packages/wifi_iot/lib/wifi_connect_codes.dart new file mode 100644 index 00000000..e13827a5 --- /dev/null +++ b/packages/wifi_iot/lib/wifi_connect_codes.dart @@ -0,0 +1,70 @@ +// ignore_for_file: package_api_docs, public_member_api_docs + +/// Unified Wi‑Fi connect result codes for Flutter, Android, and iOS (`WiFiForIoTPlugin.connectWithResult`). +/// +/// The native platforms return the same string values. An empty string means success; +/// any non-empty value is an error code. +abstract class WiFiConnectCode { + WiFiConnectCode._(); + + static const String ok = ''; + + // Flutter-side validation + static const String invalidSsid = 'invalid_ssid'; + + // Shared / generic + static const String invalidBssid = 'invalid_bssid'; + static const String wepNotSupported = 'wep_not_supported'; + static const String unknown = 'unknown'; + static const String missingPlugin = 'missing_plugin'; + static const String platformError = 'platform_error'; + + // Android — configuration & association (deprecated API / supplicant) + static const String networkConfigurationFailed = + 'network_configuration_failed'; + static const String disconnectFailed = 'disconnect_failed'; + static const String enableNetworkFailed = 'enable_network_failed'; + static const String connectionTimeout = 'connection_timeout'; + static const String connectionUnavailable = 'connection_unavailable'; + + // Android — WifiManager.addNetworkSuggestions (see `WifiManager` status constants) + static const String networkSuggestionInternal = 'network_suggestion_internal'; + static const String networkSuggestionAppDisallowed = + 'network_suggestion_app_disallowed'; + static const String networkSuggestionDuplicate = + 'network_suggestion_duplicate'; + static const String networkSuggestionExceedsMaxPerApp = + 'network_suggestion_exceeds_max_per_app'; + static const String networkSuggestionRemoveInvalid = + 'network_suggestion_remove_invalid'; + static const String networkSuggestionAddNotAllowed = + 'network_suggestion_add_not_allowed'; + static const String networkSuggestionInvalid = 'network_suggestion_invalid'; + static const String networkSuggestionRestrictedByAdmin = + 'network_suggestion_restricted_by_admin'; + static const String networkSuggestionFailed = 'network_suggestion_failed'; + + // iOS — NEHotspotConfigurationError + static const String invalidConfiguration = 'invalid_configuration'; + static const String invalidSsidNative = 'invalid_ssid_native'; + static const String invalidWpaPassphrase = 'invalid_wpa_passphrase'; + static const String invalidWepPassphrase = 'invalid_wep_passphrase'; + static const String invalidEapSettings = 'invalid_eap_settings'; + static const String invalidHs20Settings = 'invalid_hs20_settings'; + static const String internalError = 'internal_error'; + static const String operationPending = 'operation_pending'; + static const String systemConfigurationError = 'system_configuration_error'; + static const String unknownError = 'unknown_error'; + static const String joinOnceNotSupported = 'join_once_not_supported'; + static const String notInForeground = 'not_in_foreground'; + static const String userDenied = 'user_denied'; + + // iOS only: after NEHotspotConfigurationManager.apply succeeds, getSSID verification + static const String postApplySsidUnavailable = 'post_apply_ssid_unavailable'; + static const String postApplySsidMismatch = 'post_apply_ssid_mismatch'; + + static const String iosVersionUnsupported = 'ios_version_unsupported'; + static const String pluginInternal = 'plugin_internal'; + + static bool isOk(String code) => code.isEmpty; +} diff --git a/packages/wifi_iot/lib/wifi_connect_result.dart b/packages/wifi_iot/lib/wifi_connect_result.dart new file mode 100644 index 00000000..18f2615a --- /dev/null +++ b/packages/wifi_iot/lib/wifi_connect_result.dart @@ -0,0 +1,34 @@ +import 'wifi_connect_codes.dart'; + +/// The outcome of a Wi-Fi connection operation. +/// +/// With `withInternet: true` on Android 10 or later, success means the network +/// suggestion was accepted. The system decides whether and when to connect. +/// Otherwise, success means the platform reported a connection to the network. +class WiFiConnectResult { + /// Creates a connection result with a stable [code] and optional native details. + const WiFiConnectResult({ + required this.code, + this.nativeCode, + this.nativeDomain, + this.message, + }); + + /// A stable error code from [WiFiConnectCode], or [WiFiConnectCode.ok]. + final String code; + + /// The original platform error or status code, when available. + /// + /// This is an integer for Android suggestion statuses and iOS NSError codes, + /// or a string for errors delivered as a Flutter PlatformException. + final Object? nativeCode; + + /// The native NSError domain or Android API/exception class, when available. + final String? nativeDomain; + + /// The platform's error message, when available. It may be localized. + final String? message; + + /// Whether the operation succeeded, subject to the platform semantics above. + bool get isSuccess => code == WiFiConnectCode.ok; +} diff --git a/packages/wifi_iot/lib/wifi_iot.dart b/packages/wifi_iot/lib/wifi_iot.dart index 0125e4a8..1acc4675 100755 --- a/packages/wifi_iot/lib/wifi_iot.dart +++ b/packages/wifi_iot/lib/wifi_iot.dart @@ -5,6 +5,12 @@ import 'dart:io'; import 'package:flutter/services.dart'; +import 'wifi_connect_codes.dart'; +import 'wifi_connect_result.dart'; + +export 'wifi_connect_codes.dart'; +export 'wifi_connect_result.dart'; + enum WIFI_AP_STATE { WIFI_AP_STATE_DISABLING, WIFI_AP_STATE_DISABLED, @@ -372,6 +378,72 @@ class WiFiForIoTPlugin { return bResult ?? false; } + /// Connects to a Wi-Fi network and returns error details when available. + /// + /// Accepts the same parameters as [connect]. Existing callers can continue + /// using [connect], which returns a boolean. + /// + /// On Android 10 or later with [withInternet] enabled, a successful result + /// means the network suggestion was added, not that a connection is already + /// established. For local connections, Android's `onUnavailable` callback + /// cannot distinguish user rejection from other connection failures. + /// + /// Native codes, domains and messages are included only when the platform + /// supplies them. Errors without native details use [WiFiConnectCode]. + static Future connectWithResult( + String ssid, { + String? bssid, + String? password, + NetworkSecurity security = NetworkSecurity.NONE, + bool joinOnce = true, + bool withInternet = false, + bool isHidden = false, + int timeoutInSeconds = 30, + }) async { + if (ssid.isEmpty || utf8.encode(ssid).length > 32) { + return const WiFiConnectResult(code: WiFiConnectCode.invalidSsid); + } + if (security != NetworkSecurity.NONE && password == null) { + return const WiFiConnectResult( + code: WiFiConnectCode.invalidConfiguration, + ); + } + if (timeoutInSeconds <= 0 || timeoutInSeconds > 2147483) { + return const WiFiConnectResult( + code: WiFiConnectCode.invalidConfiguration, + ); + } + + try { + if (!Platform.isIOS && !await isEnabled()) await setEnabled(true); + final raw = await _channel + .invokeMapMethod('connectWithResult', { + 'ssid': ssid, + 'bssid': bssid, + 'password': password, + 'join_once': joinOnce, + 'with_internet': withInternet, + 'is_hidden': isHidden, + 'timeout_in_seconds': timeoutInSeconds, + 'security': serializeNetworkSecurityMap[security], + }); + return WiFiConnectResult( + code: raw?['code'] as String? ?? WiFiConnectCode.unknown, + nativeCode: raw?['nativeCode'], + nativeDomain: raw?['nativeDomain'] as String?, + message: raw?['message'] as String?, + ); + } on MissingPluginException { + return const WiFiConnectResult(code: WiFiConnectCode.missingPlugin); + } on PlatformException catch (error) { + return WiFiConnectResult( + code: WiFiConnectCode.platformError, + nativeCode: error.code, + message: error.message, + ); + } + } + /// Register a network with the system in the device's wireless networks. /// Android only. /// diff --git a/packages/wifi_iot/pubspec.yaml b/packages/wifi_iot/pubspec.yaml index 1edb6569..a1a601d2 100644 --- a/packages/wifi_iot/pubspec.yaml +++ b/packages/wifi_iot/pubspec.yaml @@ -20,3 +20,7 @@ environment: dependencies: flutter: sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/packages/wifi_iot/test/connect_test.dart b/packages/wifi_iot/test/connect_test.dart new file mode 100644 index 00000000..1fde035c --- /dev/null +++ b/packages/wifi_iot/test/connect_test.dart @@ -0,0 +1,228 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wifi_iot/wifi_iot.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('wifi_iot'); + final calls = []; + Object? response; + PlatformException? failure; + var missingPlugin = false; + var failPreparation = false; + + setUp(() { + calls.clear(); + response = {'code': WiFiConnectCode.ok}; + failure = null; + missingPlugin = false; + failPreparation = false; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + if (call.method == 'isEnabled') { + if (failPreparation) { + throw PlatformException( + code: 'permission_denied', + message: 'No permission', + ); + } + return true; + } + if (missingPlugin) throw MissingPluginException(); + if (failure != null) throw failure!; + return response; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('connectWithResult forwards the complete connection request', () async { + final result = await WiFiForIoTPlugin.connectWithResult( + 'Device AP', + bssid: '00:11:22:33:44:55', + password: 'password123', + security: NetworkSecurity.WPA, + joinOnce: false, + withInternet: true, + isHidden: true, + timeoutInSeconds: 12, + ); + expect(result.isSuccess, isTrue); + expect(result.nativeCode, isNull); + expect(result.nativeDomain, isNull); + expect(result.message, isNull); + expect(calls.last.method, 'connectWithResult'); + expect(calls.last.arguments, { + 'ssid': 'Device AP', + 'bssid': '00:11:22:33:44:55', + 'password': 'password123', + 'security': 'WPA', + 'join_once': false, + 'with_internet': true, + 'is_hidden': true, + 'timeout_in_seconds': 12, + }); + }); + + test( + 'returns stable Android errors and the original suggestion status', + () async { + response = { + 'code': WiFiConnectCode.networkSuggestionAppDisallowed, + 'nativeCode': 2, + 'nativeDomain': 'android.net.wifi.WifiManager', + }; + final result = await WiFiForIoTPlugin.connectWithResult('Device AP'); + expect(result.isSuccess, isFalse); + expect(result.code, WiFiConnectCode.networkSuggestionAppDisallowed); + expect(result.nativeCode, 2); + expect(result.nativeDomain, 'android.net.wifi.WifiManager'); + }, + ); + + test( + 'preserves the iOS NSError code, domain and localized message', + () async { + response = { + 'code': WiFiConnectCode.userDenied, + 'nativeCode': 7, + 'nativeDomain': 'NEHotspotConfigurationErrorDomain', + 'message': 'The user declined the request.', + }; + final result = await WiFiForIoTPlugin.connectWithResult('Device AP'); + expect(result.isSuccess, isFalse); + expect(result.code, WiFiConnectCode.userDenied); + expect(result.nativeCode, 7); + expect(result.nativeDomain, 'NEHotspotConfigurationErrorDomain'); + expect(result.message, 'The user declined the request.'); + }, + ); + + test( + 'connectionUnavailable does not invent an authentication error', + () async { + response = {'code': WiFiConnectCode.connectionUnavailable}; + final result = await WiFiForIoTPlugin.connectWithResult('Device AP'); + expect(result.code, WiFiConnectCode.connectionUnavailable); + expect(result.isSuccess, isFalse); + expect(result.nativeCode, isNull); + }, + ); + + test( + 'rejects empty and oversized UTF-8 SSIDs before invoking the platform', + () async { + for (final ssid in ['', 'a' * 33, '界' * 11]) { + final result = await WiFiForIoTPlugin.connectWithResult(ssid); + expect(result.code, WiFiConnectCode.invalidSsid); + } + expect(calls, isEmpty); + }, + ); + + test('accepts an SSID at the UTF-8 byte limit', () async { + final result = await WiFiForIoTPlugin.connectWithResult('界' * 10 + 'ab'); + expect(result.isSuccess, isTrue); + expect(calls.last.method, 'connectWithResult'); + }); + + test('rejects missing passwords and invalid timeout values', () async { + final missingPassword = await WiFiForIoTPlugin.connectWithResult( + 'Device AP', + security: NetworkSecurity.WPA, + ); + expect(missingPassword.code, WiFiConnectCode.invalidConfiguration); + for (final timeout in [0, -1, 2147484]) { + final result = await WiFiForIoTPlugin.connectWithResult( + 'Device AP', + timeoutInSeconds: timeout, + ); + expect(result.code, WiFiConnectCode.invalidConfiguration); + } + expect(calls, isEmpty); + }); + + test( + 'reports null or incomplete native results as unknown failures', + () async { + for (final value in [null, {}]) { + response = value; + final result = await WiFiForIoTPlugin.connectWithResult('Device AP'); + expect(result.code, WiFiConnectCode.unknown); + expect(result.isSuccess, isFalse); + } + }, + ); + + test( + 'reports a missing native method without changing the old API', + () async { + missingPlugin = true; + final result = await WiFiForIoTPlugin.connectWithResult('Device AP'); + expect(result.code, WiFiConnectCode.missingPlugin); + expect(result.isSuccess, isFalse); + }, + ); + + test( + 'preserves platform exception code and message in the new API', + () async { + failure = PlatformException( + code: 'native_error', + message: 'Native details', + ); + final result = await WiFiForIoTPlugin.connectWithResult('Device AP'); + expect(result.code, WiFiConnectCode.platformError); + expect(result.nativeCode, 'native_error'); + expect(result.message, 'Native details'); + }, + ); + + test('captures failures while preparing the Wi-Fi adapter', () async { + failPreparation = true; + final result = await WiFiForIoTPlugin.connectWithResult('Device AP'); + expect(result.code, WiFiConnectCode.platformError); + expect(result.nativeCode, 'permission_denied'); + expect(calls.map((call) => call.method), ['isEnabled']); + }); + + test( + 'connect and findAndConnect retain their boolean channel contracts', + () async { + for (final value in [true, false]) { + response = value; + final Future connect = WiFiForIoTPlugin.connect('Device AP'); + expect(await connect, value); + expect(calls.last.method, 'connect'); + // ignore: deprecated_member_use + final Future find = WiFiForIoTPlugin.findAndConnect('Device AP'); + expect(await find, value); + expect(calls.last.method, 'findAndConnect'); + } + }, + ); + + test( + 'the existing connect API still propagates platform exceptions', + () async { + failure = PlatformException( + code: 'Error', + message: 'Invalid BSSID representation', + ); + await expectLater( + WiFiForIoTPlugin.connect('Device AP'), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'Error', + ), + ), + ); + }, + ); +}