diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/settings/KeepAliveRow.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/settings/KeepAliveRow.kt index db4bbd2..5a43d14 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/settings/KeepAliveRow.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/settings/KeepAliveRow.kt @@ -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 小时,用满会被停下" diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/settings/SettingsScreen.kt index b2a81e6..22bb87b 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/settings/SettingsScreen.kt @@ -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(), + ) + } + } } } } diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/settings/KeepAliveSummaryTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/settings/KeepAliveSummaryTest.kt index 94d68ac..f5030a4 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/ui/settings/KeepAliveSummaryTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/settings/KeepAliveSummaryTest.kt @@ -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(