merge: W53S —— 退出登录加 Miuix 确认对话框;保活那行补「24 小时累计 6 小时」上限事实 + 权限被拒时的「去系统设置」入口
This commit is contained in:
commit
6505512c0a
3 files changed
+183
-14
No files matched your search
@@ -1,8 +1,13 @@
|
|||||||
package top.yeij.cyrene.ui.settings
|
package top.yeij.cyrene.ui.settings
|
||||||
|
|
||||||
import android.Manifest
|
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.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
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.core.prefs.AppSettings
|
||||||
import top.yeij.cyrene.ui.common.PreferenceCard
|
import top.yeij.cyrene.ui.common.PreferenceCard
|
||||||
import top.yukonga.miuix.kmp.basic.Text
|
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.preference.SwitchPreference
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
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`):
|
* 起停只有一个主人(`CyreneApp` 里那个 `LaunchedEffect` + `core.keepalive.keepAliveAction`):
|
||||||
@@ -37,6 +44,8 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme
|
|||||||
* ## 权限被拒时不假装成功
|
* ## 权限被拒时不假装成功
|
||||||
* `POST_NOTIFICATIONS` 是前台服务的**可见性**来源(minSdk 33,它是运行时权限)。被拒时本端
|
* `POST_NOTIFICATIONS` 是前台服务的**可见性**来源(minSdk 33,它是运行时权限)。被拒时本端
|
||||||
* **不打开开关**,并把原因写在开关下面:一个「开着但其实没跑」的开关比一个关着的开关更坏。
|
* **不打开开关**,并把原因写在开关下面:一个「开着但其实没跑」的开关比一个关着的开关更坏。
|
||||||
|
* 话说到「到系统设置里允许」为止还不够——用户不该自己摸进系统设置去找那一栏,所以那两档下面
|
||||||
|
* 直接给一个按钮,替他跳过去(见 [notificationSettingsFallback])。
|
||||||
*
|
*
|
||||||
* @param settings 偏好写入口。
|
* @param settings 偏好写入口。
|
||||||
* @param prefs 当前偏好(开关的显示态)。
|
* @param prefs 当前偏好(开关的显示态)。
|
||||||
@@ -46,9 +55,12 @@ internal fun KeepAliveRow(settings: AppSettings, prefs: AppPrefs) {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
// 授权状态只在**本页内**会变:要么是我们刚发起的那次申请,要么是从系统设置回来重新进这一页。
|
// 授权状态在本页内会变的只有两条路:我们刚发起的那次申请,以及我们刚把用户送去系统设置
|
||||||
// 刻意不做「回前台重查」:那需要额外的生命周期钩子,而这块的价值全在「用户动作的那一刻说真话」,
|
// 又回来。后者靠 [openNotificationSettings] 的结果回调**直接复核**——从系统设置回来那一刻
|
||||||
// 不在毫秒级同步系统设置(真被撤销时,服务的起停判据在下次启动时照样读得到)。
|
// 才知道用户改成了什么,所以不需要再挂一个生命周期钩子去猜「是不是又回前台了」。
|
||||||
|
//
|
||||||
|
// ⚠️ 复核只纠正**这句话**,不改开关、也不起停服务:服务的起停判据在 `CyreneApp`,
|
||||||
|
// 而它的 `LaunchedEffect` 只认「开关」与「登录态」这两个键(权限是每次现读的入参)。
|
||||||
var allowed by remember { mutableStateOf(context.notificationsAllowed()) }
|
var allowed by remember { mutableStateOf(context.notificationsAllowed()) }
|
||||||
var justDenied by remember { mutableStateOf(false) }
|
var justDenied by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -60,6 +72,45 @@ internal fun KeepAliveRow(settings: AppSettings, prefs: AppPrefs) {
|
|||||||
if (granted) scope.launch { settings.setKeepAliveEnabled(true) }
|
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()
|
KeepAliveSectionTitle()
|
||||||
PreferenceCard {
|
PreferenceCard {
|
||||||
SwitchPreference(
|
SwitchPreference(
|
||||||
@@ -79,14 +130,26 @@ internal fun KeepAliveRow(settings: AppSettings, prefs: AppPrefs) {
|
|||||||
else -> requestNotification.launch(Manifest.permission.POST_NOTIFICATIONS)
|
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
|
@Composable
|
||||||
private fun KeepAliveSectionTitle() {
|
private fun KeepAliveSectionTitle() {
|
||||||
@@ -103,8 +166,16 @@ private fun KeepAliveSectionTitle() {
|
|||||||
* 「关着会怎样」「开着要付出什么」「为什么没打开」——三件事都得说,且顺序上后者优先
|
* 「关着会怎样」「开着要付出什么」「为什么没打开」——三件事都得说,且顺序上后者优先
|
||||||
* (用户刚被拒的那一次是此刻最要紧的信息)。
|
* (用户刚被拒的那一次是此刻最要紧的信息)。
|
||||||
*
|
*
|
||||||
* 开的代价**必须自己说出来**(常驻通知 + 多耗电):不说的代价会变成用户某天
|
* 代价**必须自己说出来**,而且是**两份**:
|
||||||
* 「莫名其妙多了条通知」的意外,那比不开这个功能更伤信任。
|
* - 用户付的那份:一条常驻通知 + 多耗一点电。不说的代价会变成他某天「莫名其妙多了条通知」
|
||||||
|
* 的意外,那比不开这个功能更伤信任。
|
||||||
|
* - 系统收走的那份:`dataSync` 型前台服务在 targetSdk 37 下,**24 小时内累计最多 6 小时**
|
||||||
|
* (`KeepAliveService.onTimeout` 到点自己收手,不硬撑到 ANR)。所以「全天候保活」做不到,
|
||||||
|
* 这句话不能只对他一个人藏着——一个以为买了全天候、结果 6 小时后悄悄断开的用户,
|
||||||
|
* 比一个从没开过这个开关的用户更亏。
|
||||||
|
*
|
||||||
|
* 因此「关 / 开」两档都带上了这条上限([CAP_NOTE]):关着的人要据此判断值不值得开,
|
||||||
|
* 开着的人要知道自己买到的到底是什么。
|
||||||
*/
|
*/
|
||||||
internal fun keepAliveSummary(
|
internal fun keepAliveSummary(
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
@@ -118,8 +189,17 @@ internal fun keepAliveSummary(
|
|||||||
"通知权限现在是关的,服务起不来:到系统设置里允许 Cyrene 发送通知后才会真的生效"
|
"通知权限现在是关的,服务起不来:到系统设置里允许 Cyrene 发送通知后才会真的生效"
|
||||||
|
|
||||||
enabled ->
|
enabled ->
|
||||||
"已开:会常驻一条静默通知(不响铃、不震动),并多耗一点电;换来的是切到后台久了,实时连接也不被系统掐断"
|
"已开:会常驻一条静默通知(不响铃、不震动),并多耗一点电;换来的是切到后台久了,实时连接也不被系统掐断。$CAP_NOTE"
|
||||||
|
|
||||||
else ->
|
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
|
package top.yeij.cyrene.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -22,15 +29,18 @@ import top.yeij.cyrene.core.prefs.ThemeMode
|
|||||||
import top.yeij.cyrene.ui.common.PreferenceCard
|
import top.yeij.cyrene.ui.common.PreferenceCard
|
||||||
import top.yukonga.miuix.kmp.basic.BasicComponent
|
import top.yukonga.miuix.kmp.basic.BasicComponent
|
||||||
import top.yukonga.miuix.kmp.basic.BasicComponentDefaults
|
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.Icon
|
||||||
import top.yukonga.miuix.kmp.basic.IconButton
|
import top.yukonga.miuix.kmp.basic.IconButton
|
||||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||||
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
|
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
|
||||||
import top.yukonga.miuix.kmp.basic.Text
|
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.MiuixIcons
|
||||||
import top.yukonga.miuix.kmp.icon.extended.ChevronBackward
|
import top.yukonga.miuix.kmp.icon.extended.ChevronBackward
|
||||||
import top.yukonga.miuix.kmp.icon.extended.ChevronForward
|
import top.yukonga.miuix.kmp.icon.extended.ChevronForward
|
||||||
import top.yukonga.miuix.kmp.icon.extended.Remove
|
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.preference.SwitchPreference
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
|
|
||||||
@@ -44,6 +54,8 @@ fun SettingsScreen(
|
|||||||
val prefs by container.appSettings.data.collectAsState(initial = AppPrefs())
|
val prefs by container.appSettings.data.collectAsState(initial = AppPrefs())
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val settings = container.appSettings
|
val settings = container.appSettings
|
||||||
|
// 登出的二次确认。纯 UI 的开关,不进任何 VM:它不表达应用状态,只表达「这一次手指落在哪」。
|
||||||
|
var confirmLogout by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -163,10 +175,51 @@ fun SettingsScreen(
|
|||||||
tint = MiuixTheme.colorScheme.error,
|
tint = MiuixTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = onLogout,
|
onClick = { confirmLogout = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(32.dp))
|
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. **开的代价自己说出来**(常驻通知 + 多耗电)——不说的代价会变成用户某天「莫名多了条通知」的意外;
|
* 1. **开的代价自己说出来**(常驻通知 + 多耗电)——不说的代价会变成用户某天「莫名多了条通知」的意外;
|
||||||
* 2. **跑不了的时候不许说在跑**(通知权限被拒 / 被撤时,一个字都不提「已开」);
|
* 2. **跑不了的时候不许说在跑**(通知权限被拒 / 被撤时,一个字都不提「已开」);
|
||||||
* 3. 四个分支互不相同:任何一次改文案都不该把某一档悄悄吞掉。
|
* 3. **能跑多久也要说**——系统给 `dataSync` 型前台服务的额度是 24 小时窗口内累计 6 小时
|
||||||
|
* (`KeepAliveService.onTimeout` 到点自停),所以「全天候保活」做不到;这句必须落在
|
||||||
|
* 用户据以做决定的「关 / 开」两档里,否则他会以为自己买到了永远;
|
||||||
|
* 4. **话与按钮指着同一个地方**——权限那两档写着「到系统设置里允许」,设置页那个
|
||||||
|
* 「去系统设置」按钮就出现在这两档下(`KeepAliveRow` 里算 `settingsShortcut` 的那个条件
|
||||||
|
* 与之一一对应);
|
||||||
|
* 5. 四个分支互不相同:任何一次改文案都不该把某一档悄悄吞掉。
|
||||||
*/
|
*/
|
||||||
class KeepAliveSummaryTest {
|
class KeepAliveSummaryTest {
|
||||||
|
|
||||||
@@ -47,6 +53,36 @@ class KeepAliveSummaryTest {
|
|||||||
assertFalse("被拒时不许写成「已开」:$text", text.contains("已开"))
|
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
|
@Test
|
||||||
fun `the four states are told apart`() {
|
fun `the four states are told apart`() {
|
||||||
val texts = listOf(
|
val texts = listOf(
|
||||||
|
|||||||
Reference in new issue
Block a user