react-native-local-network-info 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/android/build.gradle +20 -0
- package/android/src/main/AndroidManifest.xml +8 -0
- package/android/src/main/java/com/localnetworkinfo/LocalNetworkInfoModule.kt +371 -0
- package/build/LocalNetworkInfo.types.d.ts +97 -0
- package/build/LocalNetworkInfo.types.d.ts.map +1 -0
- package/build/LocalNetworkInfo.types.js +2 -0
- package/build/LocalNetworkInfo.types.js.map +1 -0
- package/build/LocalNetworkInfoModule.d.ts +11 -0
- package/build/LocalNetworkInfoModule.d.ts.map +1 -0
- package/build/LocalNetworkInfoModule.js +4 -0
- package/build/LocalNetworkInfoModule.js.map +1 -0
- package/build/LocalNetworkInfoModule.web.d.ts +14 -0
- package/build/LocalNetworkInfoModule.web.d.ts.map +1 -0
- package/build/LocalNetworkInfoModule.web.js +27 -0
- package/build/LocalNetworkInfoModule.web.js.map +1 -0
- package/build/index.d.ts +43 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +48 -0
- package/build/index.js.map +1 -0
- package/build/useLocalIp.d.ts +18 -0
- package/build/useLocalIp.d.ts.map +1 -0
- package/build/useLocalIp.js +43 -0
- package/build/useLocalIp.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/LocalNetworkInfo.podspec +29 -0
- package/ios/LocalNetworkInfoModule.swift +241 -0
- package/package.json +61 -0
- package/src/LocalNetworkInfo.types.ts +110 -0
- package/src/LocalNetworkInfoModule.ts +17 -0
- package/src/LocalNetworkInfoModule.web.ts +35 -0
- package/src/index.ts +57 -0
- package/src/useLocalIp.ts +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# react-native-local-network-info
|
|
2
|
+
|
|
3
|
+
Read a React Native device's **own local IPv4 address** and whether it's **connected to WiFi** or **acting as a hotspot host** — with **live network-change events**.
|
|
4
|
+
|
|
5
|
+
> ℹ️ This library only reads **your own device's** network interfaces. It does **not** scan, probe, or connect to any other device on the network, and it needs no location permission.
|
|
6
|
+
|
|
7
|
+
Built with the [Expo Modules API](https://docs.expo.dev/modules/overview/), so it works in **both bare React Native and Expo** apps and supports the New Architecture out of the box.
|
|
8
|
+
|
|
9
|
+
## Why
|
|
10
|
+
|
|
11
|
+
`expo-network` / `WifiManager.getConnectionInfo()` only return the **WiFi station** IP — they report `0.0.0.0` when the device is the hotspot host. This module reads the device's network interfaces directly, so it returns a usable LAN IP whether the device is a WiFi client **or** the hotspot, and tells you which.
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- ✅ Device's own **local IPv4** on the active LAN interface.
|
|
16
|
+
- ✅ **Role**: `wifi`, `hotspot`, or `none`.
|
|
17
|
+
- ✅ **WiFi takes precedence** when both WiFi and hotspot are active at once.
|
|
18
|
+
- ✅ **Hotspot host** detection that handles Android 11+'s **randomized** SoftAP subnet (reads the real interface IP — never hardcodes `192.168.43.1`).
|
|
19
|
+
- ✅ **Predicted client IP range** when the device is a hotspot (iOS: fixed `172.20.10.2–.14`; Android: derived from the live subnet).
|
|
20
|
+
- ✅ **Live listener** that re-fires on every connectivity / WiFi / hotspot change.
|
|
21
|
+
- ✅ A `useLocalIp()` React hook.
|
|
22
|
+
- ✅ No location permission required.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
npm install react-native-local-network-info
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
> **Bare React Native:** you must also have the `expo` package installed so Expo Modules autolinking works. If you don't yet:
|
|
31
|
+
> ```sh
|
|
32
|
+
> npx install-expo-modules@latest
|
|
33
|
+
> ```
|
|
34
|
+
> Then `cd ios && pod install`. Expo apps need no extra steps.
|
|
35
|
+
|
|
36
|
+
### Requirements
|
|
37
|
+
|
|
38
|
+
| | Minimum |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| Expo SDK | 54+ (New Architecture) |
|
|
41
|
+
| iOS | 15.1 |
|
|
42
|
+
| Android | API 24 (Android 7.0) |
|
|
43
|
+
|
|
44
|
+
### Permissions
|
|
45
|
+
|
|
46
|
+
Android permissions are merged automatically via the module's manifest:
|
|
47
|
+
|
|
48
|
+
- `ACCESS_NETWORK_STATE` — for the change listener & station gateway.
|
|
49
|
+
- `ACCESS_WIFI_STATE` — for the DHCP gateway fallback.
|
|
50
|
+
|
|
51
|
+
No `ACCESS_FINE_LOCATION` and no iOS `Info.plist` keys are needed — the module only reads its **own** interfaces (it never connects to or scans other devices).
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import {
|
|
57
|
+
getLocalIp,
|
|
58
|
+
addNetworkChangeListener,
|
|
59
|
+
useLocalIp,
|
|
60
|
+
} from 'react-native-local-network-info';
|
|
61
|
+
|
|
62
|
+
// One-shot read
|
|
63
|
+
const info = await getLocalIp();
|
|
64
|
+
console.log(info.ip, info.role); // e.g. "192.168.1.42" "wifi"
|
|
65
|
+
|
|
66
|
+
// Live updates
|
|
67
|
+
const sub = addNetworkChangeListener((info) => {
|
|
68
|
+
console.log('network changed →', info.role, info.ip);
|
|
69
|
+
});
|
|
70
|
+
// later: sub.remove();
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### React hook
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
import { useLocalIp } from 'react-native-local-network-info';
|
|
77
|
+
|
|
78
|
+
function Status() {
|
|
79
|
+
const info = useLocalIp(); // null until first snapshot, then live
|
|
80
|
+
if (!info) return <Text>Detecting…</Text>;
|
|
81
|
+
return (
|
|
82
|
+
<Text>
|
|
83
|
+
{info.role === 'hotspot' ? 'Hosting hotspot at' : 'Local IP'}: {info.ip}
|
|
84
|
+
</Text>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## API
|
|
90
|
+
|
|
91
|
+
### `getLocalIp(): Promise<LocalIpInfo>`
|
|
92
|
+
|
|
93
|
+
Captures the current snapshot.
|
|
94
|
+
|
|
95
|
+
### `addNetworkChangeListener(cb): EventSubscription`
|
|
96
|
+
|
|
97
|
+
Subscribes to changes. Call `.remove()` on the returned subscription to unsubscribe.
|
|
98
|
+
|
|
99
|
+
### `getAllInterfaces(): Promise<NetworkInterfaceInfo[]>`
|
|
100
|
+
|
|
101
|
+
Every up, non-loopback IPv4 interface with a best-effort role — handy for debugging unusual devices.
|
|
102
|
+
|
|
103
|
+
### `useLocalIp(): LocalIpInfo | null`
|
|
104
|
+
|
|
105
|
+
Hook returning the latest snapshot, kept live and auto-cleaned on unmount.
|
|
106
|
+
|
|
107
|
+
### `LocalIpInfo`
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
interface LocalIpInfo {
|
|
111
|
+
ip: string | null; // device's own local IPv4 (wifi first, then hotspot)
|
|
112
|
+
role: 'wifi' | 'hotspot' | 'none'; // wifi wins when both are active
|
|
113
|
+
isWifiConnected: boolean;
|
|
114
|
+
isHotspotHost: boolean;
|
|
115
|
+
interfaceName: string | null; // "en0" | "wlan0" | "bridge100" | "ap0" | ...
|
|
116
|
+
netmask: string | null; // "255.255.255.0"
|
|
117
|
+
gateway: string | null; // see notes below
|
|
118
|
+
predictedClientRange: { first: string; last: string } | null; // hotspot only
|
|
119
|
+
platform: 'ios' | 'android' | 'web';
|
|
120
|
+
timestamp: number; // epoch ms
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## How detection works
|
|
125
|
+
|
|
126
|
+
| | iOS | Android |
|
|
127
|
+
| --- | --- | --- |
|
|
128
|
+
| Enumeration | `getifaddrs(3)` | `java.net.NetworkInterface` |
|
|
129
|
+
| WiFi station | `en0` | the interface `ConnectivityManager` reports for the `TRANSPORT_WIFI` network (usually `wlan0`) |
|
|
130
|
+
| Hotspot host | `bridge*` @ `172.20.10.1/28` | any WiFi-family interface (`wlan*`/`ap*`/`swlan*`/`softap*`) that is **not** the confirmed station — reads its live IP |
|
|
131
|
+
| Cellular (ignored for LAN IP) | `pdp_ip0` | `rmnet*` / `ccmni*` |
|
|
132
|
+
| Change events | `NWPathMonitor` | `registerDefaultNetworkCallback` + `WIFI_AP_STATE_CHANGED` |
|
|
133
|
+
| Station gateway | derived from subnet (heuristic) | `LinkProperties` default route (real) |
|
|
134
|
+
|
|
135
|
+
**Precedence:** if a WiFi-station IP exists it is returned with `role: 'wifi'`; otherwise the hotspot-host IP is returned with `role: 'hotspot'`; otherwise `role: 'none'` with `ip: null`.
|
|
136
|
+
|
|
137
|
+
## Platform notes & limitations
|
|
138
|
+
|
|
139
|
+
- **iOS station gateway is a heuristic** (first host of the subnet). Apple exposes no public default-gateway API; the WiFi IP, netmask, and role are accurate.
|
|
140
|
+
- **iOS hotspot change events:** `NWPathMonitor` reliably reports WiFi/cellular changes, but toggling Personal Hotspot while the default path is unchanged may not fire an event. Call `getLocalIp()` to force a fresh read when you need certainty.
|
|
141
|
+
- **Android hotspot vs. station** is resolved by asking `ConnectivityManager` which interface carries the real `TRANSPORT_WIFI` (station) network, then treating any *other* WiFi-family interface with an IP as the hotspot. This correctly handles phones that host the SoftAP on `wlan0` itself (not just `ap0`/`swlan0`), and devices running WiFi + hotspot concurrently. Use `getAllInterfaces()` to inspect an unusual device.
|
|
142
|
+
- **Web** returns `role: 'none'` / `ip: null` — browsers don't expose the LAN IP.
|
|
143
|
+
- iOS interface-name → role mapping is a documented **heuristic** (Apple exposes no public role API); the hotspot-host `172.20.10.1`/`255.255.255.240` fingerprint and `NetworkInterface` reads are the reliable signals.
|
|
144
|
+
|
|
145
|
+
## Example app
|
|
146
|
+
|
|
147
|
+
```sh
|
|
148
|
+
cd example
|
|
149
|
+
npm install
|
|
150
|
+
npx expo prebuild # generates ios/ and android/
|
|
151
|
+
npx expo run:ios # or: npx expo run:android
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Then toggle WiFi / your hotspot in Settings and watch the values update live.
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
plugins {
|
|
2
|
+
id 'com.android.library'
|
|
3
|
+
id 'expo-module-gradle-plugin'
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
group = 'com.localnetworkinfo'
|
|
7
|
+
version = '0.1.0'
|
|
8
|
+
|
|
9
|
+
// compileSdk (36), minSdk (24), targetSdk (36), Kotlin (2.0.21) and the
|
|
10
|
+
// JVM toolchain (17) are supplied by the `expo-module-gradle-plugin`.
|
|
11
|
+
android {
|
|
12
|
+
namespace 'com.localnetworkinfo'
|
|
13
|
+
defaultConfig {
|
|
14
|
+
versionCode 1
|
|
15
|
+
versionName '0.1.0'
|
|
16
|
+
}
|
|
17
|
+
lintOptions {
|
|
18
|
+
abortOnError false
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
2
|
+
<!-- Required for ConnectivityManager (network callbacks, LinkProperties). -->
|
|
3
|
+
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
4
|
+
<!-- Required for WifiManager (DHCP gateway fallback). -->
|
|
5
|
+
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
|
6
|
+
<!-- NOTE: ACCESS_FINE_LOCATION is intentionally NOT requested: IP/role/gateway
|
|
7
|
+
detection does not need it (only WiFi scanning / reading SSID would). -->
|
|
8
|
+
</manifest>
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
package com.localnetworkinfo
|
|
2
|
+
|
|
3
|
+
import android.content.BroadcastReceiver
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.Intent
|
|
6
|
+
import android.content.IntentFilter
|
|
7
|
+
import android.net.ConnectivityManager
|
|
8
|
+
import android.net.LinkProperties
|
|
9
|
+
import android.net.Network
|
|
10
|
+
import android.net.NetworkCapabilities
|
|
11
|
+
import android.os.Build
|
|
12
|
+
import android.os.Handler
|
|
13
|
+
import android.os.HandlerThread
|
|
14
|
+
import expo.modules.kotlin.exception.Exceptions
|
|
15
|
+
import expo.modules.kotlin.modules.Module
|
|
16
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
17
|
+
import java.net.Inet4Address
|
|
18
|
+
import java.net.NetworkInterface
|
|
19
|
+
|
|
20
|
+
class LocalNetworkInfoModule : Module() {
|
|
21
|
+
|
|
22
|
+
private val context: Context
|
|
23
|
+
get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
|
|
24
|
+
|
|
25
|
+
private val connectivityManager: ConnectivityManager
|
|
26
|
+
get() = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
27
|
+
|
|
28
|
+
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
|
29
|
+
private var apStateReceiver: BroadcastReceiver? = null
|
|
30
|
+
private var handlerThread: HandlerThread? = null
|
|
31
|
+
private var handler: Handler? = null
|
|
32
|
+
private val resampleRunnable = Runnable { doEmit() }
|
|
33
|
+
|
|
34
|
+
override fun definition() = ModuleDefinition {
|
|
35
|
+
Name("LocalNetworkInfo")
|
|
36
|
+
|
|
37
|
+
Events("onNetworkChange")
|
|
38
|
+
|
|
39
|
+
AsyncFunction("getLocalIpAsync") {
|
|
40
|
+
return@AsyncFunction buildInfo()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
AsyncFunction("getAllInterfacesAsync") {
|
|
44
|
+
return@AsyncFunction enumerateInterfaces().map { it.toMap() }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
OnStartObserving {
|
|
48
|
+
startMonitoring()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
OnStopObserving {
|
|
52
|
+
stopMonitoring()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
OnDestroy {
|
|
56
|
+
stopMonitoring()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// region Network change monitoring
|
|
61
|
+
|
|
62
|
+
private fun startMonitoring() {
|
|
63
|
+
if (handlerThread == null) {
|
|
64
|
+
val thread = HandlerThread("expo.localnetworkinfo.emit").apply { start() }
|
|
65
|
+
handler = Handler(thread.looper)
|
|
66
|
+
handlerThread = thread
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (networkCallback == null) {
|
|
70
|
+
val callback = object : ConnectivityManager.NetworkCallback() {
|
|
71
|
+
override fun onAvailable(network: Network) = emitChange()
|
|
72
|
+
override fun onLost(network: Network) = emitChange()
|
|
73
|
+
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) = emitChange()
|
|
74
|
+
override fun onLinkPropertiesChanged(network: Network, lp: LinkProperties) = emitChange()
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
// registerDefaultNetworkCallback is API 24+ (our minSdk is 24).
|
|
78
|
+
connectivityManager.registerDefaultNetworkCallback(callback)
|
|
79
|
+
networkCallback = callback
|
|
80
|
+
} catch (_: Exception) {
|
|
81
|
+
// Leave networkCallback null; the AP receiver below may still work.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (apStateReceiver == null) {
|
|
86
|
+
val receiver = object : BroadcastReceiver() {
|
|
87
|
+
override fun onReceive(receivedContext: Context?, intent: Intent?) = emitChange()
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
// Fired by the system whenever the WiFi hotspot is enabled/disabled.
|
|
91
|
+
// NetworkCallback does NOT cover the device's own SoftAP, so we need this.
|
|
92
|
+
val filter = IntentFilter("android.net.wifi.WIFI_AP_STATE_CHANGED")
|
|
93
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
94
|
+
context.applicationContext.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
|
95
|
+
} else {
|
|
96
|
+
@Suppress("UnspecifiedRegisterReceiverFlag")
|
|
97
|
+
context.applicationContext.registerReceiver(receiver, filter)
|
|
98
|
+
}
|
|
99
|
+
apStateReceiver = receiver
|
|
100
|
+
} catch (_: Exception) {
|
|
101
|
+
// Hotspot-toggle events will be missed, but everything else still works.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private fun stopMonitoring() {
|
|
107
|
+
handler?.removeCallbacks(resampleRunnable)
|
|
108
|
+
handlerThread?.quitSafely()
|
|
109
|
+
handlerThread = null
|
|
110
|
+
handler = null
|
|
111
|
+
|
|
112
|
+
networkCallback?.let {
|
|
113
|
+
try {
|
|
114
|
+
connectivityManager.unregisterNetworkCallback(it)
|
|
115
|
+
} catch (_: Exception) {
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
networkCallback = null
|
|
119
|
+
|
|
120
|
+
apStateReceiver?.let {
|
|
121
|
+
try {
|
|
122
|
+
context.applicationContext.unregisterReceiver(it)
|
|
123
|
+
} catch (_: Exception) {
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
apStateReceiver = null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private fun doEmit() {
|
|
130
|
+
try {
|
|
131
|
+
sendEvent("onNetworkChange", buildInfo())
|
|
132
|
+
} catch (_: Exception) {
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private fun emitChange() {
|
|
137
|
+
// Emit an immediate snapshot, then re-sample over the next few seconds. On
|
|
138
|
+
// Android the hotspot/station interface frequently receives its IPv4 a beat
|
|
139
|
+
// AFTER the change event fires, and no further event follows — so a single
|
|
140
|
+
// immediate snapshot can report "no connection" until the next manual
|
|
141
|
+
// refresh. The trailing re-samples catch the late address assignment.
|
|
142
|
+
doEmit()
|
|
143
|
+
handler?.let { h ->
|
|
144
|
+
h.removeCallbacks(resampleRunnable)
|
|
145
|
+
h.postDelayed(resampleRunnable, 700)
|
|
146
|
+
h.postDelayed(resampleRunnable, 1800)
|
|
147
|
+
h.postDelayed(resampleRunnable, 3500)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// endregion
|
|
152
|
+
|
|
153
|
+
// region Interface model
|
|
154
|
+
|
|
155
|
+
private enum class Role(val value: String) {
|
|
156
|
+
WIFI("wifi"),
|
|
157
|
+
HOTSPOT("hotspot"),
|
|
158
|
+
CELLULAR("cellular"),
|
|
159
|
+
ETHERNET("ethernet"),
|
|
160
|
+
OTHER("other")
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private data class Iface(
|
|
164
|
+
val name: String,
|
|
165
|
+
val ip: String,
|
|
166
|
+
val netmask: String?,
|
|
167
|
+
val prefixLength: Int,
|
|
168
|
+
val role: Role
|
|
169
|
+
) {
|
|
170
|
+
fun toMap(): Map<String, Any?> = mapOf(
|
|
171
|
+
"name" to name,
|
|
172
|
+
"ip" to ip,
|
|
173
|
+
"netmask" to netmask,
|
|
174
|
+
"role" to role.value
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Classify an interface, using ConnectivityManager to confirm which interface
|
|
180
|
+
* actually carries a WiFi *station* network. This is what lets us tell a
|
|
181
|
+
* hotspot apart from a station even when the device hosts its SoftAP on
|
|
182
|
+
* `wlan0` itself (common on single-radio phones) instead of a separate
|
|
183
|
+
* `ap0` / `wlan1` / `swlan0` / `softap0` interface.
|
|
184
|
+
*/
|
|
185
|
+
private fun classify(name: String, stationInterfaces: Set<String>): Role = when {
|
|
186
|
+
// Confirmed by the system to be a real WiFi station (client) connection.
|
|
187
|
+
name in stationInterfaces -> Role.WIFI
|
|
188
|
+
name.startsWith("rmnet") || name.startsWith("ccmni") -> Role.CELLULAR
|
|
189
|
+
name.startsWith("eth") -> Role.ETHERNET
|
|
190
|
+
// Any WiFi-family interface that is NOT a confirmed station is the hotspot/SoftAP.
|
|
191
|
+
name.startsWith("wlan") ||
|
|
192
|
+
name.startsWith("ap") ||
|
|
193
|
+
name.startsWith("swlan") ||
|
|
194
|
+
name.startsWith("softap") -> Role.HOTSPOT
|
|
195
|
+
else -> Role.OTHER
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Interface names ConnectivityManager reports as carrying a WiFi station network. */
|
|
199
|
+
private fun wifiStationInterfaceNames(): Set<String> {
|
|
200
|
+
val names = mutableSetOf<String>()
|
|
201
|
+
try {
|
|
202
|
+
@Suppress("DEPRECATION")
|
|
203
|
+
val networks = connectivityManager.allNetworks
|
|
204
|
+
for (network in networks) {
|
|
205
|
+
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: continue
|
|
206
|
+
if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) continue
|
|
207
|
+
connectivityManager.getLinkProperties(network)?.interfaceName?.let { names.add(it) }
|
|
208
|
+
}
|
|
209
|
+
} catch (_: Exception) {
|
|
210
|
+
}
|
|
211
|
+
return names
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// endregion
|
|
215
|
+
|
|
216
|
+
// region Detection
|
|
217
|
+
|
|
218
|
+
private fun enumerateInterfaces(): List<Iface> {
|
|
219
|
+
val result = mutableListOf<Iface>()
|
|
220
|
+
val stationInterfaces = wifiStationInterfaceNames()
|
|
221
|
+
|
|
222
|
+
val interfaces = try {
|
|
223
|
+
NetworkInterface.getNetworkInterfaces() ?: return result
|
|
224
|
+
} catch (_: Exception) {
|
|
225
|
+
return result
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (nif in interfaces) {
|
|
229
|
+
try {
|
|
230
|
+
if (!nif.isUp || nif.isLoopback) continue
|
|
231
|
+
} catch (_: Exception) {
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// interfaceAddresses gives us the prefix length (for netmask + prediction).
|
|
236
|
+
for (interfaceAddress in nif.interfaceAddresses) {
|
|
237
|
+
val address = interfaceAddress.address
|
|
238
|
+
if (address !is Inet4Address) continue
|
|
239
|
+
if (address.isLoopbackAddress || address.isLinkLocalAddress) continue
|
|
240
|
+
|
|
241
|
+
val ip = address.hostAddress ?: continue
|
|
242
|
+
val prefix = interfaceAddress.networkPrefixLength.toInt()
|
|
243
|
+
result.add(Iface(nif.name, ip, prefixToNetmask(prefix), prefix, classify(nif.name, stationInterfaces)))
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return result
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private fun buildInfo(): Map<String, Any?> {
|
|
251
|
+
val interfaces = enumerateInterfaces()
|
|
252
|
+
|
|
253
|
+
val station = interfaces.firstOrNull { it.role == Role.WIFI }
|
|
254
|
+
val host = interfaces.firstOrNull { it.role == Role.HOTSPOT }
|
|
255
|
+
|
|
256
|
+
val isWifiConnected = station != null
|
|
257
|
+
val isHotspotHost = host != null
|
|
258
|
+
|
|
259
|
+
var ip: String? = null
|
|
260
|
+
var role = "none"
|
|
261
|
+
var interfaceName: String? = null
|
|
262
|
+
var netmask: String? = null
|
|
263
|
+
var gateway: String? = null
|
|
264
|
+
var predicted: Map<String, String>? = null
|
|
265
|
+
|
|
266
|
+
if (station != null) {
|
|
267
|
+
ip = station.ip
|
|
268
|
+
role = "wifi"
|
|
269
|
+
interfaceName = station.name
|
|
270
|
+
netmask = station.netmask
|
|
271
|
+
// Prefer the real default-route gateway; fall back to a subnet guess.
|
|
272
|
+
gateway = stationGateway() ?: deriveGateway(station.ip, station.prefixLength)
|
|
273
|
+
} else if (host != null) {
|
|
274
|
+
ip = host.ip
|
|
275
|
+
role = "hotspot"
|
|
276
|
+
interfaceName = host.name
|
|
277
|
+
netmask = host.netmask
|
|
278
|
+
gateway = host.ip // the hotspot host is its own gateway
|
|
279
|
+
predicted = predictClientRange(host.ip, host.prefixLength)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return mapOf(
|
|
283
|
+
"ip" to ip,
|
|
284
|
+
"role" to role,
|
|
285
|
+
"isWifiConnected" to isWifiConnected,
|
|
286
|
+
"isHotspotHost" to isHotspotHost,
|
|
287
|
+
"interfaceName" to interfaceName,
|
|
288
|
+
"netmask" to netmask,
|
|
289
|
+
"gateway" to gateway,
|
|
290
|
+
"predictedClientRange" to predicted,
|
|
291
|
+
"platform" to "android",
|
|
292
|
+
"timestamp" to System.currentTimeMillis().toDouble()
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Real station gateway via LinkProperties (default route), with a DHCP fallback. */
|
|
297
|
+
private fun stationGateway(): String? {
|
|
298
|
+
return try {
|
|
299
|
+
val network = connectivityManager.activeNetwork ?: return null
|
|
300
|
+
val linkProperties = connectivityManager.getLinkProperties(network) ?: return null
|
|
301
|
+
// Ignore the 0.0.0.0 wildcard (appears as the "gateway" of a directly-connected
|
|
302
|
+
// default route, e.g. when the device is tethering rather than a real station).
|
|
303
|
+
val routeGateway = linkProperties.routes
|
|
304
|
+
.firstOrNull { it.isDefaultRoute && it.gateway?.isAnyLocalAddress == false }
|
|
305
|
+
?.gateway
|
|
306
|
+
?.hostAddress
|
|
307
|
+
if (routeGateway != null) return routeGateway
|
|
308
|
+
val dhcpServer = if (Build.VERSION.SDK_INT >= 30) linkProperties.dhcpServerAddress else null
|
|
309
|
+
if (dhcpServer != null && !dhcpServer.isAnyLocalAddress) dhcpServer.hostAddress else null
|
|
310
|
+
} catch (_: Exception) {
|
|
311
|
+
null
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// endregion
|
|
316
|
+
|
|
317
|
+
// region IPv4 math
|
|
318
|
+
|
|
319
|
+
private fun prefixToNetmask(prefix: Int): String? {
|
|
320
|
+
if (prefix < 0 || prefix > 32) return null
|
|
321
|
+
val mask = if (prefix == 0) 0 else (-1 shl (32 - prefix))
|
|
322
|
+
return "${(mask ushr 24) and 0xff}.${(mask ushr 16) and 0xff}.${(mask ushr 8) and 0xff}.${mask and 0xff}"
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private fun ipToInt(ip: String): Int? {
|
|
326
|
+
val parts = ip.split(".")
|
|
327
|
+
if (parts.size != 4) return null
|
|
328
|
+
var value = 0
|
|
329
|
+
for (part in parts) {
|
|
330
|
+
val octet = part.toIntOrNull() ?: return null
|
|
331
|
+
if (octet < 0 || octet > 255) return null
|
|
332
|
+
value = (value shl 8) or octet
|
|
333
|
+
}
|
|
334
|
+
return value
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private fun intToIp(value: Int): String =
|
|
338
|
+
"${(value ushr 24) and 0xff}.${(value ushr 16) and 0xff}.${(value ushr 8) and 0xff}.${value and 0xff}"
|
|
339
|
+
|
|
340
|
+
/** Subnet-guess gateway (first host) — only used as an Android station fallback. */
|
|
341
|
+
private fun deriveGateway(ip: String, prefix: Int): String? {
|
|
342
|
+
val ipInt = ipToInt(ip) ?: return null
|
|
343
|
+
if (prefix <= 0 || prefix >= 32) return null
|
|
344
|
+
val mask = -1 shl (32 - prefix)
|
|
345
|
+
val network = ipInt and mask
|
|
346
|
+
return intToIp(network + 1)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Predict the usable client IPv4 range of a hotspot subnet (network+1 ..
|
|
351
|
+
* broadcast-1). The host occupies one address within this range unless it is
|
|
352
|
+
* the first host, in which case the range starts at the next address.
|
|
353
|
+
*/
|
|
354
|
+
private fun predictClientRange(hostIp: String, prefix: Int): Map<String, String>? {
|
|
355
|
+
val ipInt = ipToInt(hostIp) ?: return null
|
|
356
|
+
if (prefix <= 0 || prefix >= 31) return null
|
|
357
|
+
val mask = -1 shl (32 - prefix)
|
|
358
|
+
val network = ipInt and mask
|
|
359
|
+
val broadcast = network or mask.inv()
|
|
360
|
+
|
|
361
|
+
var first = network + 1
|
|
362
|
+
if (first == ipInt) first += 1 // skip the host itself
|
|
363
|
+
val last = broadcast - 1
|
|
364
|
+
// Compare as unsigned so high-bit subnets (e.g. 192.168/172.16) order correctly.
|
|
365
|
+
if (Integer.compareUnsigned(last, first) < 0) return null
|
|
366
|
+
|
|
367
|
+
return mapOf("first" to intToIp(first), "last" to intToIp(last))
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// endregion
|
|
371
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which network role is currently providing the device's local IPv4.
|
|
3
|
+
*
|
|
4
|
+
* - `wifi` — the device is connected to a WiFi network as a station/client.
|
|
5
|
+
* - `hotspot` — the device is acting as a WiFi hotspot (SoftAP) host.
|
|
6
|
+
* - `none` — neither WiFi nor hotspot is providing a usable LAN address
|
|
7
|
+
* (e.g. cellular-only, airplane mode, or offline).
|
|
8
|
+
*
|
|
9
|
+
* When both WiFi and hotspot are active simultaneously, `wifi` takes precedence.
|
|
10
|
+
*/
|
|
11
|
+
export type NetworkRole = 'wifi' | 'hotspot' | 'none';
|
|
12
|
+
/** The platform that produced a given snapshot. */
|
|
13
|
+
export type DevicePlatform = 'ios' | 'android' | 'web';
|
|
14
|
+
/**
|
|
15
|
+
* The usable host range of the hotspot subnet — i.e. the addresses a client
|
|
16
|
+
* could be assigned. This is the whole subnet's usable span (network+1 ..
|
|
17
|
+
* broadcast-1), so the host/gateway itself is one address *within* this range
|
|
18
|
+
* (it is not a boundary). When scanning for clients, skip your own `ip`.
|
|
19
|
+
*
|
|
20
|
+
* Example: host/gateway `10.121.184.216` on a `/24` → range `10.121.184.1` ..
|
|
21
|
+
* `10.121.184.254` (a client may legitimately be `.215` *or* `.217`).
|
|
22
|
+
*/
|
|
23
|
+
export interface ClientIpRange {
|
|
24
|
+
/** First usable client IPv4 in the subnet. */
|
|
25
|
+
first: string;
|
|
26
|
+
/** Last usable client IPv4 in the subnet. */
|
|
27
|
+
last: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A point-in-time snapshot of the device's local network position.
|
|
31
|
+
*
|
|
32
|
+
* The same shape is returned by {@link getLocalIp} and delivered to
|
|
33
|
+
* {@link addNetworkChangeListener} / {@link useLocalIp}.
|
|
34
|
+
*/
|
|
35
|
+
export interface LocalIpInfo {
|
|
36
|
+
/**
|
|
37
|
+
* The device's own local IPv4 on the active LAN interface, or `null` when
|
|
38
|
+
* there is no WiFi/hotspot LAN address. Follows the precedence:
|
|
39
|
+
* WiFi station IP first, then hotspot host IP.
|
|
40
|
+
*/
|
|
41
|
+
ip: string | null;
|
|
42
|
+
/** Which role is providing {@link ip}. WiFi takes precedence over hotspot. */
|
|
43
|
+
role: NetworkRole;
|
|
44
|
+
/** `true` when connected to a WiFi network as a client/station. */
|
|
45
|
+
isWifiConnected: boolean;
|
|
46
|
+
/** `true` when this device is currently acting as a WiFi hotspot (SoftAP) host. */
|
|
47
|
+
isHotspotHost: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Name of the network interface backing {@link ip}
|
|
50
|
+
* (e.g. `en0`, `wlan0`, `bridge100`, `ap0`), or `null`.
|
|
51
|
+
*/
|
|
52
|
+
interfaceName: string | null;
|
|
53
|
+
/** Dotted IPv4 subnet mask for {@link ip} (e.g. `255.255.255.0`), or `null`. */
|
|
54
|
+
netmask: string | null;
|
|
55
|
+
/**
|
|
56
|
+
* The LAN gateway.
|
|
57
|
+
*
|
|
58
|
+
* - As a **hotspot host**, this is the device itself (it *is* the gateway).
|
|
59
|
+
* - As an **Android station**, this is the real default-route gateway
|
|
60
|
+
* (read from `LinkProperties`).
|
|
61
|
+
* - As an **iOS station**, this is a best-effort guess derived from the
|
|
62
|
+
* subnet (the first host address), because Apple exposes no public gateway
|
|
63
|
+
* API. Treat the iOS station gateway as a heuristic.
|
|
64
|
+
*/
|
|
65
|
+
gateway: string | null;
|
|
66
|
+
/**
|
|
67
|
+
* When this device is a hotspot host, the predicted inclusive range of IPs
|
|
68
|
+
* its clients can receive, derived from the host IP + subnet:
|
|
69
|
+
*
|
|
70
|
+
* - **iOS** — the fixed Personal Hotspot range `172.20.10.2`–`172.20.10.14`.
|
|
71
|
+
* - **Android** — computed from the (randomized on Android 11+) SoftAP subnet.
|
|
72
|
+
*
|
|
73
|
+
* `null` when the device is not a hotspot host.
|
|
74
|
+
*/
|
|
75
|
+
predictedClientRange: ClientIpRange | null;
|
|
76
|
+
/** Platform that captured this snapshot. */
|
|
77
|
+
platform: DevicePlatform;
|
|
78
|
+
/** Epoch milliseconds when this snapshot was captured natively. */
|
|
79
|
+
timestamp: number;
|
|
80
|
+
}
|
|
81
|
+
/** Per-interface detail, returned by {@link getAllInterfaces} for debugging. */
|
|
82
|
+
export interface NetworkInterfaceInfo {
|
|
83
|
+
/** Interface name (e.g. `en0`, `wlan0`, `bridge100`, `ap0`, `pdp_ip0`). */
|
|
84
|
+
name: string;
|
|
85
|
+
/** The interface's IPv4 address. */
|
|
86
|
+
ip: string;
|
|
87
|
+
/** Dotted IPv4 subnet mask, when known. */
|
|
88
|
+
netmask: string | null;
|
|
89
|
+
/** Best-effort role classification of the interface. */
|
|
90
|
+
role: 'wifi' | 'hotspot' | 'cellular' | 'ethernet' | 'other';
|
|
91
|
+
}
|
|
92
|
+
/** Native event map for the module. */
|
|
93
|
+
export type LocalNetworkInfoModuleEvents = {
|
|
94
|
+
/** Fired whenever the device's network position changes. */
|
|
95
|
+
onNetworkChange: (info: LocalIpInfo) => void;
|
|
96
|
+
};
|
|
97
|
+
//# sourceMappingURL=LocalNetworkInfo.types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LocalNetworkInfo.types.d.ts","sourceRoot":"","sources":["../src/LocalNetworkInfo.types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC;AAEtD,mDAAmD;AACnD,MAAM,MAAM,cAAc,GAAG,KAAK,GAAG,SAAS,GAAG,KAAK,CAAC;AAEvD;;;;;;;;GAQG;AACH,MAAM,WAAW,aAAa;IAC5B,8CAA8C;IAC9C,KAAK,EAAE,MAAM,CAAC;IACd,6CAA6C;IAC7C,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B;;;;OAIG;IACH,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAElB,8EAA8E;IAC9E,IAAI,EAAE,WAAW,CAAC;IAElB,mEAAmE;IACnE,eAAe,EAAE,OAAO,CAAC;IAEzB,mFAAmF;IACnF,aAAa,EAAE,OAAO,CAAC;IAEvB;;;OAGG;IACH,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAE7B,gFAAgF;IAChF,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAEvB;;;;;;;;;OASG;IACH,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAEvB;;;;;;;;OAQG;IACH,oBAAoB,EAAE,aAAa,GAAG,IAAI,CAAC;IAE3C,4CAA4C;IAC5C,QAAQ,EAAE,cAAc,CAAC;IAEzB,mEAAmE;IACnE,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,gFAAgF;AAChF,MAAM,WAAW,oBAAoB;IACnC,2EAA2E;IAC3E,IAAI,EAAE,MAAM,CAAC;IACb,oCAAoC;IACpC,EAAE,EAAE,MAAM,CAAC;IACX,2CAA2C;IAC3C,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,wDAAwD;IACxD,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,UAAU,GAAG,UAAU,GAAG,OAAO,CAAC;CAC9D;AAED,uCAAuC;AACvC,MAAM,MAAM,4BAA4B,GAAG;IACzC,4DAA4D;IAC5D,eAAe,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,IAAI,CAAC;CAC9C,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LocalNetworkInfo.types.js","sourceRoot":"","sources":["../src/LocalNetworkInfo.types.ts"],"names":[],"mappings":""}
|