mirror of
https://github.com/gkd-kit/gkd.git
synced 2026-09-03 07:19:59 +08:00
refactor: streamline permission request flow
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
## 测试策略
|
||||
|
||||
- 新增测试必须验证可观察行为,明确被测输入、预期输出和要防止的具体回归。优先覆盖纯函数、边界条件、异常路径、平台或版本兼容差异,以及已修复缺陷的回归场景。
|
||||
- 禁止仅为增加测试而拆散本应聚合的生产逻辑、扩大声明可见性或暴露测试专用 API;测试必须适配合理的生产设计,而不是反向塑造生产代码。
|
||||
- 禁止新增仅复述生产代码静态声明的测试,包括枚举成员、常量取值或集合、连续编号、由同一注册表推导出的成员关系,以及 Kotlin 类型系统已经保证的约束。
|
||||
- 只有当常量或标识属于外部协议、持久化格式或跨版本兼容契约时,才允许为其新增稳定性测试,并在测试名称或注释中说明要保护的兼容行为。
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AlertDialogOptions?>(null)
|
||||
val authReasonFlow = MutableStateFlow<AuthReason?>(null)
|
||||
|
||||
val updateStatus = if (META.updateEnabled) UpdateStatus(viewModelScope) else 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<AuthReason?>) {
|
||||
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
|
||||
}
|
||||
511
app/src/main/kotlin/li/songe/gkd/permission/PermissionRequest.kt
Normal file
511
app/src/main/kotlin/li/songe/gkd/permission/PermissionRequest.kt
Normal file
@@ -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<PermissionPrompt?>(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<IPermission>,
|
||||
continueRequestRunnable: Runnable,
|
||||
breakRequestRunnable: Runnable,
|
||||
) {
|
||||
continueRequestRunnable.run()
|
||||
}
|
||||
|
||||
override fun onRequestPermissionStart(
|
||||
activity: Activity,
|
||||
requestList: List<IPermission>,
|
||||
) {
|
||||
promptSession = permissionRequestCoordinator.beginPrompt(prompt)
|
||||
}
|
||||
|
||||
override fun onRequestPermissionEnd(
|
||||
activity: Activity,
|
||||
requestList: List<IPermission>,
|
||||
) {
|
||||
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<PermissionPrompt?>(null)
|
||||
val dialog = MutableStateFlow<PermissionDialogState?>(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,
|
||||
)
|
||||
}
|
||||
@@ -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<PermissionResult>()
|
||||
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
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user