把一台闲置的 Android 手机变成常开的 Wi-Fi 热点网关——插上电放在角落, 开机自动开热点、掉线自动重开,连上的人扫码即入网, 你随时能看到谁在用、用了多少、还剩多少额度。
手机自带的「个人热点」当临时救急很好用,但真要长期当路由器使,会撞上四件事:
- 它会自己关掉。系统有空闲超时(实测 10 分钟没人连就断),你人不在旁边就没了。
- 看不见谁在用。系统只给一个设备数,没有名字、没有分设备流量, 蹭网的和自己的设备长得一模一样。
- 不知道流量花在哪。等运营商短信提醒时,套餐已经用超了。
- 改个配置要拿起手机。人在电脑前,想换个频段还得去够手机。
HotspotGate 把这四件事补齐:保活轮询顶掉空闲超时;靠 mDNS / DHCP 指纹认出 「MacBook Air」「iPhone」而不是一串随机 MAC;分层时序库记下每台设备每个时段的消耗; 连上热点的任何设备打开浏览器就能改配置——它本身就是个可运维的网关,而不只是一个开关。
需求见 docs/requirements.md。
连上热点的任何设备,浏览器打开 http://<网关IP>:8080 就是这个页面。
不需要装任何东西,资源全部本地打包、不依赖 CDN——连上来的设备很可能根本没有外网。
顶部四格是最常看的读数:累计流量、在线设备数、实时上下行网速、服务连续运行了多久。 下面是连接二维码和热点参数,右下角可以直接改配置、重开或关掉热点。
![]() |
![]() |
| 设备:自动认出机型、配品牌图标,可改名、打标签、分组 | 流量:1 小时到 30 天的消耗曲线 + 设备用量排行 |
![]() |
![]() |
| SIM:信号强度、4G/5G 切换、按卡设流量上限、超限自动断这张卡 | 热点设置:改 SSID、密码、频段、加密方式 |
开了访问控制之后,打开后台要先登录。账号在手机 App 上设:
截图里的二维码是演示用的占位码,不是真实密码。
![]() |
![]() |
![]() |
| 扫码入网 | 实时读数 | 设备识别 |
![]() |
![]() |
|
| SIM 卡管理 | 设置 |
App 里的组件是在 Compose 里复刻的 shadcn/ui,不是 Material3 的默认外观—— 没有紫色、没有填充式输入框、没有涟漪,和 Web 端共用同一套设计 token。
- 开机自启:系统启动后自动拉起前台服务并打开热点,不用手动开 App
- 掉线自愈:热点被系统关掉(比如空闲超时)会自动重开;你自己主动关的则不会被拉起来
- 扫码入网:按 Wi-Fi QR 标准生成二维码,iPhone 相机和 Android 系统扫码都能直接连
- 自动认设备:靠 mDNS / DHCP 指纹 / 浏览器 UA 判断 iPhone、iPad、Mac、Android、Windows, 配对应品牌图标,还能拿到「小明的 MacBook Air」这样的真实设备名
- 重命名与分组:给设备起名、打标签、分组,按 MAC 记住,重连不丢
- 实时网速:SSE 推送,总网速和每台设备的网速都能看
- 流量趋势:分层降采样的时序库,1 小时到 30 天的消耗曲线与设备用量排行
- SIM 卡管理:每张卡的运营商、信号强度、4G/5G 制式;可锁 4G、切数据卡、单独断某张卡的网
- 按卡设流量上限:每张 SIM 独立额度与账单日,超限只断那一张卡(热点不断,设备仍在局域网内)
- 用量可校准:App 往往装得比卡开通晚,之前的流量它无从得知;照着运营商 App 的数字填进来即可
- 局域网 Web 后台:连上热点的任何设备打开
http://<网关IP>:8080就能看状态, 也能直接改 SSID / 密码 / 频段、重开热点 - 后台登录保护:账号密码在手机 App 上设,密码只存 PBKDF2 哈希,连错 5 次锁 5 分钟
- 运行时长:看服务连续运行了多久——归零就说明它中途被系统杀过
- 常驻通知:通知栏显示热点状态、设备数和后台地址
| 项 | 要求 |
|---|---|
| 设备 | 已 root(Magisk),开发机型为 Redmi K40 / LineageOS |
| 系统 | Android 8.0+(minSdk 26),实测 Android 16 |
| JDK | 17(AGP 8.13 不支持更高版本,用 25 会构建失败) |
| Android SDK | platform-36、build-tools 36.0.0 |
| Node | 18+(只有改 Web 前端时才需要) |
echo "sdk.dir=$HOME/Library/Android/sdk" > local.properties如果你的 SDK 装在别处(例如 Homebrew 的 android-commandlinetools),把路径换掉即可。
JAVA_HOME=/opt/homebrew/opt/openjdk@17 ./gradlew :app:assembleDebug产物在 app/build/outputs/apk/debug/app-debug.apk。
adb install -r app/build/outputs/apk/debug/app-debug.apkWeb 后台是独立的 Vite + React + Tailwind + shadcn/ui 工程,构建产物已经提交在
app/src/main/assets/web/,只改 Android 代码的话不需要跑这一步。
cd web
npm install
npm run build产物会直接输出到 app/src/main/assets/web/,再重新构建 APK 即可。
所有资源都本地打包,不依赖任何 CDN —— 连上热点的设备可能根本没有外网。
首次启动时会给这台设备随机生成一个 12 位密码(去掉了 0/o/1/l/i 这些容易认错的字符), 在「设置 → 密码 → 显示」里能看到,也可以直接改成自己想要的。
这里没有写死一个「出厂默认密码」,是因为那个值会随开源代码公开—— 所有没改过密码的安装就都共用同一把钥匙了。
热点密码就是这套系统的总钥匙:连上来的人默认能打开 Web 后台。 下一步的访问控制能把后台再锁一层,但热点密码本身给谁,还是要想清楚。
App 需要 root 才能开关系统热点。首次启动时 Magisk 会弹授权框,点「允许」。
如果没弹框,或者不小心点了拒绝(比如弹框时手机是息屏的,会超时自动拒绝):
打开 Magisk → 底部「超级用户」→ 找到 HotspotGate → 把右边的开关打开
被拒绝过一次后 Magisk 会记住,不会再弹框,只能去这里手动打开。
排查用:
adb shell su -c '/debug_ramdisk/magisk --sqlite "SELECT * FROM policies"'policy=2 是允许,policy=1 是拒绝。
「设置 → Web 后台访问控制 → 需要登录」,设一个用户名和密码(至少 8 位)。
不开的话,连上热点的任何人打开 http://<网关IP>:8080 就能看到全部状态,
还能改你的 SSID 和密码。默认关着只是因为总得让人先有机会设密码,不是因为它不重要。
密码只存不可逆的哈希,忘了就在这里重设一个——改凭据会让所有已登录的浏览器重新登录。
Android 13+ 会请求通知权限。拒绝的话服务照常运行,只是看不到常驻通知。
默认开启,在「设置 → 启动与保活」里。服务拿到 root 后会自动做这些加固:
- 加入 Doze 白名单,避免深度待机时轮询被冻结
- 保持在 active 待机桶里,不被 App Standby 降级
部分 ROM(尤其是 MIUI 系)还需要在系统设置里单独允许「自启动」和「后台运行」。
- 用另一台设备连上热点
- 浏览器打开 App 概览页显示的地址,形如
http://192.168.x.x:8080
网关 IP 不是固定的 —— Android 12 起 tethering 网段是随机的(实测会分到 10.x.x.x/24),
所以以 App 里显示的为准,别硬记 192.168.43.1。
Web 服务只绑定在热点网卡的 IP 上,并且会拒绝非本网段来源的请求,不会暴露到公网或移动数据。 开了访问控制之后还要再过一道登录,见下文。
端口被占用时 App 会明确提示,在「设置 → Web 后台与统计」里换一个即可。
现代手机连 Wi-Fi 默认用随机 MAC(本地管理地址),所以查 OUI 厂商表这条路是死的—— 很多同类工具就栽在这。这里靠三路信号融合,按可信度取高的:
| 信号 | 能认出 | 可信度 |
|---|---|---|
mDNS 的 _companion-link TXT 机型标识 |
iPhone14,5 / MacBookAir10,1 |
95 |
| 浏览器 User-Agent(打开过本后台的设备) | 全平台 | 99 |
| DHCP Option 60 vendor class | android-dhcp-16 → Android,MSFT 5.0 → Windows |
95 |
| mDNS / DHCP 主机名关键词 | iPhone、MacBook、DESKTOP-* 等 | 85 |
mDNS 服务类型(_remotepairing 等) |
Apple 生态、iOS 17+ | 35–60 |
有个坑值得记一笔:mDNS 报文里的名字不一定属于发送者。设备查询别人的服务时,
问题段里写的是对方的名字——iPhone 找 小明的MacBook Air._companion-link._tcp.local 时,
那串是 Mac 的名字。所以命名只认「自报家门」的部分:先用 A 记录里 rdata 等于来源 IP 的那条
锁定主机名,再取指向该主机名的 SRV 实例名。
用量口径是整张卡的全部移动数据——手机自己刷视频的、后台同步的、经热点转发的全算在内。 运营商就是这么计费的,只统计热点转发的部分会漏掉大头,额度永远等不到。
实现上没走 NetworkStatsManager:Android 11 起它的 subscriberId 必须传 null,
拿不到 IMSI 也就没法按卡区分,还要 PACKAGE_USAGE_STATS 这种特殊授权。改成两步更简单:
dumpsys netstats的 Active interfaces 段直接给出iface → subId映射;- 再读内核的
/sys/class/net/<iface>/statistics,按 subId 聚合。
只统计 metered=true 的接口——metered=false 的那条是 IMS 信令专用 APN,运营商不计费。
接口计数在 rmnet 重连时会归零,所以基线落库,跨重连和跨应用重启都能连续累加。
Android 的 grep 是 toybox,不支持 {n,m} 大范围重复计数。
在设备侧写 grep -oE 'xxx.{0,300}yyy' 会直接报 invalid repetition count(s) 导致整条命令失败。
需要截断的地方一律整行取出,交给 Kotlin 侧的正则处理。
切换 4G/5G 的参数是二进制字符串。cmd phone set-allowed-network-types-for-users
的参数名虽然叫 NETWORK_TYPES_BITMASK,但它既不收十进制数字、也不收 LTE|NR 名字列表——
AOSP 里的校验是 bitmaskString.matches("[01]+"),要的是 10101001110000100 这种二进制串。
传错只会得到一句 No valid NETWORK_TYPES_BITMASK。
而且不能凭空构造一份制式清单下发,那会把设备原本支持的制式改没。正确做法是读出当前掩码
(dumpsys isub 的 allowedNetworkTypesForReasons=user=NNN),只增删 NR 那一位(1 shl 19)。
-s 跟的是卡槽号不是 subId(传错会得到 N is not a valid SLOT_ID)。
别碰 Settings.Global.preferred_network_mode。这一版踩过:它在现代 Android 上已经是遗留物,
谁都能写、写了也不改变实际制式,而真正生效的只有上面那个掩码。当时读它当界面状态、
又顺手写它当「持久化偏好」,结果同一台机器上三个值指向三个答案——
preferred_network_mode 卡槽 0 段是 26(含 5G)、系统自己维护的 preferred_network_mode1 是 11(仅 4G)、
掩码里 NR 位却是 1。界面读了其中一个,于是切换「点了没反应」。
读写认准掩码这一处就不会打架,下发后再回读校验一次 NR 位,不一致就如实报错,
不要让界面显示成功而实际没动。
默认关着,在手机 App 的「设置 → Web 后台访问控制」里开。开了之后打开后台要先登录, 账号密码也在那里设——Web 端只能登录,不能改自己的凭据, 不然拿到一个会话就能顺手把管理员锁在门外。
几个实现上的选择:
- 密码只存 PBKDF2-HMAC-SHA256 派生值和 16 字节随机盐,不存明文。 迭代 12 万次:OWASP 给服务器的建议是 60 万,但这跑在一台旧手机上, 登录要等两三秒就没法用了;配合随机盐,离线爆破一个像样的密码依然不划算。
- 会话只在内存里。进程重启后全部失效——服务被杀过一次,中间发生了什么谁也说不准,
让人重登一次的代价很小。Cookie 带
HttpOnly(脚本读不到,XSS 偷不走) 和SameSite=Strict(挡住 CSRF)。 - 连错 5 次锁 5 分钟。局域网的攻击面不大,但密码往往很短, 没有节流的话一个脚本几分钟就能把常见密码表跑完。
- 不区分「用户名错」和「密码错」,那等于白送攻击者一半答案。 用户名和密码都走常量时间比较。
- 在 App 上改凭据会清掉全部会话,连同失败计数和锁定。 密码换了而旧 Cookie 还能用,这次「改密码」就没真正生效; 而锁定不清的话,任何人故意连错 5 次就能把真正的管理员锁 5 分钟—— 防暴破反倒成了拒绝服务。
静态资源不设防:前端得先加载出来才能画登录页,而它本身不含任何数据,
真正的东西都在 /api 后面,那一层是一律过闸的。
默认走 5 GHz(实测跑在信道 36/44、80MHz 带宽、Wi-Fi 6)。
5 GHz 更快,但穿墙差、部分老设备不支持。隔墙连不上或者对方设备连不上时, 去「设置 → 频段」改成 2.4 GHz。
改完频段要重启热点才生效,App 会问你要不要立刻重启。
总流量读的是 AP 网卡的 /sys/class/net/<iface>/statistics,稳定可靠。
分设备流量靠自建的两条 iptables 计数链(按源/目的 IP 统计)。但 Android 的 tethering 会做 eBPF/硬件 offload,被 offload 的转发流量根本不经过 netfilter,计数链会一直是 0。
App 会自动检测这种情况(总流量在涨、计数链纹丝不动),把分设备数据标记为「暂不可用」, 而不是显示一堆假的 0。
想要精确到每台设备,在「设置 → 精确分设备流量」里打开 —— 它会关掉系统的转发加速, 代价是转发全部走软件路径,高速下载时更费电、吞吐略降。默认是关的。
单模块 Kotlin 工程,依赖压到最少:
| 用途 | 方案 |
|---|---|
| UI | Jetpack Compose,自己实现的 shadcn/ui 组件(见下) |
| 图标 | Lucide(SVG path 直接喂给 PathParser) |
| 二维码 | zxing-core,只用编码部分 |
| Web 服务 | NanoHTTPD |
| 存储 | DataStore(配置)+ Room(设备档案、流量时序、配额) |
| 实时推送 | SSE(NanoHTTPD chunked + EventSource) |
| 图表 | Recharts |
| root | 自己实现的常驻 su shell(core/RootShell.kt) |
热点控制走的是 root + cmd wifi start-softap。没用 TetheringManager / WifiManager
的隐藏 API,是因为那条路需要签名级权限,第三方应用拿不到;而 cmd wifi start-softap
在 root 下走的是完整 tethering 流程(回显里能看到 TetheringRequest),
DHCP 和 NAT 都由系统负责,正是我们想要的。
RootShell 保持一个长期存活的 su 子进程,命令通过 stdin 送进去、用哨兵行标记输出结束。
不是每条命令都 fork 一个 su —— 那样 Magisk 每次都要走一遍鉴权,很慢也很费电。
拿不到 root 时会指数退避(30s 起,最多 5 分钟),否则几秒一轮的状态轮询会把 Magisk
的授权框一遍遍怼到用户脸上。
App 和 Web 两端共用同一套 shadcn/ui 设计 token,base color 取 stone——
需求文档给的那组颜色(#FAFAF9 / #E7E5E4 / #1C1917 / #78716C / #F5F5F4)
恰好就是 Tailwind 的 stone 色阶。
Web 端是真的 shadcn/ui(Radix + cva + Tailwind v4 + lucide-react)。
Android 端不存在 shadcn 这个库,所以在 Compose 里复刻了它的设计系统:
ui/components/ 下的 Button / Input / Select / Switch / Card / Badge / Dialog 全部自绘,
不使用 Material3 的默认外观(没有紫色、没有填充式输入框、没有涟漪)。
交互反馈按 shadcn 的路子走:hover:bg-primary/90 映射成按下变色,
输入框有 ring-2 ring-offset-2 的聚焦环,禁用态是整体 opacity-50。
- 设备名不是一定能拿到。Android 的 tethering DHCP 服务跑在 NetworkStack 进程内、
不落地 lease 文件,所以名字要靠 mDNS 广播和 DHCP 抓包碰运气。
Apple 设备基本都能拿到(它们在 mDNS 上很活跃),沉默的设备会退化成
iPhone A1B2这样的占位名——手动改个名就好。 另外 iOS 17+ 有些服务实例名是随机 UUID(故意防止设备名泄露),这类会被直接忽略。 - 分设备流量可能不可用,原因见上。
- 热点空闲会被系统自动关闭(实测超时 10 分钟)。App 会尝试写
settings put global soft_ap_timeout_enabled 0,但这个设置项不一定被所有 ROM 采纳; 真正兜底的是服务侧的保活轮询,检测到热点掉了就重开。 - Web 后台的登录默认是关着的。开着的话第一次打开就要密码、而用户还没设过, 人就被自己的后台锁在外面了。没开的时候,连上热点的人都能打开它、也都能改热点配置, 所以装好之后建议去「设置 → Web 后台访问控制」开上。
- 后台走的是 HTTP,不是 HTTPS。登录密码在局域网内是明文传输的: Wi-Fi 本身有 WPA2 加密,外人抓不到,但同一个热点下做 ARP 欺骗的人能截到。 自签证书要每台设备手动装信任链,对一个「连上就能看」的面板来说代价太大, 所以这里选了不做——别把这个热点的密码给不信任的人,那才是真正的边界。
- 改 SSID / 密码必须重启热点,hostapd 不支持热更新。两端都会先问你再重启。
- 双卡切换没有实测。开发设备只插了一张卡,「设为数据卡」这条路径代码写了但没能真机验证。
- 固定域名访问(
gate.lan)还没做。需要在 app 内跑 DNS 服务并用 iptables 把 53 端口 REDIRECT 过来,属于会动数据通路的改动,留到最后单独做。
app/src/main/java/com/hotspotgate/app/
├── core/ RootShell、二维码、格式化
├── data/ 数据模型、DataStore、Room 实体与 DAO
├── discovery/ mDNS 报文解析、DHCP 抓包、设备指纹与档案
├── hotspot/ 热点控制、客户端发现、流量统计与时序库、配额
├── sim/ SIM 读取、分卡流量、制式与数据开关
├── service/ 前台服务、通知、开机广播、保活加固
├── ui/ Compose 界面
│ ├── components/ shadcn 组件的 Compose 实现
│ ├── icons/ Lucide 图标
│ ├── screens/ 概览 / 设备 / SIM / 设置
│ └── theme/ 设计 token
└── web/ NanoHTTPD 服务、JSON 接口、SSE、登录与会话
web/ Web 后台前端(Vite + React + shadcn/ui)
看 App 日志:
adb logcat -s HotspotGate:*手动验证开机自启(不用真的重启手机):
adb shell am broadcast -a android.intent.action.BOOT_COMPLETED -n com.hotspotgate.app/.service.BootReceiver看热点当前状态:
adb shell su -c 'dumpsys wifi | grep -m1 mApInterfaceName'登不进 Web 后台:连错 5 次会锁 5 分钟,在手机上重新保存一次凭据即可立刻解锁 (改凭据会连同失败计数和锁定一起清掉)。忘了密码就在 「设置 → Web 后台访问控制」重设一个——存的是不可逆哈希,找不回来。
看后台是不是开着登录保护:
curl -s http://<网关IP>:8080/api/auth/session切 4G/5G 不生效:真正决定制式的是这个掩码,preferred_network_mode 是不生效的遗留项:
adb shell su -c "dumpsys isub | grep -o 'allowedNetworkTypesForReasons=user=[0-9]*'"第 19 位(1 shl 19)是 NR。578439 含 NR 就是 5G 优先,去掉那一位就是锁 4G。










