用户反馈"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:
@@ -13,6 +13,7 @@ import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** 与后端信封一致的错误类型。 */
|
||||
@@ -309,7 +310,46 @@ class Api(private val tokens: TokenStore) {
|
||||
private val jsonMedia = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
// 与 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 是无状态的,没有注销接口。 */
|
||||
fun logout() {
|
||||
@@ -532,19 +572,23 @@ class Api(private val tokens: TokenStore) {
|
||||
tokens.accessToken?.let { builder.header("Authorization", "Bearer $it") }
|
||||
}
|
||||
|
||||
http.newCall(builder.build()).execute().use { response ->
|
||||
val text = response.body?.string().orEmpty()
|
||||
if (text.isBlank()) {
|
||||
throw ApiException(1500, "服务端返回空响应(HTTP ${response.code})")
|
||||
try {
|
||||
http.newCall(builder.build()).execute().use { response ->
|
||||
val text = response.body?.string().orEmpty()
|
||||
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 {
|
||||
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")
|
||||
} catch (e: IOException) {
|
||||
throw unreachable(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,24 @@ class TokenStore(context: Context) {
|
||||
|
||||
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 {
|
||||
const val KEY_ACCESS = "access"
|
||||
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 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) {
|
||||
if (seconds > 0) {
|
||||
delay(1000)
|
||||
@@ -68,6 +104,7 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
}
|
||||
.onFailure {
|
||||
val c = (it as? ApiException)?.code ?: 0
|
||||
if (c == 1) serverOpen = true
|
||||
error = verifyStatusCode(c).ifBlank { it.message ?: "验证码没发出去" }
|
||||
}
|
||||
busy = false
|
||||
@@ -84,6 +121,7 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
}
|
||||
.onFailure {
|
||||
val c = (it as? ApiException)?.code ?: 0
|
||||
if (c == 1) serverOpen = true
|
||||
error = verifyStatusCode(c).ifBlank { it.message ?: "登录没成功" }
|
||||
}
|
||||
busy = false
|
||||
@@ -195,6 +233,78 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
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))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
listOf(
|
||||
|
||||
Reference in New Issue
Block a user