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', + ), + ), + ); + }, + ); +}