LinkLoom VPN SDK 集成指南
把"VPN 初始化 + UDP 隧道 + 设备注册"封装为 AAR,让任意 Android 项目通过 Builder API 接入虚拟内网。
目录
1. SDK 能力概览
数据流
[宿主应用业务流量]
│
▼ 被系统 TUN 接口捕获
[TUN 接口] ──read──► forwardToUdp() ──► SimpleUdpTunnel.send()
│
▼
[UDP 中继服务器 host:port]
│
▼
[宿主应用] ◄──write── VPN-Write 线程 ◄── BlockingQueue ◄── PacketHandler ◄── UDP-Receive 线程
内置功能
- UDP 配置后端拉取(
GET /udp-config):服务器 host/port、MTU、socket buffer 全部由后端下发,Builder 里只是兜底默认值 - 设备注册(
GET /device/validate/{id}+POST /device/bind),不返回networkKey - UDP 隧道:注册请求、心跳保活(默认 30s 间隔 / 90s 超时)、原生 IP 包透传
- 数据面双工转发,带 IPv4 包校验(
version==4 && ihl>=5 && length>=20) - TUN 接口建立与读写(两个独立线程 + BlockingQueue 解耦,TUN 流先于线程启动,避免竞态)
- 三路 token 并投:请求头
satoken+ GET query?apiKey=+ POST body"apiKey":"..."(参数名均可配) - 前台服务通知(自定义图标/渠道名/点击跳转)
- WakeLock 保持 CPU
- JobScheduler 守护(默认 60s 巡检,可关闭)
- 自动处理
VpnService.prepare授权(透明VpnPermissionActivity) excludeSelf路由开关(默认false,宿主流量走 VPN;SDK 自动VpnService.protect()UDP socket 避免死循环;可选true让宿主绕过 VPN)
不会做的事
- ❌ 不处理用户登录/Token 持久化(由
TokenProvider注入当前 token) - ❌ 不做错误页跳转(错误经
VpnStateListener.onError上抛,调用方决定 UI) - ❌ 不强制路由模式(默认仅路由配置的网段,如
10.57.0.0/24,不会全局代理)
2. 环境要求
| 项 | 要求 |
|---|---|
| minSdk | 28 (Android 9.0) |
| targetSdk / compileSdk | 34 |
| Java 源码版本 | 11 |
| Gradle 插件 | AGP 9.0.1 / Gradle 9.1.0(向下兼容到 AGP 8.x) |
| 传递依赖 | okhttp3:okhttp:4.12.0、com.google.code.gson:gson:2.10.1、androidx.core:core |
如果宿主项目已经依赖了不同版本的 OkHttp/Gson,AAR 会复用宿主版本。建议宿主使用 4.x 的 OkHttp 与 2.10+ 的 Gson。
3. 集成步骤
3.1 构建 AAR
cd linkloom-vpn-sdk
# Windows
gradlew.bat :vpnsdk:assembleRelease
# macOS / Linux
./gradlew :vpnsdk:assembleRelease
产物路径:vpnsdk/build/outputs/aar/vpnsdk-release.aar
3.2 把 AAR 放入宿主工程
把 vpnsdk-release.aar 拷贝到宿主 app/libs/ 目录。
3.3 添加 gradle 依赖
宿主 app/build.gradle:
dependencies {
// 本地 AAR
implementation files("libs/vpnsdk-release.aar")
// 传递依赖(AAR 不携带版本,宿主必须显式声明)
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.google.code.gson:gson:2.10.1'
// 如果宿主没有 androidx.core,需要补
implementation 'androidx.core:core:1.13.1'
}
为什么 AAR 不带依赖版本? Android Library 的
pom默认会带传递依赖版本,但本地 AAR 不带 pom,因此需要在宿主显式声明。这样能避免版本冲突,宿主可以自由升级。
3.4 AndroidManifest 权限
SDK 已通过 manifest merger 自动声明下列权限,通常无需任何额外配置:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.BIND_VPN_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
Service / Activity 也由 SDK manifest 自动注册:
<service android:name="com.linkloom.vpnsdk.core.LinkLoomVpnService" ... />
<service android:name="com.linkloom.vpnsdk.core.VpnWatchdogJobService" ... />
<activity android:name="com.linkloom.vpnsdk.core.VpnPermissionActivity" ... />
3.5 Android 13+ 通知运行时权限
targetSdk ≥ 33 时,前台服务通知 需要运行时申请 POST_NOTIFICATIONS。建议在 Application.onCreate 或首个 Activity:
if (Build.VERSION.SDK_INT >= 33) {
registerForActivityResult(new ActivityResultContracts.RequestPermission(), granted -> {
// 即使拒绝,VPN 仍能工作(只是通知不可见)
}).launch(Manifest.permission.POST_NOTIFICATIONS);
}
3.6 初始化 SDK
在 Application.onCreate(或首个 Activity):
public class MyApp extends Application {
private static final String TAG = "MyApp";
@Override
public void onCreate() {
super.onCreate();
LinkLoomVpn vpn = new LinkLoomVpn.Builder(this)
.apiBaseUrl("http://117.72.215.142:4981") // 必填(同时用于 /udp-config 拉取)
.tokenProvider(() -> SessionManager.getToken()) // 必填
.tokenHeader("satoken") // 可选,默认 "satoken"
.apiKeyParamName("apiKey") // 可选,默认 "apiKey";传 null 仅走 header
.route("10.57.0.0", 24) // 可选,默认 10.57.0.0/24
.heartbeatInterval(30) // 可选
.heartbeatTimeout(90) // 可选
.notificationIcon(R.mipmap.ic_launcher) // 可选
.launchActivity(MainActivity.class) // 可选
.enableWatchdog(true) // 可选,默认 true
.listener(new VpnStateListener() {
@Override public void onStateChanged(@NonNull VpnState s) {
Log.i(TAG, "VPN state: " + s);
}
@Override public void onError(@NonNull VpnErrorCode code, String msg) {
Log.e(TAG, "VPN error: " + code + " / " + msg);
}
@Override public void onAssignedIp(@NonNull String ip) {
Log.i(TAG, "VPN assigned IP: " + ip);
}
})
.build(); // 注册全局单例
}
}
认证说明:
tokenProvider是 lambda,每次 REST 请求前才求值,登录态变化后 SDK 下次请求自动拿到新 token。- 默认行为(对齐参考
SaTokenHttpUtil):token 会同时放到 请求头satoken、GET query?apiKey=...、POST body"apiKey":"..."三处。 - 后端用标准
Authorization头时:.tokenHeader("Authorization"),并在TokenProvider里返回"Bearer xxx"。 - 只想走 header 时:
.apiKeyParamName(null),禁用 query/body 注入。
3.7 启动 / 停止
在任意有 UI 上下文的地方:
// 1. 先通过网络选择拿到 networkKey(来自 GET /network/my 列表)
String networkKey = selectedNetwork.getNetworkKey();
// 2a. 便捷:start 同时传 networkKey
LinkLoomVpn.instance().start(context, networkKey);
// —或—
// 2b. 先 setNetworkKey 再 start
LinkLoomVpn.instance().setNetworkKey(networkKey);
LinkLoomVpn.instance().start(context);
// 停止
LinkLoomVpn.instance().stop(context);
// 查询
VpnState state = LinkLoomVpn.instance().getState();
String ip = LinkLoomVpn.instance().getAssignedIp();
注意:未提供
networkKey就调start(context),SDK 会立即回调onError(NO_NETWORK_KEY, ...),不会拉起 VPN 授权对话框。networkKey属于Network实体,由宿主 App 自行从/network/my拉取后让用户选择。
4. 完整示例代码
4.1 最小集成(Application 初始化)
MyApp.java:
public class MyApp extends Application {
@Override
public void onCreate() {
super.onCreate();
new LinkLoomVpn.Builder(this)
.server("117.72.215.142", 5060)
.apiBaseUrl("http://117.72.215.142:4981")
.tokenProvider(() -> Session.getToken())
.launchActivity(MainActivity.class)
.listener(new VpnStateListener() {
@Override public void onStateChanged(@NonNull VpnState s) {
Toast.makeText(MyApp.this, "VPN: " + s, Toast.LENGTH_SHORT).show();
}
@Override public void onError(@NonNull VpnErrorCode c, String m) {
Toast.makeText(MyApp.this, "VPN Error: " + c, Toast.LENGTH_LONG).show();
}
@Override public void onAssignedIp(@NonNull String ip) {
Toast.makeText(MyApp.this, "VPN IP: " + ip, Toast.LENGTH_SHORT).show();
}
})
.build();
}
}
AndroidManifest.xml:
<application android:name=".MyApp" ...>
<!-- SDK 内的 Service / Activity 自动合并 -->
</application>
4.2 UI 触发启停
public class MainActivity extends AppCompatActivity {
public void onConnectClick(View v) {
LinkLoomVpn.instance().start(this);
}
public void onDisconnectClick(View v) {
LinkLoomVpn.instance().stop(this);
}
}
4.3 动态注册监听器(在 Activity 中)
private final VpnStateListener stateListener = new VpnStateListener() { /* ... */ };
@Override protected void onStart() {
super.onStart();
LinkLoomVpn.instance().addListener(stateListener);
}
@Override protected void onStop() {
super.onStop();
LinkLoomVpn.instance().removeListener(stateListener);
}
5. API 参考
5.1 LinkLoomVpn
| 方法 | 说明 |
|---|---|
Builder(context) | 构造 Builder |
LinkLoomVpn.instance() | 取得 build 后的全局单例 |
start(context) | 启动 VPN(异步) |
start(context, networkKey) | 设置 networkKey 并启动(便捷重载) |
setNetworkKey(networkKey) | 设置当前选中的网络密钥(start 前必须设置) |
stop(context) | 停止 VPN(异步) |
getState() | 当前 VpnState |
getAssignedIp() | 服务器分配的内网 IP(连接前为 null) |
addListener(l) / removeListener(l) | 注册 / 移除状态监听器 |
getConfig() | 调试用:当前 SdkConfig |
networkKey 来源:属于服务端
Network实体(GET /network/my列表里的字段),由宿主 App 让用户选择网络后把networkKey传给 SDK。SDK 不会从设备接口提取它。
5.2 Builder
必填(缺一会触发 onError(INVALID_CONFIG, ...)):
apiBaseUrl(url)— REST base URL(同时用于设备注册和/udp-config拉取)tokenProvider(provider)— Token 提供者(必填,决定认证方式)
UDP 服务器:
server(host, port)— 可选兜底。UDP 服务器地址默认由后端/udp-config下发,此处仅作可选兜底;不调用时若/udp-config拉取失败会触发onError(INVALID_CONFIG, ...)。
认证(Token / apiKey 注入):
tokenHeader(name)— token 请求头名称,默认"satoken"(Sa-Token 框架默认)。后端用标准Authorization头时传"Authorization",并在TokenProvider里返回"Bearer xxx"形式的值。apiKeyParamName(name)— apiKey 在 GET query / POST body 中的参数名,默认"apiKey"。传null或空串可禁用 query/body 注入(仅走 header)。
三路并投(默认行为,对齐参考
SaTokenHttpUtil):SDK 在每次 REST 请求时同时把 token 放到三处:
- 请求头:
satoken: <token>- GET query:自动追加
?apiKey=<token>- POST JSON body:自动注入
"apiKey":"<token>"仅当
apiKeyParamName为null/空时才退化为「只走 header」。
网络/隧道:
route(address, prefix)— 默认10.57.0.0/24mtu(int)— 默认 1500(实际值会被/udp-config覆盖)heartbeatInterval(seconds)— 默认 30heartbeatTimeout(seconds)— 默认 90httpTimeout(seconds)— 默认 30excludeSelf(boolean)— 默认false。是否把宿主应用自身排除出 VPN 路由。false(默认):宿主流量也走 VPN。SDK 自动调用VpnService.protect(udpSocket)保护 UDP 隧道 socket,避免 UDP 包回环进 TUN 形成死循环。大多数场景用此默认值。true:整个宿主应用(含 WebView / OkHttp 等所有流量)绕过 VPN,走真实网络。等价于在VpnService.Builder上调用addDisallowedApplication(getPackageName())。
UI / 通知:
notificationIcon(resId)— 默认android.R.drawable.stat_sys_downloadnotificationChannelName(name)— 默认"LinkLoom VPN Service"launchActivity(Class)— 不配则用包默认启动页 IntentsessionName(name)— 默认"LinkLoom VPN"
守护:
enableWatchdog(boolean)— 默认 truewatchdogInterval(ms)— 默认 60000
调试:
logEnabled(boolean)— 默认 truelistener(VpnStateListener)— 等价 build 后 addListener
5.3 VpnState
| 值 | 含义 |
|---|---|
IDLE | 未启动 |
CONNECTING | 设备注册 / UDP 注册 / 等待 IP / 建立 TUN |
CONNECTED | 已连接,正在转发 |
DISCONNECTED | 已停止(主动 stop 或守护任务触发的停止) |
ERROR | 异常终止,参见 onError |
5.4 VpnErrorCode
| 值 | 触发场景 |
|---|---|
INVALID_CONFIG | 必填项缺失(apiBaseUrl/tokenProvider/route),或 /udp-config 拉取失败且未配置 server() 兜底 |
VPN_PERMISSION_DENIED | 用户拒绝 VPN 授权 / 系统撤销(onRevoke) |
DEVICE_BOUND_OTHER | 设备已被其他用户绑定 |
DEVICE_REGISTER_FAILED | /device/bind 调用失败(含 401 token 失效) |
NO_NETWORK_KEY | 宿主 App 未在 start() 前提供 networkKey(应来自网络选择) |
UDP_REGISTER_TIMEOUT | 10s 内未收到服务器分配的 IP |
DEVICE_DISABLED | 收到 0x12,设备未注册或被禁用 |
ESTABLISH_FAILED | VpnService.establish() 返回 null |
NETWORK_ERROR | 网络/IO 异常 |
UNKNOWN | 未分类错误 |
5.5 TokenProvider
public interface TokenProvider {
String getToken(); // 返回当前用户的认证 token;未登录时返回 null/空串
}
每次发起 REST 请求前会调用,不需要缓存。
5.6 VpnStateListener
public interface VpnStateListener {
void onStateChanged(@NonNull VpnState state); // 主线程
void onError(@NonNull VpnErrorCode code, String message); // 主线程
void onAssignedIp(@NonNull String ip); // 主线程,连接成功后触发一次
}
6. UDP 协议规范
此小节定义 SDK 与中继服务器之间的二进制协议。如果你只使用现有服务器,可跳过。
字节序说明:所有多字节整数(如心跳 timestamp)使用 大端序(big-endian / network byte order)。
| 类型码 | 方向 | 名称 | 格式 |
|---|---|---|---|
0x01 | C→S | 注册请求 | [0x01][idLen:1][id...][keyLen:1][key...] |
0x11 | S→C | 注册响应(L3) | [0x11][ipLen:1][ip...] |
0x11 | S→C | 注册响应(L2) | [0x11][ipLen:1][ip...][maskLen:1][mask...][gwLen:1][gw...][dnsLen:1][dns...] |
0x03 | C→S | 心跳请求 | [0x03](单字节,无 payload) |
0x13 | S→C | 心跳响应 | [0x13][timestamp:8 BE] |
0x04 | S→C | 连接通知 | [0x04] |
0x05 | S→C | 服务器断开通知 | [0x05] |
0x12 | S→C | 设备禁用 | [0x12] |
| 其他 | 双向 | 原生 IP 包 | 首字节高 4 位 = 4(IPv4)/ 6(IPv6) |
id为设备的 UUID;key为宿主 App 通过网络选择传入的networkKey(不是设备接口返回)。- 注册响应按 payload 长度自动识别 L3/L2:携带 mask/gateway/dns 即 L2,SDK 据此设置 TUN 掩码与 DNS。
- 心跳:客户端发送单字节
0x03;服务器回0x13+ 8 字节大端 timestamp。 - 心跳首次延迟 20s,之后每 30s 一次。
- 客户端时钟与服务器相差 > 5s 会在日志打印 warning。
- 客户端主动断开时(
SimpleUdpTunnel.close())会向服务器发0x04通知。
7. 混淆规则
consumer-rules.pro 会被 AGP 自动应用到宿主,无需手动配置。关键 keep 项:
# SDK 公共 API
-keep class com.linkloom.vpnsdk.LinkLoomVpn { *; }
-keep class com.linkloom.vpnsdk.LinkLoomVpn$Builder { *; }
-keep class com.linkloom.vpnsdk.SdkConfig { *; }
-keep class com.linkloom.vpnsdk.VpnState { *; }
-keep class com.linkloom.vpnsdk.VpnErrorCode { *; }
-keep class com.linkloom.vpnsdk.VpnStateListener { *; }
-keep class com.linkloom.vpnsdk.auth.TokenProvider { *; }
# Gson 反射需要的 model
-keep class com.linkloom.vpnsdk.device.model.** { *; }
# OkHttp / Gson 标准规则
-dontwarn okhttp3.**
-dontwarn okio.**
-keep class com.google.gson.** { *; }
8. 常见问题
Q1:用户拒绝 VPN 授权怎么处理?
会通过 onError(VPN_PERMISSION_DENIED, ...) 上报。建议 UI 显示 "请到系统设置开启 VPN 权限"。
Q2:通知不显示(Android 13+)
targetSdk ≥ 33 必须运行时申请 POST_NOTIFICATIONS。SDK 已经把权限声明在 manifest,运行时申请由宿主负责。
Q3:UDP 不通
排查清单:
- UDP 服务器 IP / 端口正确(默认
117.72.215.142:5060) - 防火墙放行 UDP 入站
- 客户端能 ping 通服务器(TCP ping 也行,验证基础网络)
- 抓包确认是否有
0x01注册包到达服务器
Q4:日志报 "Clock skew detected"
客户端与服务器时间相差 > 5 秒。仅是 warning,不影响功能;建议开启 NTP 时间同步。
Q5:VPN 一连接就断 / UDP 死循环
SDK 默认(excludeSelf=false)会调用 VpnService.protect(udpSocket) 保护 UDP 隧道 socket 不进入 TUN 接口,避免回环死循环;此时宿主应用流量会走 VPN。
如果你切到 excludeSelf=true(宿主绕过 VPN),SDK 会改用 addDisallowedApplication(getPackageName()) 排除整个宿主,UDP socket 不需要 protect 也安全。
如果宿主是通过 plugin/子进程加载 SDK,子进程的流量不会自动被排除,需要额外处理——目前 SDK 未暴露此能力,可后续扩展。
Q6:JobScheduler 守护耗电
默认 60s 巡检一次,每次只调 getRunningServices 一次,开销很小。如需省电:enableWatchdog(false),但服务被系统杀掉后不会自动拉起。
Q7:服务端返回 401 怎么办?
SdkHttpClient 会抛 IOException("401 Unauthorized"),最终上报为 DEVICE_REGISTER_FAILED。宿主应在 onError 里跳转登录或刷新 token。
Q8:多进程使用
SDK 是进程内单例(VpnController 静态字段)。如果宿主在多进程都需要 VPN,应在主进程初始化,子进程通过 Service / IPC 触发 start()。本 SDK 未做多进程同步。
Q9:怎么自定义通知文案?
通过宿主 res/values/strings.xml 覆盖:
<string name="linkloom_vpn_title">我的应用 VPN</string>
<string name="linkloom_vpn_status_connecting">连接中...</string>
<string name="linkloom_vpn_status_connected">已连接</string>