feat(settings): 退出登录加二次确认;保活那行补系统上限并给「去系统设置」入口

两件事都在这一个切片里,都只碰 ui/settings/**。

一、「退出登录」的确认框
- 现状:W52C 把它改成整行可点的 BasicComponent(红色标题 + Remove 图标),而它紧挨着
  「版本」那一行,两条长得几乎一样 ⇒ 误触代价是一次真登出(AuthManager.logout 会吊销
  服务端 refresh 并把本机凭据整份清空,没有撤销)。W52C 自己把这条风险标了出来。
- 改成:点条目只立一个本页的 remember 状态(不进任何 VM),弹出的确认框里
  「取消 / 点框外 / 系统返回键」三个出口都只是关掉框,一个字都不改;只有确认那一路
  才调 onLogout —— 行为链路与加框之前完全一致。
- 控件用的是 Miuix 自己的 OverlayDialog(top.yukonga.miuix.kmp.overlay),由本页 Scaffold
  自带的 MiuixPopupHost 承载:标题 / 说明 / 内容分槽,正是为这种事准备的。
  (注意 Miuix 0.9.x 并没有 extra.* 包,对话框只在这两个包里,别照旧版 import 去猜。)
- 说明文案如实带上代价:退出后本机登录信息整份清空,服务器地址也要重填。

二、「后台保持连接」那行
- 补事实:dataSync 型前台服务在 targetSdk 37 下,24 小时内累计最多 6 小时会被系统回调
  onTimeout(KeepAliveService 到点自停以免 ANR)⇒「全天候保活」做不到。原先这句只写在
  KeepAliveService 的代码注释里,用户看不到;现在写进开关下面那行字,且落在「关 / 开」
  两档——只写在「已开」那一档等于默认所有人都已经开过一遍,而默认态恰恰是关的。
- 补入口:权限把功能挡住的两档(刚被拒 / 开关开着但权限被撤)原先只说「到系统设置里允许」,
  用户得自己摸进去找。现在这两档下面多一个「去系统设置」按钮,直接跳到本 App 的通知设置页
  (ACTION_APP_NOTIFICATION_SETTINGS + EXTRA_APP_PACKAGE;厂商砍了这个页面就退到应用详情页,
  两次都失败就什么都不做,绝不崩)。按钮出现的条件与那两句文案一一对应,两处不会分叉。
- 顺手复核:点了按钮从系统设置回来时,由 ActivityResult 回调现读一次真实授权,把过期的
  「刚刚被拒」清掉 —— 比挂生命周期钩子直接,也不需要它。
- 测试:KeepAliveSummaryTest 原有的 4 条断言一条没动(新增事实挂在「关 / 开」两档,正好
  不吃「已开」与「起不来」那两条);新增两条 —— ① 关 / 开两档都必须写明 24 小时窗口与
  6 小时上限;② 权限挡住的那两档都必须把用户指向系统设置(与按钮的判据同源)。

验收:./gradlew.bat --console=plain assembleDebug testDebugUnitTest
     BUILD SUCCESSFUL;101 suite / 1272 tests / 0 失败(基线 101/1270)。
This commit is contained in:
AskaEth committed 2026-10-06 18:52:31 +08:00
1 parent f8ac95ceed
commit bbd02fc279
3 files changed
+183 -14

No files matched your search

@@ -1,8 +1,13 @@
package top.yeij.cyrene.ui.settings
import android.Manifest
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -19,6 +24,7 @@ import top.yeij.cyrene.core.prefs.AppPrefs
import top.yeij.cyrene.core.prefs.AppSettings
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.preference.SwitchPreference
import top.yukonga.miuix.kmp.theme.MiuixTheme
@@ -26,8 +32,9 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme
* 设置页的「后台保持连接」一行。
*
* ## 为什么单独一个文件
* `SettingsScreen.kt` 此刻正被另一个切片(登出条目)编辑。为了合并时不打架,这一行的实现全部
* 落在本文件里,`SettingsScreen.kt` 那边只加一行调用。
* 这一行自带状态与权限流程(授权状态、刚被拒的标记、发起申请、跳系统设置),是这块功能里唯一
* 会被改动的地方。`SettingsScreen.kt` 那边只留一行调用:改这里不会碰到别的行,改别的行也不会
* 碰到这里。
*
* ## 它只改一个偏好,**不起停服务**
* 起停只有一个主人(`CyreneApp` 里那个 `LaunchedEffect` + `core.keepalive.keepAliveAction`):
@@ -37,6 +44,8 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme
* ## 权限被拒时不假装成功
* `POST_NOTIFICATIONS` 是前台服务的**可见性**来源(minSdk 33,它是运行时权限)。被拒时本端
* **不打开开关**,并把原因写在开关下面:一个「开着但其实没跑」的开关比一个关着的开关更坏。
* 话说到「到系统设置里允许」为止还不够——用户不该自己摸进系统设置去找那一栏,所以那两档下面
* 直接给一个按钮,替他跳过去(见 [notificationSettingsFallback])。
*
* @param settings 偏好写入口。
* @param prefs 当前偏好(开关的显示态)。
@@ -46,9 +55,12 @@ internal fun KeepAliveRow(settings: AppSettings, prefs: AppPrefs) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
// 授权状态只在**本页内**会变:要么是我们刚发起的那次申请,要么是从系统设置回来重新进这一页。
// 刻意不做「回前台重查」:那需要额外的生命周期钩子,而这块的价值全在「用户动作的那一刻说真话」,
// 不在毫秒级同步系统设置(真被撤销时,服务的起停判据在下次启动时照样读得到)。
// 授权状态在本页内会变的只有两条路:我们刚发起的那次申请,以及我们刚把用户送去系统设置
// 又回来。后者靠 [openNotificationSettings] 的结果回调**直接复核**——从系统设置回来那一刻
// 才知道用户改成了什么,所以不需要再挂一个生命周期钩子去猜「是不是又回前台了」。
//
// ⚠️ 复核只纠正**这句话**,不改开关、也不起停服务:服务的起停判据在 `CyreneApp`,
// 而它的 `LaunchedEffect` 只认「开关」与「登录态」这两个键(权限是每次现读的入参)。
var allowed by remember { mutableStateOf(context.notificationsAllowed()) }
var justDenied by remember { mutableStateOf(false) }
@@ -60,6 +72,45 @@ internal fun KeepAliveRow(settings: AppSettings, prefs: AppPrefs) {
if (granted) scope.launch { settings.setKeepAliveEnabled(true) }
}
// 从系统设置回来:无论结果码是什么(那个页面一般回 RESULT_CANCELED),都现读一次真实授权。
val openNotificationSettings = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) {
allowed = context.notificationsAllowed()
// 刚回来时读到的就是最终答案,那句「刚刚被拒」已经过期了——留着它才是说假话。
justDenied = false
}
// 与 [keepAliveSummary] 里那两档**一一对应**:文案叫你「到系统设置里允许」,按钮就带你去
// 那里。这样两处不会各说各话(改了文案忘了按钮、或反过来,都会让用户觉得被耍)。
//
// 类型写全是有意的:`bottomAction` 收的是可空的可组合 lambda,写成 `if (...) null else {...}`
// 那种内联形状时要靠推导去猜哪一支是 `@Composable`,显式标出来省得编译器为难。
val settingsShortcut: (@Composable () -> Unit)? =
if (!allowed && (justDenied || prefs.keepAliveEnabled)) {
{
TextButton(
text = "去系统设置",
onClick = {
// 先跳正式做法;有的 ROM 把那个页面砍了(`launch` 会抛
// ActivityNotFoundException)⇒ 退到「应用详情页」,那里也有一栏「通知」。
// 两次都失败就等于点了没反应,此时宁可什么都不做,也不让它崩。
val direct = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
val launched = runCatching { openNotificationSettings.launch(direct) }.isSuccess
if (!launched) {
runCatching {
openNotificationSettings.launch(notificationSettingsFallback(context))
}
}
},
modifier = Modifier.fillMaxWidth(),
)
}
} else {
null
}
KeepAliveSectionTitle()
PreferenceCard {
SwitchPreference(
@@ -79,14 +130,26 @@ internal fun KeepAliveRow(settings: AppSettings, prefs: AppPrefs) {
else -> requestNotification.launch(Manifest.permission.POST_NOTIFICATIONS)
}
},
bottomAction = settingsShortcut,
)
}
}
/**
* 分段标题。样式与 `SettingsScreen.SectionTitle` 逐字一致——它是那边的 `private`,而本文件
* **不许**改那个文件(另一个切片正在编辑),所以只能照抄这一小段。两处将来若分叉,
* 先动的是这个注释。
* 「去系统设置」的兜底去处。
*
* 首选是 `ACTION_APP_NOTIFICATION_SETTINGS` + `EXTRA_APP_PACKAGE`,那是 API 26 起的正式做法;
* 本端 minSdk 33,所以旧版那对 `"app_package"` / `"app_uid"` extra 与 API 24/25 的差异都用不着管。
* 剩下的一处差异不是 API 而是**厂商**:这个页面不是每个 ROM 都留着,砍了就退到这里
* (应用详情页,多一层才能点到「通知」)。
*/
private fun notificationSettingsFallback(context: Context): Intent =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
.setData(Uri.fromParts("package", context.packageName, null))
/**
* 分段标题。样式与 `SettingsScreen.SectionTitle` 逐字一致:那边那个是 `private` 的,而这里要
* 的只是同一段样式,为此把它的可见性放宽不划算。两处将来若分叉,先动的是这个注释。
*/
@Composable
private fun KeepAliveSectionTitle() {
@@ -103,8 +166,16 @@ private fun KeepAliveSectionTitle() {
* 「关着会怎样」「开着要付出什么」「为什么没打开」——三件事都得说,且顺序上后者优先
* (用户刚被拒的那一次是此刻最要紧的信息)。
*
* 开的代价**必须自己说出来**(常驻通知 + 多耗电):不说的代价会变成用户某天
* 「莫名其妙多了条通知」的意外,那比不开这个功能更伤信任。
* 代价**必须自己说出来**,而且是**两份**:
* - 用户付的那份:一条常驻通知 + 多耗一点电。不说的代价会变成他某天「莫名其妙多了条通知」
* 的意外,那比不开这个功能更伤信任。
* - 系统收走的那份:`dataSync` 型前台服务在 targetSdk 37 下,**24 小时内累计最多 6 小时**
* (`KeepAliveService.onTimeout` 到点自己收手,不硬撑到 ANR)。所以「全天候保活」做不到,
* 这句话不能只对他一个人藏着——一个以为买了全天候、结果 6 小时后悄悄断开的用户,
* 比一个从没开过这个开关的用户更亏。
*
* 因此「关 / 开」两档都带上了这条上限([CAP_NOTE]):关着的人要据此判断值不值得开,
* 开着的人要知道自己买到的到底是什么。
*/
internal fun keepAliveSummary(
enabled: Boolean,
@@ -118,8 +189,17 @@ internal fun keepAliveSummary(
"通知权限现在是关的,服务起不来:到系统设置里允许 Cyrene 发送通知后才会真的生效"
enabled ->
"已开:会常驻一条静默通知(不响铃、不震动),并多耗一点电;换来的是切到后台久了,实时连接也不被系统掐断"
"已开:会常驻一条静默通知(不响铃、不震动),并多耗一点电;换来的是切到后台久了,实时连接也不被系统掐断。$CAP_NOTE"
else ->
"关:切到后台久了,系统可能冻结本进程并断开实时连接;消息不会丢(服务端已落库),回到前台会自动补上"
"关:切到后台久了,系统可能冻结本进程并断开实时连接;消息不会丢(服务端已落库),回到前台会自动补上。$CAP_NOTE"
}
/**
* 系统给这类前台服务的额度。措辞刻意**不带情绪**:它是一个客观上限,不是遗憾——
* 说清了,用户才知道这开关换来的是「更久」而不是「永远」。
*
* 数字与 [top.yeij.cyrene.core.keepalive.KeepAliveService.onTimeout] 的注释同源(那边是机制,
* 这边是用户可见的说法);改任何一处都得两处一起改。
*/
private const val CAP_NOTE = "不过系统对这种服务有上限:24 小时内累计最多 6 小时,用满会被停下"
@@ -1,16 +1,23 @@
package top.yeij.cyrene.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
@@ -22,15 +29,18 @@ import top.yeij.cyrene.core.prefs.ThemeMode
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.BasicComponent
import top.yukonga.miuix.kmp.basic.BasicComponentDefaults
import top.yukonga.miuix.kmp.basic.ButtonDefaults
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.ChevronBackward
import top.yukonga.miuix.kmp.icon.extended.ChevronForward
import top.yukonga.miuix.kmp.icon.extended.Remove
import top.yukonga.miuix.kmp.overlay.OverlayDialog
import top.yukonga.miuix.kmp.preference.SwitchPreference
import top.yukonga.miuix.kmp.theme.MiuixTheme
@@ -44,6 +54,8 @@ fun SettingsScreen(
val prefs by container.appSettings.data.collectAsState(initial = AppPrefs())
val scope = rememberCoroutineScope()
val settings = container.appSettings
// 登出的二次确认。纯 UI 的开关,不进任何 VM:它不表达应用状态,只表达「这一次手指落在哪」。
var confirmLogout by remember { mutableStateOf(false) }
Scaffold(
topBar = {
@@ -163,10 +175,51 @@ fun SettingsScreen(
tint = MiuixTheme.colorScheme.error,
)
},
onClick = onLogout,
onClick = { confirmLogout = true },
)
}
Spacer(Modifier.height(32.dp))
/*
* 退出登录的确认框。
*
* 为什么非要有:这一条是**整行可点**,而它紧挨着上面那条「版本」——两条长得一样、
* 只差一行,误触的代价却是一次真登出(`AuthManager.logout` 会吊销服务端 refresh
* 并把本机凭据整份清掉,没有「撤销」)。一行可点的危险动作,必须再问一次。
*
* 用的是 Miuix 自己的 `OverlayDialog`(`top.yukonga.miuix.kmp.overlay`):它由
* 本页 `Scaffold` 自带的 `MiuixPopupHost` 承载,标题 / 说明 / 内容分槽。注意 Miuix
* 没有 `extra.*` 包(0.9.x 里对话框就在 `overlay` / `window` 两处),别照旧版本的
* import 去猜。
*
* 三处出口刻意一致:取消按钮、点框外、系统返回键全都只是关掉这个框,一个字都不改;
* 只有「退出登录」这一条路通向 [onLogout]——行为链路与加这个框之前一模一样。
*/
OverlayDialog(
show = confirmLogout,
title = "退出登录?",
// 如实说清代价(服务器地址也要重填):登出后回到登录页,那儿是空的。
summary = "退出后本机会清空登录信息(服务器地址也要重填),需要重新登录",
onDismissRequest = { confirmLogout = false },
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(text = "取消", onClick = { confirmLogout = false })
Spacer(Modifier.width(8.dp))
TextButton(
text = "退出登录",
onClick = {
// 先把框收掉再登出:登出会把整个设置页(连同这个框的 remember)从
// 组合里摘掉,留着它等于把一次「正在关闭」的动画交给一个正在消失的宿主。
confirmLogout = false
onLogout()
},
colors = ButtonDefaults.textButtonColorsPrimary(),
)
}
}
}
}
}
@@ -11,7 +11,13 @@ import org.junit.Test
* 这一行的价值全在「说真话」,所以钉的也是真话:
* 1. **开的代价自己说出来**(常驻通知 + 多耗电)——不说的代价会变成用户某天「莫名多了条通知」的意外;
* 2. **跑不了的时候不许说在跑**(通知权限被拒 / 被撤时,一个字都不提「已开」);
* 3. 四个分支互不相同:任何一次改文案都不该把某一档悄悄吞掉。
* 3. **能跑多久也要说**——系统给 `dataSync` 型前台服务的额度是 24 小时窗口内累计 6 小时
* (`KeepAliveService.onTimeout` 到点自停),所以「全天候保活」做不到;这句必须落在
* 用户据以做决定的「关 / 开」两档里,否则他会以为自己买到了永远;
* 4. **话与按钮指着同一个地方**——权限那两档写着「到系统设置里允许」,设置页那个
* 「去系统设置」按钮就出现在这两档下(`KeepAliveRow` 里算 `settingsShortcut` 的那个条件
* 与之一一对应);
* 5. 四个分支互不相同:任何一次改文案都不该把某一档悄悄吞掉。
*/
class KeepAliveSummaryTest {
@@ -47,6 +53,36 @@ class KeepAliveSummaryTest {
assertFalse("被拒时不许写成「已开」:$text", text.contains("已开"))
}
/**
* 「关」和「开」两档是用户据以做决定的那两档,系统上限必须出现在这里——
* 只在「已开」那一档说,等于默认所有人都已经开过一遍(而默认态恰恰是关的)。
*/
@Test
fun `both the off and the on state admit the system's ceiling`() {
val off = keepAliveSummary(enabled = false, notificationsAllowed = true, permissionJustDenied = false)
val on = keepAliveSummary(enabled = true, notificationsAllowed = true, permissionJustDenied = false)
listOf("关" to off, "开" to on).forEach { (state, text) ->
assertTrue("「$state」那一档必须写明是 24 小时的窗口:$text", text.contains("24 小时"))
assertTrue("「$state」那一档必须写明累计上限是 6 小时:$text", text.contains("6 小时"))
}
}
/**
* 权限把功能挡住的那两档都写了「到系统设置里允许」,而设置页那个「去系统设置」按钮
* 正好只在 `!allowed && (permissionJustDenied || enabled)` 时出现——正是这两档。
* 钉住这一点是为了防止两边分叉:文案让人去 A、按钮把人送去 B,比没有按钮更糟。
*/
@Test
fun `the permission-blocked states point at the system settings`() {
val revoked = keepAliveSummary(enabled = true, notificationsAllowed = false, permissionJustDenied = false)
val denied = keepAliveSummary(enabled = false, notificationsAllowed = false, permissionJustDenied = true)
listOf("权限被撤" to revoked, "刚被拒" to denied).forEach { (state, text) ->
assertTrue("「$state」那一档得把用户指向系统设置:$text", text.contains("系统设置"))
}
}
@Test
fun `the four states are told apart`() {
val texts = listOf(