munim-wifi
v0.5.0
Published
Fast Wi-Fi scanning, connection, and network information for Expo and React Native, powered by Nitro Modules
Maintainers
Readme
Introduction
munim-wifi is a comprehensive React Native Wi-Fi library for nearby-network discovery, current-network information, connection flows, and Wi-Fi fingerprinting. It exposes SSIDs, BSSIDs, signal strength, frequencies, channels, security information, local IP data, and platform-native connect/disconnect behavior where the operating system permits it.
Fully compatible with Expo! It includes an Expo config plugin and a managed Expo example app. Because the package contains native code, Expo projects must use a development build rather than Expo Go.
Built with React Native's Nitro Modules architecture using Swift on iOS, Kotlin on Android, generated native bindings, and callback-based continuous results without a legacy React Native event bridge.
Note: Wi-Fi is heavily platform-gated. Android exposes nearby scans but throttles their frequency. Ordinary iOS apps cannot perform general Wi-Fi scans, so iOS returns the current network when Apple allows access. Unsupported data is returned as null instead of being fabricated.
Table of contents
- 📚 Documentation
- 🚀 Features
- Platform Support Matrix
- 📦 Installation
- Permissions and OS Behavior
- ⚡ Quick Start
- 🔧 API Reference
- 📖 Usage Examples
- 🔍 Troubleshooting
- 👏 Contributing
- 📄 License
📚 Documentation
🚀 Features
Wi-Fi Discovery
- 📡 Nearby Network Scanning: Retrieve Android scan results without blocking a native thread.
- 📶 Signal Information: Read RSSI, frequency, and calculated 2.4/5/6/60 GHz channel information on Android.
- 🔐 Security Details: Read capabilities and an easy-to-use secure/open flag.
- 🔄 Continuous Results: Subscribe to result batches or individual networks through Nitro callbacks.
- 🧭 Wi-Fi Fingerprinting: Capture visible networks with a millisecond timestamp.
Network Management
- 🔌 Native Connection Flows: Android
WifiNetworkSpecifierand iOSNEHotspotConfiguration. - 📱 Current Network Information: Read SSID, BSSID, IP address, gateway, DNS, and subnet data where available.
- 🌐 Local Routing: Android 10+ binds the app process to the approved requested network until
disconnect(), then restores the previous binding. - 🏢 Enterprise and Passpoint: WPA2/WPA3-Enterprise (PEAP, TTLS, TLS, …) and Hotspot 2.0 on both platforms.
- 🔎 SSID-prefix joins: Join the first network whose SSID starts with a prefix (iOS 13+, Android 10+).
- 🛰️ Service Discovery: Browse Bonjour/mDNS services (
NsdManager/NWBrowser) with resolved host, port and TXT records. - 🌍 Internet Reachability: OS-validated reachability plus an optional HTTP probe.
- ✅ Explicit Failures: Invalid options, missing permissions, disabled Wi-Fi, timeouts, and unsupported WEP flows reject clearly.
Additional Features
- 📱 Cross-platform: One TypeScript API with honest platform-specific results.
- 🎯 TypeScript Support: Full result, option, callback, and HybridObject types.
- ⚡ High Performance: Nitro Modules with generated Swift/Kotlin/C++ bindings.
- 🚀 Expo Compatible: Managed config plugin and Expo 57 example project.
- 🔐 Permission Handling: Android runtime permission requests and real iOS location authorization.
- 🧪 Release Verification: Package, example, iOS, and Android release-candidate checks.
Platform Support Matrix
| Capability | iOS | Android | Notes |
| --- | --- | --- | --- |
| Nearby-network scan | ⚠️ Current network only | ✅ Full | Ordinary iOS apps cannot enumerate nearby Wi-Fi networks. |
| SSID and BSSID | ✅ | ✅ | iOS requires the Wi-Fi Information entitlement plus an Apple access criterion. |
| RSSI | ❌ | ✅ | Android returns dBm. |
| Frequency and channel | ❌ | ✅ | Android covers 2.4, 5, 6, and 60 GHz channel calculations. |
| Capabilities/security | ⚠️ Security state only | ✅ | iOS does not expose Android-style capability strings. |
| Current network | ✅ | ✅ | Values can be hidden by permissions or OS privacy behavior. |
| Local IPv4/IPv6 addresses | ✅ | ✅ | getIPAddress() (IPv4) and getIPAddresses() (IPv4 + IPv6). |
| Connect | ✅ | ✅ | Both platforms use system-controlled user-consent flows. |
| Disconnect | ⚠️ Removes app configuration | ✅ | Resolves false when there was nothing this app could release. |
| Continuous scan | ⚠️ One current-network result | ✅ | Throttled batches are flagged (info.throttled), never passed off as fresh. |
| Wi-Fi fingerprint | ⚠️ Current network only | ✅ | No location is inferred by the library. |
| Security type | ⚠️ Coarse (open/WEP/personal/enterprise) | ✅ Full | Android classifies WPA2/WPA3/OWE/EAP/Passpoint from scan capabilities. |
| Local network request | ✅ joinOnce configuration | ✅ Android 10+ specifier | Structured ConnectionOutcome on both platforms. |
| Persistent configuration | ✅ NEHotspotConfiguration | ⚠️ Via network suggestion | configureNetwork(). |
| Network suggestions | ❌ unsupported outcome | ✅ Android 10+ | NEHotspotConfiguration is the iOS analog. |
| Suggestion connection events | ❌ | ✅ Android 10+ | Post-connection broadcast; failures on Android 11+. |
| WPA2/WPA3-Enterprise | ✅ PEAP/TTLS/TLS/FAST | ✅ PEAP/TTLS/TLS/PWD/SIM/AKA/AKA' | security: { type: 'enterprise', eap }. |
| Passpoint (Hotspot 2.0) | ✅ | ✅ Android 11+ (suggestions) | security: { type: 'passpoint', passpoint, eap }. |
| SSID-prefix join | ✅ iOS 13+ | ⚠️ requestLocalNetwork only | ssidPrefix: true. |
| Configured SSIDs | ✅ | ✅ (app suggestions) | getConfiguredSSIDs(). |
| Service discovery (DNS-SD) | ✅ NWBrowser | ✅ NsdManager | iOS needs NSBonjourServices; Android 17 needs ACCESS_LOCAL_NETWORK or showPicker. |
| Service picker | ❌ (normal browse) | ✅ Android 17+ | startServiceDiscovery(type, handlers, { showPicker: true }), no permission needed. |
| Local network permission | ✅ Prompt + result | ✅ Android 17+ ACCESS_LOCAL_NETWORK | requestLocalNetworkPermission(); granted with nothing to ask below Android 17. |
| Wi-Fi on/off events | ⚠️ Wi-Fi path usable | ✅ Android 16 listener, broadcast below | addWifiStateListener(). |
| Internet reachability | ✅ NWPath | ✅ NET_CAPABILITY_VALIDATED | Optional HTTP probe on both. |
| Wi-Fi settings intent | ❌ | ✅ Android 10+ | requestUserSavedNetwork() opens the system panel. |
| Local-only hotspot | ❌ | ✅ Android 8+ | Returns generated SSID/passphrase/security. |
| Network diagnostics | ⚠️ Path-level | ✅ Capability + link level | validated/captivePortal are Android-only. |
| Network observer | ✅ NWPathMonitor | ✅ Default network callback | Continuous NetworkDiagnostics updates. |
| Capability report | ✅ | ✅ | getWifiCapabilityStatus() includes permission states. |
Platform support can vary by OS version, hardware, permission state, foreground/background state, and device-management policy.
📦 Installation
React Native CLI
npm install munim-wifi react-native-nitro-modules
# or
yarn add munim-wifi react-native-nitro-modulesExpo
npx expo install munim-wifi react-native-nitro-modulesImportant: This package requires a native Expo development build and does not work in Expo Go. After installing, run
npx expo run:ios,npx expo run:android, or create a development build with EAS.
Add the included config plugin to app.json:
{
"expo": {
"plugins": [
[
"munim-wifi",
{
"locationPermission": "Allow this app to find nearby Wi-Fi networks."
}
]
]
}
}The plugin adds the Android Wi-Fi, location, and Nearby Wi-Fi Devices permissions; the iOS location and local-network descriptions and NSBonjourServices; and the iOS Access Wi-Fi Information and Hotspot Configuration entitlements.
All plugin options:
[
"munim-wifi",
{
"locationPermission": "Allow this app to find nearby Wi-Fi networks.",
"localNetworkPermission": "Allow this app to find devices on your local network.",
"bonjourServices": ["_http._tcp", "_ipp._tcp"],
"android": {
"locationOnAndroid13Plus": true,
"neverForLocation": true,
"localNetworkPermission": true
}
}
]locationPermission: iOSNSLocationWhenInUseUsageDescription.localNetworkPermission: iOSNSLocalNetworkUsageDescription.bonjourServices: service types forstartServiceDiscovery(), added toNSBonjourServicesnext to_munimwifi._tcp(used byrequestLocalNetworkPermission()).android.locationOnAndroid13Plus(defaulttrue): keepACCESS_FINE_LOCATIONon Android 13+ sogetCurrentNetwork()can read the connected SSID/BSSID. Setfalseto cap location at API 32; scanning then needs only Nearby Wi-Fi Devices.android.neverForLocation(defaulttrue): declareNEARBY_WIFI_DEVICESwithusesPermissionFlags="neverForLocation". Setfalseonly if your app derives physical location from Wi-Fi scans (scans then also need location on Android 13+).android.localNetworkPermission(defaulttrue): keep Android 17'sACCESS_LOCAL_NETWORK. Setfalseto strip it from the merged manifest, for example when you only useshowPickerdiscovery.
Xcode 27 / iOS 27: apps built with Xcode 27 crash at launch on iOS 27 unless they adopt the UIScene lifecycle (expo/expo#46664). This is an app setting, not a munim-wifi change: on Expo 57 use
expo57.0.23 or newer, runnpx expo install expo-build-properties, and add["expo-build-properties", { "ios": { "enableSceneSupport": true } }]to your plugins (the example app does this).
Nitro + Xcode 27 on iOS 17 and older: any Nitro module built with Xcode 27 can crash at launch on iOS versions below 18 (
std::exception_ptr::__from_native_exception_pointermissing; margelo/nitro#1652, fix pending in #1666). It comes fromreact-native-nitro-modules, not munim-wifi. Until a fixed Nitro release ships, build with Xcode 26 if you support iOS 15–17, or apply the patch from that PR withpatch-package.
Android build defaults: the library compiles against
compileSdk37 and targets 36 when your root project does not set them, and builds with Kotlin 2.2 and AGP 9.2 (React Native 0.87). Apps that still compile against API 36 (Expo SDK 57, React Native 0.86) build too; the Android 17 features then need an Android 17 device at runtime.
Generate or rebuild native projects after changing the plugin configuration:
npx expo prebuild
npx expo run:ios
# or
npx expo run:androidiOS Setup
Bare React Native apps must enable these capabilities in Xcode:
- Access Wi-Fi Information
- Hotspot Configuration
Add a location usage message to Info.plist:
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app uses location permission to access Wi-Fi information.</string>Android Setup
The library manifest already declares what it needs and is merged into your app:
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />ACCESS_LOCAL_NETWORK is Android 17's (API 37) local network permission. Apps that target API 37 need it for NSD/mDNS service discovery and for TCP/UDP to LAN addresses; older Android versions ignore it. See Android 17 local network permission.
Location is capped at API 32 because Android 13+ gates scans with NEARBY_WIFI_DEVICES instead. If your app reads the connected network's SSID/BSSID (getCurrentNetwork(), getNetworkSuggestionStatus()'s active state) or uses suggestion connection events on Android 13+, lift the cap in your app manifest (add xmlns:tools="http://schemas.android.com/tools" to <manifest>):
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
tools:remove="android:maxSdkVersion" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"
tools:remove="android:maxSdkVersion" />If your app derives physical location from Wi-Fi scans, also drop the neverForLocation assertion (scans then need location as well as Nearby Wi-Fi Devices on 13+):
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"
tools:remove="android:usesPermissionFlags" />The Expo config plugin does this for you (see its android options above).
Permissions and OS Behavior
iOS current-network access
Apple's NEHotspotNetwork.fetchCurrent() returns a network only when the app has the Access Wi-Fi Information entitlement and meets at least one qualifying condition, such as precise-location authorization, a network configured by the app, an active VPN configuration, or an active DNS settings configuration. See Apple's fetchCurrent documentation.
disconnect() can remove only a Wi-Fi configuration created by the app. It cannot remove or force-disconnect a network configured by the user or another app.
Which permission each API needs
requestWifiPermission() asks for what scanning needs on the running OS (Nearby Wi-Fi Devices on Android 13+, precise location below; plus location on 13+ when your manifest declares it). On Android 17+ it also asks for ACCESS_LOCAL_NETWORK, which shares the Nearby devices dialog. Everything else is checked at call time.
| API | Android 13+ (API 33+) | Android 10–12L (API 29–32) | Android 9 and below | iOS |
| --- | --- | --- | --- | --- |
| scanNetworks, startScan, getSSIDs, getWifiFingerprint, getRSSI, getBSSID, getChannelInfo, getNetworkInfo | NEARBY_WIFI_DEVICES (plus ACCESS_FINE_LOCATION if the app did not declare neverForLocation) | ACCESS_FINE_LOCATION | ACCESS_FINE_LOCATION or ACCESS_COARSE_LOCATION | Access Wi-Fi Information entitlement + location (current network only) |
| getCurrentNetwork, CurrentNetworkInfo in diagnostics | ACCESS_FINE_LOCATION (the SSID/BSSID is redacted without it) | ACCESS_FINE_LOCATION | location | entitlement + location, or a network this app configured |
| getIPAddress, getIPAddresses, getNetworkDiagnostics, observer, isInternetReachable | none | none | none | none |
| connectToNetwork, requestLocalNetwork | NEARBY_WIFI_DEVICES | none | none (legacy WifiManager) | Hotspot Configuration entitlement |
| configureNetwork, suggestions | none (CHANGE_WIFI_STATE) | none | unsupported | Hotspot Configuration entitlement |
| Suggestion connection events | ACCESS_FINE_LOCATION | ACCESS_FINE_LOCATION | unsupported | unsupported |
| startLocalOnlyHotspot | NEARBY_WIFI_DEVICES (plus location without neverForLocation) | ACCESS_FINE_LOCATION | ACCESS_FINE_LOCATION | unsupported |
| startServiceDiscovery | none (Android 17+, target API 37+: ACCESS_LOCAL_NETWORK, or showPicker: true) | none | none | Local Network permission + NSBonjourServices entry |
| isInternetReachable({ probeUrl }) to a LAN address | none (Android 17+, target API 37+: ACCESS_LOCAL_NETWORK) | none | none | Local Network permission |
See Android Wi-Fi permissions.
Android 17 local network permission
Android 17 (API 37) protects the local network: apps that target API 37 need the runtime ACCESS_LOCAL_NETWORK permission (Nearby devices group) for NSD/mDNS, multicast, and TCP/UDP to LAN addresses (DNS on port 53 is exempt). Blocked UDP fails with EPERM and blocked TCP connections time out, which looks like a dead device unless you know about it. munim-wifi:
- declares the permission in its manifest and requests it from
requestWifiPermission()andrequestLocalNetworkPermission()on Android 17+; - reports it as
getWifiCapabilityStatus().localNetworkPermission('unavailable'below Android 17, or when your app targets API 36 or lower, because nothing needs to be granted); - turns the failures into a clear error: service discovery calls
onErrorand LANisInternetReachable({ probeUrl })probes reject with a message containinglocal network permission denied. Test withisLocalNetworkPermissionError(error), which also matches iOS Local Network denials; - offers
showPicker: trueforstartServiceDiscovery(): the system picker lists services and only the ones the user selects are reported, with no permission needed.
On Android 16 you can try the restriction early with adb shell am compat enable RESTRICT_LOCAL_NETWORK <package>. See Android's local network permission guide.
Android scan throttling
Android throttles WifiManager.startScan() (foreground apps: 4 scans every 2 minutes). When a continuous-scan request is refused, the cached batch is still delivered but flagged: addNetworksFoundListener((networks, info) => …) receives info.throttled === true and info.fresh === false, and addScanThrottledListener fires. scanNetworks({ allowCached: false }) rejects instead of resolving with cached results. Each WifiNetwork.timestamp is when the radio last saw that network, so stale entries show their age.
Android process binding
Android 10+ connections use WifiNetworkSpecifier. The OS presents a system approval flow and may create a local-only connection. connectToNetwork() binds the app process to the approved network; when that network is lost or disconnect() is called, the binding the app had before is restored.
⚡ Quick Start
Basic Usage - Scan Networks
import {
getCurrentNetwork,
isWifiEnabled,
requestWifiPermission,
scanNetworks,
} from 'munim-wifi'
const enabled = await isWifiEnabled()
if (!enabled) throw new Error('Wi-Fi is unavailable')
const hasPermission = await requestWifiPermission()
if (!hasPermission) throw new Error('Wi-Fi permission was not granted')
const [current, networks] = await Promise.all([
getCurrentNetwork(),
scanNetworks({ maxResults: 30, timeout: 10_000 }),
])
console.log('Current network:', current)
networks.forEach((network) => {
console.log(network.ssid, network.bssid, network.rssi, network.channel)
})Continuous Scanning
import {
addNetworksFoundListener,
addScanErrorListener,
startScan,
stopScan,
} from 'munim-wifi'
const removeResults = addNetworksFoundListener((networks, info) => {
// info.throttled: Android refused a new scan; these are cached results.
console.log(info.fresh ? 'Fresh scan' : 'Cached results', networks)
})
const removeError = addScanErrorListener(console.warn)
startScan({ interval: 30_000, maxResults: 30 })
// Later:
stopScan()
removeResults()
removeError()Connect to a Network
import { connectToNetwork, disconnect } from 'munim-wifi'
await connectToNetwork({
ssid: 'Workshop Wi-Fi',
password: 'correct-horse-battery-staple',
timeout: 30_000,
})
// Release/remove the app-managed connection later.
const released = await disconnect() // false when nothing app-owned was removedAndroid 10+ and iOS both show system-controlled approval UI. WEP is unsupported on Android 10+.
🔧 API Reference
Discovery Functions
isWifiEnabled()
- Android: whether the Wi-Fi radio is on (
WifiManager.isWifiEnabled). - iOS: whether a Wi-Fi interface currently offers a usable path (
NWPathMonitor(requiredInterfaceType: .wifi)). Apple has no public radio-state API, so Wi-Fi switched on but not joined to any network reportsfalse. This does not need location permission.
Returns: Promise<boolean>
requestWifiPermission()
Prompts natively (no PermissionsAndroid needed):
- Android 13+:
NEARBY_WIFI_DEVICES, plus location when the app manifest declares it for this API level. - Android 12L and below:
ACCESS_FINE_LOCATION+ACCESS_COARSE_LOCATION. - iOS: When-In-Use location when it has not been determined.
Returns: Promise<boolean> — true when Wi-Fi scanning (Android) or current-network access (iOS) is permitted afterwards. Check getWifiCapabilityStatus().locationPermission for the location grant on Android 13+.
scanNetworks(options?)
Runs one Android scan or one iOS current-network lookup.
Parameters:
maxResults?(number): Positive integer result limit.timeout?(number): Android timeout from 250 to 30,000 milliseconds.allowCached?(boolean): Android. When the OS refuses or fails a fresh scan, resolve with cached results (true, default) or reject (false).
Returns: Promise<WifiNetwork[]>
startScan(options?)
Starts repeated Android scans. On iOS, emits one current-network result because general scanning is unavailable.
Parameters:
maxResults?(number): Positive integer result limit.interval?(number): 10,000 to 600,000 milliseconds. Defaults to 30,000.timeout?(number): Validation-compatible one-shot timeout value.
Use addNetworksFoundListener(), addNetworkFoundListener(), addScanThrottledListener(), or addScanErrorListener() before starting. Batch listeners receive (networks, info: ScanResultInfo) where info is { fresh, throttled, message? }.
stopScan()
Stops continuous Android scanning and releases its broadcast receiver.
getSSIDs()
Returns visible SSIDs from the current scan information.
Returns: Promise<string[]>
getWifiFingerprint()
Returns visible/current networks and a millisecond timestamp.
Returns: Promise<WifiFingerprint>
Network Information Functions
getRSSI(ssid)
Returns Android signal strength in dBm or null. iOS returns null.
getBSSID(ssid)
Returns the BSSID matching an SSID or null.
getChannelInfo(ssid)
Returns Android { channel, frequency } data or null. iOS returns null.
getNetworkInfo(ssid)
Returns complete information for the first matching SSID or null.
getCurrentNetwork()
Returns CurrentNetworkInfo or null. Depending on the platform, it can contain SSID, BSSID, IP address, subnet mask, gateway, and DNS servers.
getIPAddress()
Returns the Wi-Fi interface's IPv4 address or null. Android reads it from the Wi-Fi network's LinkProperties, so no location permission is needed.
getIPAddresses()
Returns { interfaceName?, ipv4: string[], ipv6: string[] } for the Wi-Fi interface, or null. IPv6 lists global and unique-local addresses first and link-local (fe80::…%en0) last, so IPv6-only networks are covered. CurrentNetworkInfo.ipv6Addresses carries the same IPv6 list.
Connection Functions
connectToNetwork(options)
Starts the native connection flow.
Parameters:
ssid(string): Required network name, limited to 32 UTF-8 bytes.password?(string): WPA/WPA2 or WEP password, limited to 64 UTF-8 bytes.isWEP?(boolean): Legacy WEP mode; unsupported on Android 10+.security?(WifiSecurityType): Optional explicit security type. When omitted, security is inferred frompassword/isWEP(open without a password, WPA2 with one). Pass'wpa3'to allow short SAE passphrases and usesetWpa3Passphraseon Android.bssid?(string): Optional Android 10+ BSSID constraint.joinOnce?(boolean): Temporary connection behavior. Defaults totrue; set explicitly tofalseto retain a saved configuration where supported.timeout?(number): Connection timeout from 5,000 to 120,000 milliseconds on iOS and Android.
Returns: Promise<void>
iOS settles each attempt once, verifies the resulting SSID when public APIs expose it, and removes only a newly created persistent configuration after a failed or timed-out attempt. Existing saved configurations are preserved.
disconnect()
Android releases the requested network and restores the previous process binding. iOS removes the app-created configuration for the current SSID (or the SSID this app last joined when the current network cannot be read).
Returns: Promise<boolean> — true when something owned by this app was released; false when there was nothing to remove (for example on iOS when the current network was configured by the user).
Structured Connection Functions
These functions never reject on ordinary platform limitations — they resolve
with a structured outcome whose status explains what happened
('connected' | 'configured' | 'presented' | 'released' | 'unsupported' | 'cancelled' | 'failed').
requestLocalNetwork(options)
Requests a temporary, app-scoped connection to a nearby network.
- Android 10+:
WifiNetworkSpecifier+ConnectivityManager.requestNetwork. SetbindProcess: trueto route this process's traffic through the network. The returnedleaseIdreleases the request later. - iOS:
NEHotspotConfigurationwithjoinOnce: true. The returnedleaseIdis the SSID.
Parameters: { ssid, security, bssid?, timeout?, bindProcess?, ssidPrefix? } where security is one of
{ type: 'open' } | { type: 'owe' } | { type: 'wep', passphrase } | { type: 'wpa2', passphrase } | { type: 'wpa3', passphrase } | { type: 'enterprise', eap } | { type: 'passpoint', passpoint, eap }.
ssidPrefix: truejoins the first network whose SSID starts withssid(iOS 13+NEHotspotConfiguration(ssidPrefix:)for open/WEP/WPA personal; AndroidsetSsidPatternwithPATTERN_PREFIX).- Enterprise works on both platforms (Android through
setWpa2EnterpriseConfig/setWpa3Enterprise…); Passpoint isunsupportedfor Android local requests (use suggestions).
await requestLocalNetwork({
ssid: 'Corp',
security: {
type: 'enterprise',
eap: {
method: 'peap',
phase2: 'mschapv2',
identity: '[email protected]',
password: 'secret',
serverDomain: 'radius.example.com',
caCertificates: [caPemOrBase64Der],
},
},
})EnterpriseCredentials: method ('peap' | 'ttls' | 'tls' | 'fast' | 'pwd' | 'sim' | 'aka' | 'akaPrime'), phase2? ('none' | 'pap' | 'chap' | 'mschap' | 'mschapv2' | 'gtc' | 'eap'), identity?, anonymousIdentity?, password?, serverDomain? (Android domainSuffixMatch, iOS trusted server name), trustedServerNames? (iOS), caCertificates? (base64 DER or PEM), clientCertificate? (base64 PKCS#12, required for TLS) + clientCertificatePassword?, wpa3? (Android). iOS supports peap/ttls/tls/fast and imports certificates/identities into the app keychain (as NEHotspotEAPSettings requires). Android supports peap/ttls/tls/pwd/sim/aka/akaPrime; Android 11+ rejects enterprise suggestions without server validation (caCertificates + serverDomain).
PasspointConfig: domainName, friendlyName?, realm?, naiRealmNames?, roamingConsortiumOIs? (hex), mccAndMncs?, roamingEnabled? (iOS). For Passpoint the ssid is only an identifier; the configuration ID is the domain name. Android Passpoint credentials: TTLS (username/password), TLS (PKCS#12) or SIM/AKA (mccAndMncs required).
Returns: Promise<ConnectionOutcome> — status: 'connected' on success with mode: 'localNetwork'.
configureNetwork(options)
Persists a network configuration.
- iOS: persistent
NEHotspotConfiguration(configurationIdis the SSID, or the domain for Passpoint). SupportsssidPrefix, enterprise and Passpoint. - Android 10+: routed through a network suggestion, since Android has no direct managed-configuration equivalent.
ssidPrefixresolvesunsupported.
Returns: Promise<ConnectionOutcome> — status: 'configured' with mode: 'managedConfiguration'.
requestUserSavedNetwork(options?)
- Android 10+: opens the system Wi-Fi panel (or the Android 11+ "add networks" flow when options are given) and resolves
status: 'presented'. - iOS: resolves
status: 'unsupported'.
releaseConnection(leaseOrConfigurationId)
Releases a requestLocalNetwork lease (Android unregisters the callback and restores the previous process binding) or removes a configuration (iOS removeConfiguration(forSSID:) and, for Passpoint, removeConfiguration(forHS20DomainName:)). Resolves status: 'released'.
getConfiguredSSIDs()
iOS: NEHotspotConfigurationManager.getConfiguredSSIDs() (configurations this app created). Android: SSIDs (or Passpoint domains) of this app's network suggestions.
Returns: Promise<string[]>
addNetworkSuggestion(options) / removeNetworkSuggestion(options) / getNetworkSuggestionStatus(options)
Android 10+ WifiManager network suggestions with open, owe, wpa2, wpa3, enterprise and (Android 11+) passpoint support plus hidden and appInteractionRequired flags. Status values include 'added' | 'alreadyExists' | 'removed' | 'notFound' | 'active' | 'inactive'. On iOS these resolve status: 'unsupported' — NEHotspotConfiguration (configureNetwork) is the closest analog.
Returns: Promise<SuggestionOutcome>
addSuggestionConnectionListener(callback)
Android 10+: callback({ type, ssid?, failureReason?, message? }) for
'postConnection' (ACTION_WIFI_NETWORK_SUGGESTION_POST_CONNECTION; only for suggestions added with appInteractionRequired: true) and, on Android 11+, 'connectionFailure' with failureReason 'association' | 'authentication' | 'ipProvisioning' | 'unknown' (addSuggestionConnectionStatusListener). Both need ACCESS_FINE_LOCATION; an 'error' event says so when it is missing.
Returns: an unsubscribe function, or null on iOS and Android 9 and below.
startLocalOnlyHotspot() / stopLocalOnlyHotspot(reservationId)
Android 8+ local-only hotspot (requires Nearby Wi-Fi Devices on Android 13+, location below). The outcome carries the generated ssid, passphrase, and securityType. iOS resolves status: 'unsupported'.
Returns: Promise<HotspotOutcome>
Capability and Diagnostics Functions
getWifiCapabilityStatus()
Reports per-capability availability (scan, localNetworkRequest, managedConfiguration, networkSuggestions, userSavedNetworkIntent, localOnlyHotspot, wifiDirect, wifiAware, wifiRtt) and permission states (locationPermission, nearbyWifiPermission, wifiInformationPermission, localNetworkPermission) for the current OS version.
localNetworkPermission is Android 17's ACCESS_LOCAL_NETWORK ('unavailable' when the OS does not gate local network access for the app). iOS cannot query Local Network access, so it is the last outcome seen in this launch (requestLocalNetworkPermission(), a Bonjour browse, or a LAN probe), or 'notDetermined' before any.
Returns: Promise<WifiCapabilityStatus>
getNetworkDiagnostics()
One-shot snapshot of the default network.
- Android:
NetworkCapabilities(validated,captivePortal,metered,constrained) plusLinkProperties(interface, addresses, DNS servers, routes, MTU). - iOS:
NWPathMonitor(meteredfromisExpensive,constrainedfromisConstrained);validated/captivePortalare not detectable and stay unset.
Returns: Promise<NetworkDiagnostics>
isInternetReachable(options?)
- Android: the default network has
NET_CAPABILITY_INTERNETandNET_CAPABILITY_VALIDATEDand is not a captive portal. - iOS: the default
NWPathis satisfied. - With
{ probeUrl, timeout? }the answer is an HTTP GET over that network instead (redirects are not followed, only 2xx counts, so captive portals reportfalse). Use an https endpoint that returns 204, such ashttps://www.google.com/generate_204; Android blocks cleartexthttpunless your network security config allows it. - A probe to a LAN address that local network protection blocks (Android 17 without
ACCESS_LOCAL_NETWORK, or iOS with Local Network access off) rejects with alocal network permission deniederror instead of resolvingfalse.
Returns: Promise<boolean>
startNetworkObserver(callback) / stopNetworkObserver() / addNetworkObserverListener(callback)
Continuous NetworkDiagnostics updates as the default network appears, changes capabilities, or is lost (state: 'available' | 'lost' | 'unavailable'). addNetworkObserverListener multiplexes many JS listeners over one native observer and returns a cleanup function.
addWifiStateListener(callback) / startWifiStateObserver(callback) / stopWifiStateObserver()
Wi-Fi on/off events: { enabled, state, timestamp }, where state is 'enabled' | 'enabling' | 'disabled' | 'disabling' | 'unknown'. The current state arrives first, then each change.
const unsubscribe = addWifiStateListener(({ enabled, state }) => console.log('Wi-Fi', state, enabled))- Android 16+:
WifiManager.addWifiStateChangedListener; Android 15 and below: theWIFI_STATE_CHANGED_ACTIONbroadcast. - iOS has no radio-state API, so this follows whether a Wi-Fi path is usable (
NWPathMonitor), the same signal asisWifiEnabled(): only'enabled'/'disabled', and Wi-Fi that is on but not joined reports'disabled'. addWifiStateListenermultiplexes JS listeners over one native observer (late subscribers get the last state) and returns a cleanup function.
Local Network Functions
startServiceDiscovery(type, handlers, options?)
Browses for DNS-SD (Bonjour/mDNS) services such as '_http._tcp'.
const discovery = startServiceDiscovery('_http._tcp', {
onFound: (service) => console.log(service.name, service.host, service.port, txtRecordToObject(service.txt)),
onLost: (service) => console.log('gone', service.id),
onError: console.warn,
})
// Later:
discovery.stop() // or stopServiceDiscovery(discovery.id)options:domain?(default'local.'),resolve?(defaulttrue),resolveTimeout?(1,000–30,000 ms, default 5,000),showPicker?(defaultfalse; Android 17+ opens the system service picker viaDiscoveryRequest.FLAG_SHOW_PICKER, needs no local network permission, and reports only the services the user selects; ignored on iOS and older Android).DiscoveredService:id(name.type.domain, the same for found/lost),name,type,domain,host?,port?,addresses,txt({ key, value? }[]),interfaceName?,resolved. A service is re-reported throughonFoundwhen its TXT record changes.- Android:
NsdManager; services are resolved one at a time with a timeout. On Android 17+ apps targeting API 37 needACCESS_LOCAL_NETWORK(orshowPicker); otherwiseonErrorgets alocal network permission deniedmessage. - iOS:
NWBrowser; TXT comes from the browse result, host/port from a short-livedNWConnectionto the service (opened and cancelled immediately). The type must be listed inNSBonjourServicesandNSLocalNetworkUsageDescriptionmust be set (config plugin:bonjourServices,localNetworkPermission), otherwiseonErrorreports a policy denial.
requestLocalNetworkPermission(timeoutMs?)
iOS has no API to read the Local Network permission. This publishes and browses a private _munimwifi._tcp service (declared by the config plugin; bare apps add it to NSBonjourServices), which shows the prompt the first time. Resolves 'granted' when the browse sees the service, 'denied' when access is refused (judged after the alert is dismissed), or 'notDetermined' if nothing is decided within timeoutMs (default 30,000).
Android 17+ (apps targeting API 37+): requests ACCESS_LOCAL_NETWORK and resolves 'granted' or 'denied'. Older Android versions, and apps targeting API 36 or lower, need no permission and resolve 'granted'.
isLocalNetworkPermissionError(error)
true when an error (or an onError message string) means local network access is blocked: Android 17 without ACCESS_LOCAL_NETWORK, or iOS Local Network privacy off. Matches the LOCAL_NETWORK_PERMISSION_DENIED phrase ('local network permission denied').
Returns: Promise<PermissionState>
Events
| API/event | Payload | Notes |
| --- | --- | --- |
| addNetworkFoundListener(callback) | WifiNetwork | Called once for every network in a result batch. |
| addNetworksFoundListener(callback) | WifiNetwork[], ScanResultInfo | Called once per continuous result batch; info.fresh/info.throttled say whether the batch is new. |
| addScanThrottledListener(callback) | ScanResultInfo | Android refused a scan request; the cached batch follows. |
| addScanErrorListener(callback) | string | Continuous-scan error message. |
| addEventListener('networkFound', callback) | WifiNetwork | Generic listener alias. |
| addEventListener('networksFound', callback) | WifiNetwork[] | Generic listener alias. |
| addEventListener('scanError', callback) | string | Generic listener alias. |
| addEventListener('scanThrottled', callback) | ScanResultInfo | Generic listener alias. |
| addWifiStateListener(callback) | WifiStateEvent | Wi-Fi turned on/off; current state first. |
Each listener function returns a cleanup function. addListener() and removeListeners() remain deprecated compatibility shims.
Types
type WifiSecurityType =
| 'open'
| 'owe'
| 'wep'
| 'wpa2'
| 'wpa3'
| 'enterprise'
| 'passpoint'
| 'unknown'
interface WifiNetwork {
ssid: string
bssid: string
rssi?: number
frequency?: number
channel?: number
capabilities?: string
isSecure?: boolean
securityType: WifiSecurityType
timestamp?: number // when the radio last saw it (Android ScanResult.timestamp)
}
interface ScanResultInfo {
fresh: boolean
throttled: boolean
message?: string
}
interface IPAddressInfo {
interfaceName?: string
ipv4: string[]
ipv6: string[]
}
interface CurrentNetworkInfo {
ssid: string
bssid: string
securityType: WifiSecurityType
ipAddress?: string
ipv6Addresses?: string[]
subnetMask?: string
gateway?: string
dnsServers?: string[]
}
interface WifiFingerprint {
networks: WifiNetwork[]
timestamp: number
location?: { latitude?: number; longitude?: number }
}
interface ConnectionOutcome {
status: ConnectionStatus // 'connected' | 'configured' | 'presented' | 'released' | 'unsupported' | 'cancelled' | 'failed'
mode: ConnectionMode // 'localNetwork' | 'managedConfiguration' | 'userSavedNetwork'
ssid?: string
leaseId?: string
configurationId?: string
boundProcess: boolean
message?: string
}
interface SuggestionOutcome {
status: SuggestionStatus
suggestionId?: string
message?: string
}
interface HotspotOutcome {
status: HotspotStatus // 'started' | 'stopped' | 'unsupported' | 'failed'
reservationId?: string
ssid?: string
passphrase?: string
securityType: WifiSecurityType
message?: string
}
interface NetworkDiagnostics {
timestamp: number
state: NetworkState // 'available' | 'lost' | 'unavailable'
validated?: boolean
captivePortal?: boolean
metered?: boolean
constrained?: boolean
currentNetwork?: CurrentNetworkInfo
linkProperties?: NetworkLinkProperties
}All public result, option, callback, and HybridObject types are exported from the package.
📖 Usage Examples
Wi-Fi Fingerprint
import { getWifiFingerprint, requestWifiPermission } from 'munim-wifi'
if (await requestWifiPermission()) {
const fingerprint = await getWifiFingerprint()
console.log('Captured at', new Date(fingerprint.timestamp))
fingerprint.networks.forEach(({ ssid, bssid, rssi }) => {
console.log(ssid, bssid, rssi)
})
}React Network Scanner
import { useEffect, useState } from 'react'
import { Button, FlatList, Text, View } from 'react-native'
import {
requestWifiPermission,
scanNetworks,
type WifiNetwork,
} from 'munim-wifi'
export function NetworkScanner() {
const [networks, setNetworks] = useState<WifiNetwork[]>([])
const [message, setMessage] = useState('Ready')
const scan = async () => {
try {
if (!(await requestWifiPermission())) {
setMessage('Permission denied')
return
}
setNetworks(await scanNetworks({ maxResults: 50, timeout: 10_000 }))
setMessage('Scan complete')
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error))
}
}
useEffect(() => () => setNetworks([]), [])
return (
<View>
<Button title="Scan Wi-Fi" onPress={scan} />
<Text>{message}</Text>
<FlatList
data={networks}
keyExtractor={(network) => network.bssid || network.ssid}
renderItem={({ item }) => (
<Text>{item.ssid}: {item.rssi ?? '—'} dBm</Text>
)}
/>
</View>
)
}🔍 Troubleshooting
Common Issues
- The scan returns no Android networks: Confirm Wi-Fi is enabled and
requestWifiPermission()resolvedtrue(Nearby Wi-Fi Devices on 13+, precise location and device Location Services below). Android also throttles repeated scans — checkinfo.throttled. - Android 13+ connection throws a permission error: Request Nearby Wi-Fi Devices permission with
requestWifiPermission()before connecting. getCurrentNetwork()isnullon Android 13+: reading the connected SSID still needsACCESS_FINE_LOCATION; keep it declared without the API-32 cap (see Android Setup).- Android 17 service discovery fails or LAN devices time out: apps targeting API 37 need
ACCESS_LOCAL_NETWORK. CallrequestLocalNetworkPermission()(orrequestWifiPermission()), or browse withshowPicker: true.isLocalNetworkPermissionError(error)identifies these failures. - iOS service discovery reports a policy denial: add the type to
NSBonjourServices, setNSLocalNetworkUsageDescription, and allow Local Network access (Settings › Privacy & Security › Local Network). - iOS returns
nullfor the current network: Verify the Access Wi-Fi Information entitlement, precise-location authorization, and Apple'sfetchCurrent()eligibility conditions. - iOS returns no RSSI/channel/frequency: Those values are not exposed to ordinary iOS apps. This is expected.
- The Android connection cannot reach a local device: Keep the connection active and do not call
disconnect()until local traffic is finished; the package binds the app process to the approved network. - WEP fails on modern Android:
WifiNetworkSpecifierdoes not support WEP. Use WPA2/WPA3 or an open network.
Expo-Specific Issues
- This package does not work in Expo Go; create a development build.
- Run
npx expo prebuild --cleanafter changing plugin options or upgrading the package. - If iOS capabilities are missing, inspect the generated
.entitlementsfile after prebuild. - If Android permissions are missing, inspect the merged application manifest rather than only the library manifest.
Debug Mode
The example app in example/ requests permission, displays current-network information, scans, and renders native result fields. Run it with:
npm install
npm --workspace munim-wifi-example run prebuild
npm --workspace munim-wifi-example run ios
# or
npm --workspace munim-wifi-example run android👏 Contributing
See CONTRIBUTING.md for contribution guidelines. Before opening a pull request, run:
npm install
npm run codegen
npm run build
npm run typecheck:example
npm pack --dry-runDo not edit files in nitrogen/generated directly. Change src/specs/munim-wifi.nitro.ts and rerun npm run codegen.
Local release (maintainers)
Releases run locally and do not require GitHub Actions. On the configured maintainer Mac, npm run release:local reads the npm publishing token from macOS Keychain and the GitHub token from the authenticated GitHub CLI session, then runs semantic-release. The credentials are never stored in this repository.
Use npm run release:local -- --dry-run to verify the next release without publishing it.
📄 License
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.
