diff --git a/AGENTS.md b/AGENTS.md index e7314973..8234cab6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ ## 测试策略 - 新增测试必须验证可观察行为,明确被测输入、预期输出和要防止的具体回归。优先覆盖纯函数、边界条件、异常路径、平台或版本兼容差异,以及已修复缺陷的回归场景。 +- 禁止仅为增加测试而拆散本应聚合的生产逻辑、扩大声明可见性或暴露测试专用 API;测试必须适配合理的生产设计,而不是反向塑造生产代码。 - 禁止新增仅复述生产代码静态声明的测试,包括枚举成员、常量取值或集合、连续编号、由同一注册表推导出的成员关系,以及 Kotlin 类型系统已经保证的约束。 - 只有当常量或标识属于外部协议、持久化格式或跨版本兼容契约时,才允许为其新增稳定性测试,并在测试名称或注释中说明要保护的兼容行为。 diff --git a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt index eb1db376..3774418d 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainActivity.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainActivity.kt @@ -8,6 +8,8 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.WindowInsets import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text @@ -20,7 +22,10 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.zIndex import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsAnimationCompat @@ -41,7 +46,7 @@ import kotlinx.coroutines.sync.withLock import li.songe.gkd.a11y.topActivityFlow import li.songe.gkd.a11y.updateSystemDefaultAppId import li.songe.gkd.a11y.updateTopActivity -import li.songe.gkd.permission.AuthDialog +import li.songe.gkd.permission.PermissionRequestHost import li.songe.gkd.permission.PermissionStates import li.songe.gkd.priv.privilegeContextFlow import li.songe.gkd.priv.uiAutomationOccupiedFlow @@ -184,22 +189,28 @@ class MainActivity : ComponentActivity() { LocalMainViewModel provides mainVm ) { AppTheme { - MainNavigation(mainVm) - if (!mainVm.termsAcceptedFlow.collectAsState().value) { - TermsAcceptDialog() - } else { - UiAutomationAlreadyRegisteredDlg() - AccessRestrictedSettingsDlg() - AuthDialog(mainVm.authReasonFlow) - BuildDialog(mainVm.dialogFlow) - mainVm.uploadOptions.ShowDialog() - EditGithubCookieDlg() - mainVm.updateStatus?.UpgradeDialog() - SubsSheet(mainVm, mainVm.sheetSubsIdFlow) - mainVm.inputSubsLinkOption.ContentDialog() - mainVm.ruleGroupState.Render() - TextDialog(mainVm.textFlow) - ShareLogDlg(mainVm.showShareLogDlgFlow) + Box(modifier = Modifier.fillMaxSize()) { + MainNavigation(mainVm) + if (!mainVm.termsAcceptedFlow.collectAsState().value) { + TermsAcceptDialog() + } else { + UiAutomationAlreadyRegisteredDlg() + AccessRestrictedSettingsDlg() + BuildDialog(mainVm.dialogFlow) + mainVm.uploadOptions.ShowDialog() + EditGithubCookieDlg() + mainVm.updateStatus?.UpgradeDialog() + SubsSheet(mainVm, mainVm.sheetSubsIdFlow) + mainVm.inputSubsLinkOption.ContentDialog() + mainVm.ruleGroupState.Render() + TextDialog(mainVm.textFlow) + ShareLogDlg(mainVm.showShareLogDlgFlow) + } + PermissionRequestHost( + modifier = Modifier + .align(Alignment.TopCenter) + .zIndex(1f), + ) } } } diff --git a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt index 3fd11251..beb6971e 100644 --- a/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt +++ b/app/src/main/kotlin/li/songe/gkd/MainViewModel.kt @@ -24,7 +24,6 @@ import li.songe.gkd.data.CrashData import li.songe.gkd.data.RawSubscription import li.songe.gkd.data.SubsItem import li.songe.gkd.db.DbSet -import li.songe.gkd.permission.AuthReason import li.songe.gkd.priv.AutomationService import li.songe.gkd.priv.privilegeContextFlow import li.songe.gkd.priv.uiAutomationFlow @@ -124,7 +123,6 @@ class MainViewModel : BaseViewModel(), OnSimpleLife by DefaultSimpleLifeImpl() { fun navigateWebPage(url: String) = navigatePage(WebViewRoute(url)) val dialogFlow = MutableStateFlow(null) - val authReasonFlow = MutableStateFlow(null) val updateStatus = if (META.updateEnabled) UpdateStatus(viewModelScope) else null diff --git a/app/src/main/kotlin/li/songe/gkd/permission/PermissionDialog.kt b/app/src/main/kotlin/li/songe/gkd/permission/PermissionDialog.kt deleted file mode 100644 index 702de9fa..00000000 --- a/app/src/main/kotlin/li/songe/gkd/permission/PermissionDialog.kt +++ /dev/null @@ -1,74 +0,0 @@ -package li.songe.gkd.permission - -import android.app.Activity -import androidx.activity.compose.LocalActivity -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import kotlinx.coroutines.flow.MutableStateFlow -import li.songe.gkd.MainActivity - -data class AuthReason( - val text: () -> String, - val confirm: ((Activity) -> Unit)? = null, -) - -@Composable -fun AuthDialog(authReasonFlow: MutableStateFlow) { - val authAction = authReasonFlow.collectAsState().value - val context = LocalActivity.current as MainActivity - if (authAction != null) { - AlertDialog( - title = { - Text(text = "权限请求") - }, - text = { - Text(text = authAction.text()) - }, - onDismissRequest = { authReasonFlow.value = null }, - confirmButton = { - TextButton(onClick = { - authReasonFlow.value = null - authAction.confirm?.invoke(context) - }) { - Text(text = "确认") - } - }, - dismissButton = { - TextButton(onClick = { authReasonFlow.value = null }) { - Text(text = "取消") - } - } - ) - } -} - -sealed class PermissionResult { - data object Granted : PermissionResult() - data class Denied(val doNotAskAgain: Boolean) : PermissionResult() -} - -suspend fun ensurePermission( - context: MainActivity, - vararg permissionStates: PermissionState, -): Boolean { - for (permissionState in permissionStates) { - if (permissionState.updateAndGet()) continue - when (val result = permissionState.request?.invoke(context)) { - PermissionResult.Granted -> Unit - null -> { - context.mainVm.authReasonFlow.value = permissionState.reason - return false - } - is PermissionResult.Denied -> { - if (result.doNotAskAgain) { - context.mainVm.authReasonFlow.value = permissionState.reason - } - return false - } - } - } - return true -} diff --git a/app/src/main/kotlin/li/songe/gkd/permission/PermissionRequest.kt b/app/src/main/kotlin/li/songe/gkd/permission/PermissionRequest.kt new file mode 100644 index 00000000..cc164a41 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/permission/PermissionRequest.kt @@ -0,0 +1,511 @@ +package li.songe.gkd.permission + +import android.app.Activity +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.hjq.permissions.OnPermissionDescription +import com.hjq.permissions.XXPermissions +import com.hjq.permissions.permission.base.IPermission +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import li.songe.gkd.MainActivity +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +private val permissionRequestCoordinator = PermissionRequestCoordinator() +private val permissionRequestMutex = Mutex() + +@Composable +fun PermissionRequestHost(modifier: Modifier = Modifier) { + BindPermissionRequestHostLifecycle() + PermissionRequestDialog() + PermissionPromptOverlay(modifier) +} + +@Composable +private fun BindPermissionRequestHostLifecycle() { + val lifecycle = LocalLifecycleOwner.current.lifecycle + val hasWindowFocus = LocalWindowInfo.current.isWindowFocused + var resumed by remember { + mutableStateOf(lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) + } + DisposableEffect(Unit) { + val observer = LifecycleEventObserver { _, _ -> + resumed = lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) + } + lifecycle.addObserver(observer) + onDispose { + lifecycle.removeObserver(observer) + permissionRequestCoordinator.detachHost() + } + } + SideEffect { + permissionRequestCoordinator.updateHostState( + resumed = resumed, + hasWindowFocus = hasWindowFocus, + ) + } +} + +@Composable +private fun PermissionRequestDialog() { + val state = permissionRequestCoordinator.dialog.collectAsState().value + state ?: return + AlertDialog( + title = { + Text(text = state.title) + }, + text = { + Text(text = state.message) + }, + onDismissRequest = { + permissionRequestCoordinator.dismissDialog(state.id) + }, + confirmButton = { + TextButton(onClick = { + permissionRequestCoordinator.confirmDialog(state.id) + }) { + Text(text = state.confirmText) + } + }, + dismissButton = { + TextButton(onClick = { + permissionRequestCoordinator.dismissDialog(state.id) + }) { + Text(text = state.dismissText) + } + }, + ) +} + +@Composable +private fun PermissionPromptOverlay(modifier: Modifier) { + val requestedPrompt by permissionRequestCoordinator.visiblePrompt.collectAsState() + var prompt by remember { mutableStateOf(null) } + LaunchedEffect(requestedPrompt) { + requestedPrompt?.let { delay(it.displayDelayMillis) } + prompt = requestedPrompt + } + var displayedPrompt by remember { mutableStateOf(prompt) } + val visibility = remember { + MutableTransitionState(prompt != null) + } + LaunchedEffect(prompt) { + if (prompt != null) { + displayedPrompt = prompt + } + visibility.targetState = prompt != null + } + LaunchedEffect( + prompt, + visibility.currentState, + visibility.isIdle, + ) { + if (prompt == null && visibility.isIdle && !visibility.currentState) { + displayedPrompt = null + } + } + val currentPrompt = displayedPrompt ?: return + + Box( + modifier = modifier + .fillMaxWidth() + .windowInsetsPadding( + WindowInsets.safeDrawing.only(WindowInsetsSides.Top), + ) + .padding(horizontal = 16.dp, vertical = 12.dp), + contentAlignment = Alignment.TopCenter, + ) { + AnimatedVisibility( + visibleState = visibility, + enter = fadeIn() + slideInVertically(), + exit = fadeOut() + slideOutVertically(), + ) { + PermissionPromptCard(currentPrompt) + } + } +} + +@Composable +private fun PermissionPromptCard(prompt: PermissionPrompt) { + Surface( + modifier = Modifier + .widthIn(max = 560.dp) + .fillMaxWidth() + .semantics(mergeDescendants = true) { + liveRegion = LiveRegionMode.Polite + }, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.inverseSurface, + contentColor = MaterialTheme.colorScheme.inverseOnSurface, + tonalElevation = 6.dp, + shadowElevation = 6.dp, + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = prompt.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = prompt.message, + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +suspend fun ensurePermission( + context: MainActivity, + vararg permissionStates: PermissionState, +): Boolean = withContext(Dispatchers.Main.immediate) { + permissionRequestMutex.withLock { + for (permissionState in permissionStates) { + if (permissionState.refresh()) continue + val permission = permissionState.permission + if (permission == null) { + val resolution = permissionState.resolution ?: return@withLock false + if (permissionRequestCoordinator.awaitResolution(permissionState)) { + resolution.confirm?.invoke() + } + return@withLock false + } + val prompt = PermissionPrompt( + title = "正在申请「${permissionState.name}」", + message = checkNotNull(permissionState.purpose) { + "${permissionState.name} 缺少权限请求说明" + }, + ) + requestPermission( + context = context, + permission = permission, + prompt = prompt, + ) + if (permissionState.refresh()) continue + if (!permissionRequestCoordinator.awaitResolution(permissionState)) { + return@withLock false + } + openPermissionSettings(context, permission) + if (!permissionState.refresh()) { + return@withLock false + } + } + true + } +} + +private suspend fun openPermissionSettings( + context: MainActivity, + permission: IPermission, +): Unit = suspendCancellableCoroutine { continuation -> + runCatching { + XXPermissions.startPermissionActivity(context, permission) { _, _ -> + if (continuation.isActive) { + continuation.resume(Unit) + } + } + }.onFailure { error -> + if (continuation.isActive) { + continuation.resumeWithException(error) + } + } +} + +private suspend fun requestPermission( + context: MainActivity, + permission: IPermission, + prompt: PermissionPrompt, +): Unit = suspendCancellableCoroutine { continuation -> + if (XXPermissions.isGrantedPermission(context, permission)) { + continuation.resume(Unit) + return@suspendCancellableCoroutine + } + val description = GkdPermissionDescription( + prompt = prompt, + ) + runCatching { + XXPermissions.with(context) + .unchecked() + .permission(permission) + .description(description) + .request { _, _ -> + if (!continuation.isActive) return@request + continuation.resume(Unit) + } + }.onFailure { error -> + if (continuation.isActive) { + continuation.resumeWithException(error) + } + } +} + +private class GkdPermissionDescription( + private val prompt: PermissionPrompt, +) : OnPermissionDescription { + private var promptSession: AutoCloseable? = null + + override fun askWhetherRequestPermission( + activity: Activity, + requestList: List, + continueRequestRunnable: Runnable, + breakRequestRunnable: Runnable, + ) { + continueRequestRunnable.run() + } + + override fun onRequestPermissionStart( + activity: Activity, + requestList: List, + ) { + promptSession = permissionRequestCoordinator.beginPrompt(prompt) + } + + override fun onRequestPermissionEnd( + activity: Activity, + requestList: List, + ) { + promptSession?.close() + promptSession = null + } +} + +private data class PermissionPrompt( + val title: String, + val message: String, + val displayDelayMillis: Long = 500L, +) + +private data class PermissionDialogState( + val id: Long, + val title: String, + val message: String, + val confirmText: String, + val dismissText: String = "取消", + val onConfirm: () -> Unit, + val onDismiss: () -> Unit, +) + +private class PermissionRequestCoordinator { + private val lock = Any() + private var activityResumed = false + private var activityHasWindowFocus = false + private var nextId = 0L + private var activePrompt: ActivePrompt? = null + + val visiblePrompt = MutableStateFlow(null) + val dialog = MutableStateFlow(null) + + fun updateHostState( + resumed: Boolean, + hasWindowFocus: Boolean, + ) { + synchronized(lock) { + activityResumed = resumed + activityHasWindowFocus = hasWindowFocus + if (!resumed || !hasWindowFocus) { + revealPromptLocked() + } + dismissReturnedPromptLocked() + } + } + + fun detachHost() { + val dialogToDismiss = synchronized(lock) { + activityResumed = false + activityHasWindowFocus = false + activePrompt = null + visiblePrompt.value = null + dialog.value.also { + dialog.value = null + } + } + dialogToDismiss?.onDismiss?.invoke() + } + + fun beginPrompt(prompt: PermissionPrompt): AutoCloseable { + val id = synchronized(lock) { + val newId = ++nextId + activePrompt = ActivePrompt( + id = newId, + prompt = prompt, + ) + visiblePrompt.value = null + if (!activityResumed || !activityHasWindowFocus) { + revealPromptLocked() + } + newId + } + return PromptSession(id) + } + + suspend fun awaitResolution(permissionState: PermissionState): Boolean { + val resolution = permissionState.resolution ?: return false + return suspendCancellableCoroutine { continuation -> + val id = showDialog( + title = "权限请求", + message = listOfNotNull( + permissionState.purpose, + resolution.message, + ).joinToString("\n\n"), + confirmText = resolution.confirmText, + onConfirm = { + if (continuation.isActive) { + continuation.resume(true) + } + }, + onDismiss = { + if (continuation.isActive) { + continuation.resume(false) + } + }, + ) + continuation.invokeOnCancellation { + dismissDialog(id) + } + } + } + + fun confirmDialog(id: Long) { + takeDialog(id)?.onConfirm?.invoke() + } + + fun dismissDialog(id: Long) { + takeDialog(id)?.onDismiss?.invoke() + } + + private fun showDialog( + title: String, + message: String, + confirmText: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + ): Long { + val previousDialog: PermissionDialogState? + val id: Long + synchronized(lock) { + previousDialog = dialog.value + id = ++nextId + val state = PermissionDialogState( + id = id, + title = title, + message = message, + confirmText = confirmText, + onConfirm = onConfirm, + onDismiss = onDismiss, + ) + dialog.value = state + } + previousDialog?.onDismiss?.invoke() + return id + } + + private fun takeDialog(id: Long): PermissionDialogState? = synchronized(lock) { + dialog.value?.takeIf { it.id == id }?.also { + dialog.value = null + } + } + + private fun revealPromptLocked() { + val prompt = activePrompt ?: return + if (prompt.visible) return + prompt.visible = true + visiblePrompt.value = prompt.prompt + } + + private fun dismissReturnedPromptLocked() { + val prompt = activePrompt ?: return + if ( + prompt.visible && + activityResumed && + (activityHasWindowFocus || prompt.completed) + ) { + clearPromptLocked() + } + } + + private fun finishPrompt(id: Long) { + synchronized(lock) { + val prompt = activePrompt ?: return + if (prompt.id != id) return + prompt.completed = true + if (!prompt.visible) { + clearPromptLocked() + } else { + dismissReturnedPromptLocked() + } + } + } + + private fun clearPromptLocked() { + activePrompt = null + visiblePrompt.value = null + } + + private inner class PromptSession( + private val id: Long, + ) : AutoCloseable { + private val closed = AtomicBoolean(false) + + override fun close() { + if (closed.compareAndSet(false, true)) { + finishPrompt(id) + } + } + } + + private data class ActivePrompt( + val id: Long, + val prompt: PermissionPrompt, + var visible: Boolean = false, + var completed: Boolean = false, + ) +} diff --git a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt index 28a18be4..e6f43175 100644 --- a/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt +++ b/app/src/main/kotlin/li/songe/gkd/permission/PermissionState.kt @@ -1,20 +1,17 @@ package li.songe.gkd.permission import android.Manifest -import android.app.Activity import android.app.AppOpsManager import android.app.AppOpsManagerHidden import android.provider.Settings import com.hjq.permissions.XXPermissions import com.hjq.permissions.permission.PermissionLists import com.hjq.permissions.permission.base.IPermission -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.updateAndGet -import li.songe.gkd.MainActivity import li.songe.gkd.MainViewModel import li.songe.gkd.app import li.songe.gkd.appScope @@ -30,11 +27,9 @@ import priv.kit.core.Privilege class PermissionState( val name: String, private val check: () -> Boolean, - val request: (suspend (context: MainActivity) -> PermissionResult)? = null, - /** - * show it when user doNotAskAgain - */ - val reason: AuthReason? = null, + val permission: IPermission? = null, + val purpose: String? = null, + val resolution: PermissionResolution? = null, private val onChanged: (() -> Unit)? = null, ) { val stateFlow = MutableStateFlow(false) @@ -44,48 +39,46 @@ class PermissionState( return stateFlow.updateAndGet { check() } } - fun refresh() { - if (value != updateAndGet()) { + fun refresh(): Boolean { + val oldValue = value + val newValue = updateAndGet() + if (oldValue != newValue) { onChanged?.invoke() } + return newValue } - fun checkOrToast(): Boolean = if (!updateAndGet()) { - val r = updateAndGet() - if (!r) { - reason?.text?.let { toast(it()) } + fun checkOrToast(): Boolean { + val granted = refresh() + if (!granted) { + toast("请先授予「$name」") } - r - } else { - true + return granted } } -private suspend fun asyncRequestPermission( - context: Activity, +data class PermissionResolution( + val message: String, + val confirmText: String = "去设置", + val confirm: (() -> Unit)? = null, +) + +private fun requestablePermissionState( + name: String, + purpose: String, permission: IPermission, -): PermissionResult { - if (XXPermissions.isGrantedPermission(context, permission)) { - return PermissionResult.Granted - } - val deferred = CompletableDeferred() - XXPermissions.with(context) - .unchecked() - .permission(permission) - .request { grantedList, _ -> - if (grantedList.contains(permission)) { - PermissionResult.Granted - } else { - PermissionResult.Denied( - XXPermissions.isDoNotAskAgainPermissions( - context, - arrayOf(permission) - ) - ) - }.let { deferred.complete(it) } - } - return deferred.await() -} + check: () -> Boolean = { XXPermissions.isGrantedPermission(app, permission) }, + onChanged: (() -> Unit)? = null, +) = PermissionState( + name = name, + check = check, + permission = permission, + purpose = purpose, + resolution = PermissionResolution( + message = "未授予「$name」\n请前往系统权限设置开启", + ), + onChanged = onChanged, +) private fun checkAllowedOp(op: String): Boolean = app.appOpsManager.checkOpNoThrow( op, @@ -109,8 +102,9 @@ object PermissionStates { true } }, - reason = AuthReason( - text = { "当前操作权限「特殊用途的前台服务」已被限制,请前往特权服务重新授权" }, + resolution = PermissionResolution( + message = "「特殊用途的前台服务」已被限制,请前往特权服务重新授权", + confirmText = "去授权", confirm = { MainViewModel.instance.navigatePage(PrivilegeServiceRoute) }, @@ -173,38 +167,18 @@ object PermissionStates { } val notification by lazy { - val permission = PermissionLists.getNotificationServicePermission() - PermissionState( + requestablePermissionState( name = "通知权限", - check = { - XXPermissions.isGrantedPermission(app, permission) - }, - request = { asyncRequestPermission(it, permission) }, - reason = AuthReason( - text = { "当前操作需要「通知权限」\n请先前往权限页面授权" }, - confirm = { - XXPermissions.startPermissionActivity(app, permission) - } - ), + purpose = "用于显示后台服务运行状态与必要通知", + permission = PermissionLists.getPostNotificationsPermission(), ) } val queryPackages by lazy { - val permission = PermissionLists.getGetInstalledAppsPermission() - PermissionState( + requestablePermissionState( name = "读取应用列表权限", - check = { - XXPermissions.isGrantedPermission(app, permission) - }, - request = { - asyncRequestPermission(it, permission) - }, - reason = AuthReason( - text = { "当前操作需要「读取应用列表权限」\n请先前往权限页面授权" }, - confirm = { - XXPermissions.startPermissionActivity(app, permission) - } - ), + purpose = "用于展示设备应用并匹配应用规则", + permission = PermissionLists.getGetInstalledAppsPermission(), onChanged = { if (!updateAppMutex.mutex.isLocked) { updateAllAppInfo() @@ -214,29 +188,22 @@ object PermissionStates { } val drawOverlays by lazy { - PermissionState( + requestablePermissionState( name = "悬浮窗权限", + purpose = "用于显示快照按钮、界面信息和事件提示等悬浮内容", + permission = PermissionLists.getSystemAlertWindowPermission(), check = { // https://developer.android.com/security/fraud-prevention/activities?hl=zh-cn#hide_overlay_windows Settings.canDrawOverlays(app) }, - reason = AuthReason( - text = { - "当前操作需要「悬浮窗权限」\n请先前往权限页面授权" - }, - confirm = { - XXPermissions.startPermissionActivity( - app, - PermissionLists.getSystemAlertWindowPermission() - ) - } - ), ) } val writeExternalStorage by lazy { - PermissionState( + requestablePermissionState( name = "写入外部存储权限", + purpose = "用于在 Android 9 及以下保存截图或文件到公共存储", + permission = PermissionLists.getWriteExternalStoragePermission(), check = { if (AndroidTarget.Q) { true @@ -244,44 +211,17 @@ object PermissionStates { app.checkGrantedPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) } }, - request = { - if (AndroidTarget.Q) { - PermissionResult.Granted - } else { - asyncRequestPermission(it, PermissionLists.getWriteExternalStoragePermission()) - } - }, - reason = AuthReason( - text = { "当前操作需要「写入外部存储权限」\n请先前往权限页面授权" }, - confirm = { - XXPermissions.startPermissionActivity( - app, - PermissionLists.getWriteExternalStoragePermission() - ) - } - ), ) } val ignoreBatteryOptimizations by lazy { - val permission = PermissionLists.getRequestIgnoreBatteryOptimizationsPermission() - PermissionState( + requestablePermissionState( name = "忽略电池优化权限", + purpose = "用于降低后台服务被系统休眠或终止的概率", + permission = PermissionLists.getRequestIgnoreBatteryOptimizationsPermission(), check = { app.powerManager.isIgnoringBatteryOptimizations(app.packageName) }, - request = { - asyncRequestPermission(it, permission) - }, - reason = AuthReason( - text = { "当前操作需要「忽略电池优化权限」\n请先前往权限页面授权" }, - confirm = { - XXPermissions.startPermissionActivity( - app, - permission - ) - } - ), ) }