merge: W53S —— 退出登录加 Miuix 确认对话框;保活那行补「24 小时累计 6 小时」上限事实 + 权限被拒时的「去系统设置」入口

This commit is contained in:
AskaEth committed 2026-10-06 18:53:34 +08:00
commit 6505512c0a
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(