fix(android): 真机登录不上——明文流量被网络配置拦住,且地址写死在模拟器网段
CI / backend (push) Canceled after 0s

用户反馈"app 端登录不上去"。查下来是两个叠在一起的问题,都不是"后台没开":

1) 主清单设了 android:networkSecurityConfig(白名单只有 10.0.2.2 / 127.0.0.1 / localhost),
   而它的优先级高于 debug 清单里的 android:usesCleartextTraffic="true"。
   所以真机连 http://192.168.8.x:8100 会被系统拦成 CLEARTEXT communication not permitted,
   表现成登录无反应;模拟器因为地址在白名单里,一切正常,很容易误判。
   → 新增 app/src/debug/res/xml/network_security_config.xml,debug 放开明文;
     release 仍只有白名单,线上必须 HTTPS。

2) 后端地址编译期写死 BuildConfig.API_BASE = http://10.0.2.2:8100,那是模拟器专用地址。
   → 登录页加「服务器:… / 改地址」:可改、可「测试连接」、存在 TokenStore 里,
     改一次就记住,不用重新打包。地址缺失协议时自动补 http://。

顺带把网络异常翻成人话:以前 OkHttp 抛 IOException 原样冒到界面,
现在统一成「连不上 <地址> —— 具体该去查什么」,并区分了
"10.0.2.2 是模拟器地址"、"127.0.0.1 在手机上指的是手机自己"两种情况;
连不上时自动展开地址面板。

后端这边没有代码改动,但真机要用 --host 0.0.0.0 启动(原来只监听 127.0.0.1),
已写进 DEVELOPMENT.md 与 android/README.md,附防火墙放行命令。

实测:模拟器内把地址改成 http://192.168.8.151:8100 → 测试连接「连得通」→
取码(开发环境验证码回显)→ 登录成功进入工作区列表。
This commit is contained in:
dadao
2026-10-28 23:38:21 +08:00
parent 3cfd90b8d3
commit 0a3b0f3ca0
6 changed files with 258 additions and 15 deletions
+42 -2
View File
@@ -129,7 +129,47 @@ VECTOR_DATABASE_URL=postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5434/d
> 当前这台机器 Docker 起不来(见下),所以向量库还没真正跑起来, > 当前这台机器 Docker 起不来(见下),所以向量库还没真正跑起来,
> 上面是配置与脚本,M3 开工前必须先把它跑通。 > 上面是配置与脚本,M3 开工前必须先把它跑通。
## 8. 常见坑 ## 8. 让安卓真机连上本地后端
手机连不上后端,90% 是这三件事之一,按顺序查:
**① 后端有没有监听所有网卡**
```bash
cd backend
.venv\Scripts\python.exe -m uvicorn app.main:app --host 0.0.0.0 --port 8100
```
`--host 127.0.0.1`(uvicorn 默认)只对本机可见,手机必然连不上。用 `0.0.0.0` 之后:
```powershell
Invoke-RestMethod http://192.168.8.151:8100/health # 换成你自己的局域网 IP
```
**② 手机上填的地址对不对**
APK 里默认写的是 `http://10.0.2.2:8100`(模拟器映射宿主机的地址),真机连不上它。
在**登录页 → 服务器:… → 改地址**里填电脑的局域网 IP,点「测试连接」看到「连得通」再登录。
地址存在本地,改一次即可,不需要重新打包。
**③ 防火墙**
Windows 上 python.exe 首次监听端口时如果没弹「允许访问」,手机连过来的包会被丢。
以**管理员**运行一次:
```powershell
New-NetFirewallRule -DisplayName "DadaoTask backend 8100" -Direction Inbound `
-Action Allow -Protocol TCP -LocalPort 8100 -Profile Private
```
**关于明文 HTTP**:Android 从 9 开始默认禁止明文流量。主清单里配了
`network_security_config`(只放行 `10.0.2.2/127.0.0.1/localhost`),而
`android:usesCleartextTraffic` 一旦和它同时存在就不再生效——这就是"真机怎么都登不上、
但模拟器正常"的根因。所以 debug 变体单独放了一份
`android/app/src/debug/res/xml/network_security_config.xml` 允许明文;
release 仍走白名单,线上域名必须 HTTPS。
## 9. 常见坑
| 现象 | 原因 | 处理 | | 现象 | 原因 | 处理 |
|---|---|---| |---|---|---|
@@ -180,7 +220,7 @@ VECTOR_DATABASE_URL=postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5434/d
- **安装类命令一律写日志文件再 tail**,不要用管道截断输出,否则看不到进度也不知道卡在哪。 - **安装类命令一律写日志文件再 tail**,不要用管道截断输出,否则看不到进度也不知道卡在哪。
- `npm install` 要加 `--ignore-scripts`:沙箱不允许 npm 的 lifecycle 脚本派生进程(`EPERM spawn`)。 - `npm install` 要加 `--ignore-scripts`:沙箱不允许 npm 的 lifecycle 脚本派生进程(`EPERM spawn`)。
## 9. 提交前自检清单 ## 10. 提交前自检清单
1. `pytest` 全绿,`ruff` 无告警。 1. `pytest` 全绿,`ruff` 无告警。
2. 新增租户表时:`workspace_id NOT NULL` + RLS 策略 + 跨租户测试,三者齐全。 2. 新增租户表时:`workspace_id NOT NULL` + RLS 策略 + 跨租户测试,三者齐全。
+17
View File
@@ -38,6 +38,23 @@ cd android
gradle :app:assembleDebug # 或 ./gradlew(wrapper 需 Android Studio 补齐) gradle :app:assembleDebug # 或 ./gradlew(wrapper 需 Android Studio 补齐)
``` ```
## 连不上后端?先看这三件事
1. **后端要监听所有网卡**:`uvicorn app.main:app --host 0.0.0.0 --port 8100`。
默认的 `127.0.0.1` 只有本机可见,手机连不上。
2. **地址要填对**:包里的默认值是模拟器地址 `http://10.0.2.2:8100`。
真机在登录页点「改地址」填电脑的局域网 IP(如 `http://192.168.8.151:8100`),
点「测试连接」看到「连得通」再登录。改一次会记住,不用重新打包。
3. **防火墙**:Windows 没弹过「允许访问」的话,手机包会被丢。管理员 PowerShell 执行
`New-NetFirewallRule -DisplayName "DadaoTask backend 8100" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8100 -Profile Private`。
还有一个容易查半天的点:主清单配了 `network_security_config`(白名单只有
`10.0.2.2/127.0.0.1/localhost`),这种情况下 `android:usesCleartextTraffic="true"` **不生效**,
真机连 `http://192.168.8.x` 会被系统拦成 "CLEARTEXT communication not permitted",
表现就是"点了登录没反应/登录不上去",而模拟器一切正常。
所以 `android/app/src/debug/res/xml/network_security_config.xml` 单独给 debug 放开了明文,
release 依旧只走 HTTPS 白名单。
## 连哪个后端 ## 连哪个后端
`android/local.properties`(不入库): `android/local.properties`(不入库):
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
debug 变体专用(覆盖 main 里的同名资源)。
为什么需要它:主清单设了 android:networkSecurityConfig,这个属性和
android:usesCleartextTraffic 同时存在时以网络安全配置为准——也就是说
debug 清单里写的 usesCleartextTraffic="true" 会被忽略,只放行白名单里的
10.0.2.2 / 127.0.0.1 / localhost。真机连 http://192.168.8.x:8100 属于白名单外,
会被系统拦成 "CLEARTEXT communication not permitted",表现就是"登录不上去"。
debug 一律允许明文,方便连局域网里的后端;release 仍然只有 main 里的白名单,
线上域名必须 HTTPS。
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
@@ -13,6 +13,7 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** 与后端信封一致的错误类型。 */ /** 与后端信封一致的错误类型。 */
@@ -309,7 +310,46 @@ class Api(private val tokens: TokenStore) {
private val jsonMedia = "application/json; charset=utf-8".toMediaType() private val jsonMedia = "application/json; charset=utf-8".toMediaType()
// 与 backend/app/config.py 的 api_prefix 保持一致 // 与 backend/app/config.py 的 api_prefix 保持一致
private val base = BuildConfig.API_BASE.trimEnd('/') + "/api/v1" private val origin: String
get() = (tokens.apiBase ?: BuildConfig.API_BASE).trimEnd('/')
private val base: String get() = origin + "/api/v1"
/** 当前生效的后端地址(不含 /api/v1),登录页展示与修改用。 */
fun currentOrigin(): String = origin
/** 运行时切换后端地址;传 null 恢复编译期默认值。 */
fun setOrigin(value: String?) {
tokens.apiBase = value?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
}
/** 探活:`/health` 不带 `/api/v1` 前缀。用来区分"后端没起"和"地址填错"。 */
suspend fun ping(): String = withContext(Dispatchers.IO) {
try {
http.newCall(Request.Builder().url("$origin/health").build()).execute().use { r ->
if (!r.isSuccessful) throw ApiException(1, "$origin 返回 HTTP ${r.code}")
"ok"
}
} catch (e: IOException) {
throw unreachable(e)
}
}
/**
* 网络层异常统一翻成人话。
*
* 真机最常见的坑:包里默认地址是模拟器专用的 10.0.2.2,
* 手机连不上它,表现却只是"登录失败",所以把地址和下一步动作直接写进提示。
*/
private fun unreachable(e: IOException): ApiException {
val hint = when {
origin.contains("10.0.2.2") -> "这是模拟器专用地址,真机请改成电脑的局域网地址"
origin.contains("127.0.0.1") || origin.contains("localhost") ->
"手机上的 127.0.0.1 是手机自己,请改成电脑的局域网地址"
else -> "检查后端是否在运行、手机与电脑是否连同一个网络、防火墙是否放行"
}
return ApiException(1, "连不上 $origin —— $hint")
}
/** 退出登录只清本地令牌;服务端的 refresh 是无状态的,没有注销接口。 */ /** 退出登录只清本地令牌;服务端的 refresh 是无状态的,没有注销接口。 */
fun logout() { fun logout() {
@@ -532,19 +572,23 @@ class Api(private val tokens: TokenStore) {
tokens.accessToken?.let { builder.header("Authorization", "Bearer $it") } tokens.accessToken?.let { builder.header("Authorization", "Bearer $it") }
} }
http.newCall(builder.build()).execute().use { response -> try {
val text = response.body?.string().orEmpty() http.newCall(builder.build()).execute().use { response ->
if (text.isBlank()) { val text = response.body?.string().orEmpty()
throw ApiException(1500, "服务端返回空响应(HTTP ${response.code})") if (text.isBlank()) {
throw ApiException(1500, "服务端返回空响应(HTTP ${response.code})")
}
val envelope = runCatching { json.decodeFromString<Envelope<T>>(text) }.getOrElse {
throw ApiException(1500, "响应格式异常(HTTP ${response.code})")
}
if (envelope.code != 0) {
if (envelope.code == 2002 && auth) tokens.clear()
throw ApiException(envelope.code, envelope.message, envelope.requestId)
}
envelope.data ?: throw ApiException(1500, "响应缺少 data")
} }
val envelope = runCatching { json.decodeFromString<Envelope<T>>(text) }.getOrElse { } catch (e: IOException) {
throw ApiException(1500, "响应格式异常(HTTP ${response.code})") throw unreachable(e)
}
if (envelope.code != 0) {
if (envelope.code == 2002 && auth) tokens.clear()
throw ApiException(envelope.code, envelope.message, envelope.requestId)
}
envelope.data ?: throw ApiException(1500, "响应缺少 data")
} }
} }
} }
@@ -31,8 +31,24 @@ class TokenStore(context: Context) {
val isLoggedIn: Boolean get() = !accessToken.isNullOrBlank() val isLoggedIn: Boolean get() = !accessToken.isNullOrBlank()
/**
* 后端地址,含协议与端口、不含 `/api/v1`。
*
* 为空表示用编译期默认值([com.dadao.task.BuildConfig.API_BASE],模拟器是 10.0.2.2)。
* 真机联调时手机连不到 10.0.2.2,必须在登录页改成本机局域网地址,
* 所以这里做成可运行时覆盖并持久化,而不是重新打包。
*/
var apiBase: String?
get() = prefs.getString(KEY_API_BASE, null)?.takeIf { it.isNotBlank() }
set(value) {
prefs.edit().apply {
if (value.isNullOrBlank()) remove(KEY_API_BASE) else putString(KEY_API_BASE, value)
}.apply()
}
private companion object { private companion object {
const val KEY_ACCESS = "access" const val KEY_ACCESS = "access"
const val KEY_REFRESH = "refresh" const val KEY_REFRESH = "refresh"
const val KEY_API_BASE = "api_base"
} }
} }
@@ -50,6 +50,42 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
// 后端地址可改:包里默认是模拟器地址 10.0.2.2,真机必须换成电脑的局域网地址。
var serverOpen by remember { mutableStateOf(false) }
var address by remember { mutableStateOf(api.currentOrigin()) }
var probe by remember { mutableStateOf<String?>(null) }
var probeOk by remember { mutableStateOf(false) }
fun normalize(input: String): String {
val text = input.trim().ifBlank { "http://" }
val withScheme = if (text.startsWith("http://") || text.startsWith("https://")) text else "http://$text"
return withScheme.trimEnd('/')
}
fun saveAddress() {
api.setOrigin(normalize(address))
address = api.currentOrigin()
probeOk = false
probe = "已保存:${api.currentOrigin()}"
}
fun probeServer() {
scope.launch {
busy = true
probe = null
runCatching { api.ping() }
.onSuccess {
probeOk = true
probe = "连得通:${api.currentOrigin()}"
}
.onFailure {
probeOk = false
probe = (it as? ApiException)?.message ?: "连不上"
}
busy = false
}
}
LaunchedEffect(seconds) { LaunchedEffect(seconds) {
if (seconds > 0) { if (seconds > 0) {
delay(1000) delay(1000)
@@ -68,6 +104,7 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
} }
.onFailure { .onFailure {
val c = (it as? ApiException)?.code ?: 0 val c = (it as? ApiException)?.code ?: 0
if (c == 1) serverOpen = true
error = verifyStatusCode(c).ifBlank { it.message ?: "验证码没发出去" } error = verifyStatusCode(c).ifBlank { it.message ?: "验证码没发出去" }
} }
busy = false busy = false
@@ -84,6 +121,7 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
} }
.onFailure { .onFailure {
val c = (it as? ApiException)?.code ?: 0 val c = (it as? ApiException)?.code ?: 0
if (c == 1) serverOpen = true
error = verifyStatusCode(c).ifBlank { it.message ?: "登录没成功" } error = verifyStatusCode(c).ifBlank { it.message ?: "登录没成功" }
} }
busy = false busy = false
@@ -195,6 +233,78 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
onClick = { submit() }, onClick = { submit() },
) )
Spacer(Modifier.height(14.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"服务器:${api.currentOrigin()}",
style = MaterialTheme.typography.labelSmall,
color = InkMuted,
modifier = Modifier.weight(1f),
)
GhostButton(
text = if (serverOpen) "收起" else "改地址",
enabled = !busy,
onClick = {
serverOpen = !serverOpen
if (serverOpen) address = api.currentOrigin()
probe = null
},
)
}
if (serverOpen) {
Spacer(Modifier.height(8.dp))
Text(
"真机填电脑的局域网地址,例如 http://192.168.8.151:8100;模拟器用 http://10.0.2.2:8100。",
style = MaterialTheme.typography.labelSmall,
color = InkMuted,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = address,
onValueChange = { address = it.take(80) },
modifier = Modifier.fillMaxWidth(),
label = { Text("后端地址") },
placeholder = { Text("http://192.168.8.151:8100", color = InkMuted) },
singleLine = true,
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White,
unfocusedContainerColor = Color.White,
focusedIndicatorColor = Primary,
unfocusedIndicatorColor = Line,
),
)
Spacer(Modifier.height(10.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
GhostButton(
text = "测试连接",
enabled = !busy,
onClick = {
saveAddress()
probeServer()
},
)
Spacer(Modifier.width(10.dp))
PrimaryButton(
text = "保存",
enabled = !busy,
onClick = { saveAddress() },
)
}
val p = probe
if (p != null) {
Spacer(Modifier.height(10.dp))
SoftPill(
p,
if (probeOk) Sage else Rust,
if (probeOk) SageSoft else RustSoft,
Modifier.fillMaxWidth(),
)
}
}
Spacer(Modifier.height(28.dp)) Spacer(Modifier.height(28.dp))
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
listOf( listOf(