fix: 应用 xhigh 代码评审的跨端修复
来自 xhigh code review 的正确性/健壮性修复,覆盖全部五端: - server:鉴权 fail-closed、计量交叉校验与配额扣穿处理、WS 网关并发与关闭顺序、 billing 行锁、redis Lua 过期与设备槽刷新、config 解析 - desktop:会话 epoch 防串话、WS 重连与 401 处理、api 客户端复用、统一 usePoll 轮询 - android:握手时序、请求头封装、账户状态派生、按需重组 - ios:finalize 宽限、串行采集、错误文案服务端优先、删除死代码 CommitController Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -132,7 +132,6 @@ fun BuyScreen(refreshKey: Int, onBack: () -> Unit) {
|
||||
balanceSeconds = me.balanceSeconds,
|
||||
trialDailyLimit = me.trialDailyLimit,
|
||||
trialUsedToday = me.trialUsedToday,
|
||||
accountState = me.accountState,
|
||||
)
|
||||
}
|
||||
metrics.track("purchase.success", mapOf("pack_id" to pack.id))
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package app.dudu.app
|
||||
|
||||
import android.app.Application
|
||||
import app.dudu.data.AccountRepository
|
||||
import app.dudu.data.MetricsFlushWorker
|
||||
import app.dudu.net.DuduApi
|
||||
|
||||
/**
|
||||
* 应用入口:主 App 与 IME Service 同进程,这里做一次性初始化。
|
||||
* 目前只负责注册埋点周期上报(WorkManager)。
|
||||
* 注册埋点周期上报(WorkManager),并把设备 ID 注入 DuduApi(REST 请求带 X-Device-ID)。
|
||||
*/
|
||||
class DuduApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
DuduApi.init(AccountRepository.get(this).deviceId)
|
||||
MetricsFlushWorker.schedule(this)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,6 @@ fun LoginScreen(onLoggedIn: () -> Unit, onBack: () -> Unit) {
|
||||
balanceSeconds = me.balanceSeconds,
|
||||
trialDailyLimit = me.trialDailyLimit,
|
||||
trialUsedToday = me.trialUsedToday,
|
||||
accountState = me.accountState,
|
||||
)
|
||||
metrics.track("login.success")
|
||||
onLoggedIn()
|
||||
|
||||
@@ -79,7 +79,6 @@ fun MineScreen(
|
||||
balanceSeconds = me.balanceSeconds,
|
||||
trialDailyLimit = me.trialDailyLimit,
|
||||
trialUsedToday = me.trialUsedToday,
|
||||
accountState = me.accountState,
|
||||
)
|
||||
} catch (e: ApiException) {
|
||||
if (e.code == "UNAUTHORIZED") repo.markUnauthorized()
|
||||
|
||||
@@ -67,15 +67,31 @@ class AccountRepository private constructor(context: Context) {
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* 只持久化 token/balance/trialUsed/limit 四个事实,state 与 trialRemaining 读取时推导
|
||||
* (对齐 iOS AccountStore,与 server dto.go 判定规则一致):
|
||||
* - 无 token → guest;balance>0 → ok;trialRemaining>0 → trial;否则 quota
|
||||
* - trialRemaining = limit - trialUsed(下限 0)
|
||||
*/
|
||||
private fun load(): AccountSnapshot {
|
||||
val state = AccountState.fromWire(prefs.getString(KEY_STATE, AccountState.GUEST.wire))
|
||||
val token = prefs.getString(KEY_TOKEN, null)
|
||||
val balance = prefs.getLong(KEY_BALANCE, 0L)
|
||||
val limit = prefs.getInt(KEY_TRIAL_LIMIT, DEFAULT_TRIAL_LIMIT)
|
||||
val trialUsed = prefs.getInt(KEY_TRIAL_USED, 0)
|
||||
val trialRemaining = (limit - trialUsed).coerceAtLeast(0)
|
||||
val state = when {
|
||||
token == null -> AccountState.GUEST
|
||||
balance > 0 -> AccountState.OK
|
||||
trialRemaining > 0 -> AccountState.TRIAL
|
||||
else -> AccountState.QUOTA
|
||||
}
|
||||
return AccountSnapshot(
|
||||
state = state,
|
||||
balanceSeconds = prefs.getLong(KEY_BALANCE, 0L),
|
||||
trialRemainingSeconds = prefs.getInt(KEY_TRIAL_REMAIN, 0),
|
||||
trialDailyLimitSeconds = prefs.getInt(KEY_TRIAL_LIMIT, DEFAULT_TRIAL_LIMIT),
|
||||
trialUsedTodaySeconds = prefs.getInt(KEY_TRIAL_USED, 0),
|
||||
token = prefs.getString(KEY_TOKEN, null),
|
||||
balanceSeconds = balance,
|
||||
trialRemainingSeconds = trialRemaining,
|
||||
trialDailyLimitSeconds = limit,
|
||||
trialUsedTodaySeconds = trialUsed,
|
||||
token = token,
|
||||
nicknameMasked = prefs.getString(KEY_NICKNAME, null),
|
||||
userId = prefs.getString(KEY_USER_ID, null),
|
||||
)
|
||||
@@ -85,22 +101,24 @@ class AccountRepository private constructor(context: Context) {
|
||||
|
||||
/** 微信登录成功(POST /v1/auth/wechat):写入 JWT 与用户信息,随后应调 /v1/me 校正余额。 */
|
||||
fun setLogin(token: String, userId: String, nicknameMasked: String) {
|
||||
// state 由 token/balance/trial 推导,写入 token 即从 guest 转出,applyMe 再校正余额
|
||||
prefs.edit()
|
||||
.putString(KEY_TOKEN, token)
|
||||
.putString(KEY_USER_ID, userId)
|
||||
.putString(KEY_NICKNAME, nicknameMasked)
|
||||
.putString(KEY_STATE, AccountState.TRIAL.wire) // 占位,applyMe 立刻校正
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** GET /v1/me → 刷新余额/试用/账户态(字段对齐 protocol.MeResponse,试用单位为秒)。 */
|
||||
/**
|
||||
* GET /v1/me → 刷新余额/试用(字段对齐 protocol.MeResponse,试用单位为秒)。
|
||||
* 只落盘事实字段;state 由 [load] 依 token/balance/trial 推导,与服务端 account_state 判定一致。
|
||||
*/
|
||||
fun applyMe(
|
||||
userId: String,
|
||||
nicknameMasked: String,
|
||||
balanceSeconds: Long,
|
||||
trialDailyLimit: Int,
|
||||
trialUsedToday: Int,
|
||||
accountState: String,
|
||||
) {
|
||||
prefs.edit()
|
||||
.putString(KEY_USER_ID, userId)
|
||||
@@ -108,71 +126,64 @@ class AccountRepository private constructor(context: Context) {
|
||||
.putLong(KEY_BALANCE, balanceSeconds)
|
||||
.putInt(KEY_TRIAL_LIMIT, trialDailyLimit)
|
||||
.putInt(KEY_TRIAL_USED, trialUsedToday)
|
||||
.putInt(KEY_TRIAL_REMAIN, (trialDailyLimit - trialUsedToday).coerceAtLeast(0))
|
||||
.putString(KEY_STATE, accountState)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** 退出登录:清 JWT 与用户信息,回游客态(服务端 /v1/auth/logout 由调用方发起)。 */
|
||||
/** 退出登录:清 JWT 与用户信息,回游客态(清 token 后 [load] 即推导为 guest)。 */
|
||||
fun logout() {
|
||||
prefs.edit()
|
||||
.remove(KEY_TOKEN)
|
||||
.remove(KEY_USER_ID)
|
||||
.remove(KEY_NICKNAME)
|
||||
.putLong(KEY_BALANCE, 0L)
|
||||
.putString(KEY_STATE, AccountState.GUEST.wire)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** WS usage 帧 → 实时刷新余额与试用剩余。 */
|
||||
/**
|
||||
* WS usage 帧 → 实时刷新余额与试用。只更新事实字段(balance/trialUsed),
|
||||
* state 由 [load] 依 balance/trialRemaining 推导(与 dto.go 判定规则一致)。
|
||||
*/
|
||||
fun applyUsage(balanceSeconds: Long, trialRemaining: Int) {
|
||||
val limit = prefs.getInt(KEY_TRIAL_LIMIT, DEFAULT_TRIAL_LIMIT)
|
||||
prefs.edit()
|
||||
.putLong(KEY_BALANCE, balanceSeconds)
|
||||
.putInt(KEY_TRIAL_REMAIN, trialRemaining)
|
||||
.putInt(KEY_TRIAL_USED, (limit - trialRemaining).coerceAtLeast(0))
|
||||
.apply()
|
||||
// 余额/试用归零时联动账户态(与 dto.go 判定规则一致)
|
||||
val cur = _snapshot.value.state
|
||||
if (cur == AccountState.OK || cur == AccountState.TRIAL) {
|
||||
val next = when {
|
||||
balanceSeconds > 0 -> AccountState.OK
|
||||
trialRemaining > 0 -> AccountState.TRIAL
|
||||
else -> AccountState.QUOTA
|
||||
}
|
||||
if (next != cur) prefs.edit().putString(KEY_STATE, next.wire).apply()
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前是否处于离线 mock 会话(setMockState 写入的假 token,不可对真实后端发请求)。 */
|
||||
val offlineMock: Boolean
|
||||
get() = prefs.getString(KEY_TOKEN, null) == MOCK_TOKEN
|
||||
|
||||
/** 服务端回 QUOTA_EXCEEDED → 本地立刻转 quota 态。 */
|
||||
/** 服务端回 QUOTA_EXCEEDED → 余额清零 + 试用耗尽,[load] 即推导为 quota 态。 */
|
||||
fun markQuotaExceeded() {
|
||||
prefs.edit().putString(KEY_STATE, AccountState.QUOTA.wire).apply()
|
||||
val limit = prefs.getInt(KEY_TRIAL_LIMIT, DEFAULT_TRIAL_LIMIT)
|
||||
prefs.edit()
|
||||
.putLong(KEY_BALANCE, 0L)
|
||||
.putInt(KEY_TRIAL_USED, limit)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** 服务端回 UNAUTHORIZED → 清登录态。 */
|
||||
/** 服务端回 UNAUTHORIZED → 清 JWT,[load] 即推导为 guest 态。 */
|
||||
fun markUnauthorized() {
|
||||
prefs.edit()
|
||||
.remove(KEY_TOKEN)
|
||||
.putString(KEY_STATE, AccountState.GUEST.wire)
|
||||
.apply()
|
||||
}
|
||||
|
||||
// ─── 离线 mock 开关:无后端时手动切四态做联调(仅 debug UI 暴露入口) ────
|
||||
// 真实登录路径见 setLogin/applyMe;本方法保留用于后端不可用时的键盘联调
|
||||
fun setMockState(state: AccountState) {
|
||||
val e = prefs.edit().putString(KEY_STATE, state.wire).putInt(KEY_TRIAL_LIMIT, DEFAULT_TRIAL_LIMIT)
|
||||
// 只写事实字段,目标 state 由 [load] 推导(token/balance/trial → state)。
|
||||
val e = prefs.edit().putInt(KEY_TRIAL_LIMIT, DEFAULT_TRIAL_LIMIT)
|
||||
when (state) {
|
||||
AccountState.OK -> e.putLong(KEY_BALANCE, 472L * 60).putInt(KEY_TRIAL_REMAIN, 120).putInt(KEY_TRIAL_USED, 60)
|
||||
AccountState.OK -> e.putLong(KEY_BALANCE, 472L * 60).putInt(KEY_TRIAL_USED, 60)
|
||||
.putString(KEY_TOKEN, MOCK_TOKEN).putString(KEY_NICKNAME, "wang***")
|
||||
AccountState.TRIAL -> e.putLong(KEY_BALANCE, 0L).putInt(KEY_TRIAL_REMAIN, 120).putInt(KEY_TRIAL_USED, 60)
|
||||
AccountState.TRIAL -> e.putLong(KEY_BALANCE, 0L).putInt(KEY_TRIAL_USED, 60)
|
||||
.putString(KEY_TOKEN, MOCK_TOKEN).putString(KEY_NICKNAME, "wang***")
|
||||
AccountState.QUOTA -> e.putLong(KEY_BALANCE, 0L).putInt(KEY_TRIAL_REMAIN, 0).putInt(KEY_TRIAL_USED, DEFAULT_TRIAL_LIMIT)
|
||||
AccountState.QUOTA -> e.putLong(KEY_BALANCE, 0L).putInt(KEY_TRIAL_USED, DEFAULT_TRIAL_LIMIT)
|
||||
.putString(KEY_TOKEN, MOCK_TOKEN).putString(KEY_NICKNAME, "wang***")
|
||||
AccountState.GUEST -> e.putLong(KEY_BALANCE, 0L).putInt(KEY_TRIAL_REMAIN, 0).putInt(KEY_TRIAL_USED, 0)
|
||||
AccountState.GUEST -> e.putLong(KEY_BALANCE, 0L).putInt(KEY_TRIAL_USED, 0)
|
||||
.remove(KEY_TOKEN).remove(KEY_NICKNAME)
|
||||
}
|
||||
e.apply()
|
||||
@@ -180,9 +191,7 @@ class AccountRepository private constructor(context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val PREFS = "dudu_account"
|
||||
private const val KEY_STATE = "state"
|
||||
private const val KEY_BALANCE = "balance_seconds"
|
||||
private const val KEY_TRIAL_REMAIN = "trial_remaining_seconds"
|
||||
private const val KEY_TRIAL_LIMIT = "trial_daily_limit_seconds"
|
||||
private const val KEY_TRIAL_USED = "trial_used_today_seconds"
|
||||
private const val KEY_TOKEN = "token"
|
||||
|
||||
@@ -83,6 +83,8 @@ class KeyboardController(
|
||||
|
||||
private var phase = Phase.IDLE
|
||||
private var discarded = false // 上滑取消后置位:后续 final 全部丢弃
|
||||
private var started = false // start 帧是否已写出(onOpen 内置位)
|
||||
private var pendingStop = false // 用户在 onOpen 前松开:缓冲 stop,待 start 发完补发
|
||||
|
||||
init {
|
||||
// 账户缓存变化(主 App 切 mock 态 / usage 帧刷新)→ 工具条实时更新
|
||||
@@ -115,6 +117,8 @@ class KeyboardController(
|
||||
|
||||
phase = Phase.RECORDING
|
||||
discarded = false
|
||||
started = false
|
||||
pendingStop = false
|
||||
sessionId = "c-" + UUID.randomUUID()
|
||||
_state.value = _state.value.copy(
|
||||
recording = true, willCancel = false,
|
||||
@@ -158,7 +162,13 @@ class KeyboardController(
|
||||
releaseAtMs = SystemClock.elapsedRealtime()
|
||||
phase = Phase.FINALIZING
|
||||
stopAudioAndTicker()
|
||||
socket?.sendStop(sessionId)
|
||||
// 弱网下握手 >300ms 时 start 尚未写出:缓冲 stop,待 onOpen 发完 start 后补发,
|
||||
// 保证服务端始终收到有序的 start→audio→stop(参考 iOS DictationController pendingFrames)。
|
||||
if (started) {
|
||||
socket?.sendStop(sessionId)
|
||||
} else {
|
||||
pendingStop = true
|
||||
}
|
||||
_state.value = _state.value.copy(recording = false, willCancel = false)
|
||||
|
||||
// 收尾兜底:超时仍未收到 final → commit 已有内容
|
||||
@@ -204,10 +214,19 @@ class KeyboardController(
|
||||
|
||||
override fun onOpen() {
|
||||
scope.launch {
|
||||
if (phase != Phase.RECORDING) return@launch
|
||||
// 会话已彻底结束(取消/断连)才忽略;FINALIZING(onOpen 前松开)仍需先发 start。
|
||||
if (phase == Phase.IDLE) return@launch
|
||||
metrics.track("ws.connect_ms", mapOf("ms" to SystemClock.elapsedRealtime() - wsConnectAtMs))
|
||||
socket?.sendStart(sessionId)
|
||||
// start 发出后才开始推音频帧
|
||||
started = true
|
||||
// 用户在握手完成前就松开:start 已发出,立刻补发缓冲的 stop,保证 start→stop 有序。
|
||||
if (pendingStop) {
|
||||
pendingStop = false
|
||||
socket?.sendStop(sessionId)
|
||||
return@launch
|
||||
}
|
||||
// 仍在录音:start 发出后才开始推音频帧
|
||||
if (phase != Phase.RECORDING) return@launch
|
||||
audio = AudioStreamer(
|
||||
onFrame = { frame ->
|
||||
if (!gotFirstFrame) {
|
||||
|
||||
@@ -16,7 +16,9 @@ import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -62,7 +64,9 @@ fun KeyboardScreen(
|
||||
onOpenApp: () -> Unit,
|
||||
) {
|
||||
val p = LocalDuduPalette.current
|
||||
val ui by controller.state.collectAsState()
|
||||
// 单点收集整个状态,但下面各区只通过 derivedStateOf 读各自所需字段:
|
||||
// partial(~5-10/s)只重组 PartialBar,不波及 Toolbar / MicBar / CancelHint。
|
||||
val ui = controller.state.collectAsState()
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
@@ -76,17 +80,26 @@ fun KeyboardScreen(
|
||||
) {
|
||||
Toolbar(ui, onOpenApp)
|
||||
|
||||
val showTextBar = ui.recording || ui.finalText.isNotEmpty() || ui.partialText.isNotEmpty()
|
||||
// 中部区域选择:录音 / 已有文本 → 文本条;否则 notice / 功能键行。
|
||||
// showTextBar 仅在"有无文本/是否录音"翻转时变化,partial 持续刷新不触发本处重组。
|
||||
val showTextBar by remember(ui) {
|
||||
derivedStateOf {
|
||||
ui.value.recording || ui.value.finalText.isNotEmpty() || ui.value.partialText.isNotEmpty()
|
||||
}
|
||||
}
|
||||
val notice by remember(ui) { derivedStateOf { ui.value.notice } }
|
||||
when {
|
||||
showTextBar -> PartialBar(ui)
|
||||
ui.notice != null -> NoticeBar(ui.notice!!)
|
||||
notice != null -> NoticeBar(notice!!)
|
||||
else -> FunctionRow(onSwitchIme, onText, onBackspace, onEnter)
|
||||
}
|
||||
|
||||
Box(Modifier.padding(horizontal = 2.dp)) {
|
||||
val micBarState by remember(ui) { derivedStateOf { micState(ui.value) } }
|
||||
val willCancel by remember(ui) { derivedStateOf { ui.value.willCancel } }
|
||||
MicBar(
|
||||
state = micState(ui),
|
||||
willCancel = ui.willCancel,
|
||||
state = micBarState,
|
||||
willCancel = willCancel,
|
||||
onPressDown = controller::onPressDown,
|
||||
onDrag = controller::onDrag,
|
||||
onRelease = controller::onRelease,
|
||||
@@ -94,7 +107,8 @@ fun KeyboardScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (ui.recording) CancelHint(ui.willCancel)
|
||||
val recording by remember(ui) { derivedStateOf { ui.value.recording } }
|
||||
if (recording) CancelHint(ui)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,8 +122,12 @@ private fun micState(ui: KeyboardUiState): MicBarState = when {
|
||||
// ─── 工具条:左 logo + 名称,右 账户态文案(设计规范 6.2) ─────────────────────
|
||||
|
||||
@Composable
|
||||
private fun Toolbar(ui: KeyboardUiState, onOpenApp: () -> Unit) {
|
||||
private fun Toolbar(ui: State<KeyboardUiState>, onOpenApp: () -> Unit) {
|
||||
val p = LocalDuduPalette.current
|
||||
// 仅读 recording / seconds / account:partial 刷新不进入本作用域。
|
||||
val recording by remember(ui) { derivedStateOf { ui.value.recording } }
|
||||
val seconds by remember(ui) { derivedStateOf { ui.value.seconds } }
|
||||
val account by remember(ui) { derivedStateOf { ui.value.account } }
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -130,16 +148,16 @@ private fun Toolbar(ui: KeyboardUiState, onOpenApp: () -> Unit) {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when {
|
||||
ui.recording -> DText(fmtTimer(ui.seconds), 12.sp, p.positive, mono = true)
|
||||
ui.account.state == AccountState.GUEST -> DText("未登录", 12.sp, p.text3)
|
||||
ui.account.state == AccountState.QUOTA -> DText("今日试用已用完", 12.sp, p.warning)
|
||||
ui.account.state == AccountState.TRIAL ->
|
||||
DText("试用 · 今日剩 ${fmtDuration(ui.account.trialRemainingSeconds.toLong())}", 12.sp, p.text3)
|
||||
recording -> DText(fmtTimer(seconds), 12.sp, p.positive, mono = true)
|
||||
account.state == AccountState.GUEST -> DText("未登录", 12.sp, p.text3)
|
||||
account.state == AccountState.QUOTA -> DText("今日试用已用完", 12.sp, p.warning)
|
||||
account.state == AccountState.TRIAL ->
|
||||
DText("试用 · 今日剩 ${fmtDuration(account.trialRemainingSeconds.toLong())}", 12.sp, p.text3)
|
||||
else -> Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
DText("余额 ${fmtDuration(ui.account.balanceSeconds)}", 12.sp, p.text3)
|
||||
DText("余额 ${fmtDuration(account.balanceSeconds)}", 12.sp, p.text3)
|
||||
IconCheck(11.dp, p.text3)
|
||||
}
|
||||
}
|
||||
@@ -161,14 +179,17 @@ private fun fmtDuration(seconds: Long): String =
|
||||
// ─── partial 文本条:深色玻璃(硬规则),final 亮 / partial 灰拼接 ─────────────
|
||||
|
||||
@Composable
|
||||
private fun PartialBar(ui: KeyboardUiState) {
|
||||
private fun PartialBar(ui: State<KeyboardUiState>) {
|
||||
val p = LocalDuduPalette.current
|
||||
// 只有本作用域读 final / partial:partial 刷新仅重组 PartialBar。
|
||||
val finalText by remember(ui) { derivedStateOf { ui.value.finalText } }
|
||||
val partialText by remember(ui) { derivedStateOf { ui.value.partialText } }
|
||||
val text: AnnotatedString = buildAnnotatedString {
|
||||
if (ui.finalText.isEmpty() && ui.partialText.isEmpty()) {
|
||||
if (finalText.isEmpty() && partialText.isEmpty()) {
|
||||
withStyle(SpanStyle(color = p.overlayText2)) { append("聆听中…") }
|
||||
} else {
|
||||
withStyle(SpanStyle(color = p.overlayText)) { append(ui.finalText) }
|
||||
withStyle(SpanStyle(color = p.overlayText2)) { append(ui.partialText) }
|
||||
withStyle(SpanStyle(color = p.overlayText)) { append(finalText) }
|
||||
withStyle(SpanStyle(color = p.overlayText2)) { append(partialText) }
|
||||
}
|
||||
}
|
||||
Box(
|
||||
@@ -253,8 +274,9 @@ private fun RowScope.FnKey(
|
||||
// ─── 取消提示(仅录音中):"↑ 上滑取消",越过阈值转 danger"松开取消" ───────────
|
||||
|
||||
@Composable
|
||||
private fun CancelHint(willCancel: Boolean) {
|
||||
private fun CancelHint(ui: State<KeyboardUiState>) {
|
||||
val p = LocalDuduPalette.current
|
||||
val willCancel by remember(ui) { derivedStateOf { ui.value.willCancel } }
|
||||
val color = if (willCancel) p.danger else p.text3
|
||||
Row(
|
||||
Modifier
|
||||
|
||||
@@ -41,6 +41,16 @@ object DuduApi {
|
||||
// buildType 注入不同 BuildConfig.API_BASE,此处无需改动。
|
||||
private val base = BuildConfig.API_BASE.trimEnd('/')
|
||||
|
||||
// 设备 ID:与 WS 握手 X-Device-ID 同源(AccountRepository.deviceId),DuduApp.onCreate 注入一次。
|
||||
// 服务端 touchDevice 依赖该头维护 devices 表,缺失则 did=="" 直接 return。
|
||||
@Volatile
|
||||
private var deviceId: String = ""
|
||||
|
||||
/** 进程启动时由 DuduApp 注入设备 ID,供所有 REST 请求带 X-Device-ID 头。 */
|
||||
fun init(deviceId: String) {
|
||||
this.deviceId = deviceId
|
||||
}
|
||||
|
||||
private val client: OkHttpClient by lazy {
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
@@ -174,9 +184,7 @@ object DuduApi {
|
||||
}
|
||||
val req = Request.Builder()
|
||||
.url(base + "/v1/feedback")
|
||||
.header("Authorization", "Bearer $token")
|
||||
.header("X-Platform", "android")
|
||||
.header("X-App-Version", BuildConfig.VERSION_NAME)
|
||||
.commonHeaders(token)
|
||||
.post(mb.build())
|
||||
.build()
|
||||
return exec(req) { body -> JSONObject(body).getString("feedback_id") }
|
||||
@@ -216,15 +224,20 @@ object DuduApi {
|
||||
|
||||
private val JSON = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
/** 统一附加设备/平台头:X-Device-ID(服务端 touchDevice 依赖)、X-Platform、X-App-Version。 */
|
||||
private fun Request.Builder.commonHeaders(token: String?): Request.Builder = apply {
|
||||
if (token != null) header("Authorization", "Bearer $token")
|
||||
if (deviceId.isNotEmpty()) header("X-Device-ID", deviceId)
|
||||
header("X-Platform", "android")
|
||||
header("X-App-Version", BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
private fun getRequest(path: String, token: String?): Request =
|
||||
Request.Builder().url(base + path).apply {
|
||||
if (token != null) header("Authorization", "Bearer $token")
|
||||
}.build()
|
||||
Request.Builder().url(base + path).commonHeaders(token).build()
|
||||
|
||||
private fun jsonRequest(path: String, token: String?, body: JSONObject): Request =
|
||||
Request.Builder().url(base + path).apply {
|
||||
if (token != null) header("Authorization", "Bearer $token")
|
||||
}.post(body.toString().toRequestBody(JSON)).build()
|
||||
Request.Builder().url(base + path).commonHeaders(token)
|
||||
.post(body.toString().toRequestBody(JSON)).build()
|
||||
|
||||
private suspend fun <T> exec(req: Request, parse: (String) -> T): T = withContext(Dispatchers.IO) {
|
||||
val resp = try {
|
||||
|
||||
Reference in New Issue
Block a user