diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 82a5274f7..73ef7172c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + @@ -19,8 +20,6 @@ - - @@ -28,6 +27,9 @@ android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> + + + + + + + Boolean, + val check: () -> Boolean, val request: (suspend (context: Activity) -> PermissionResult)? = null, /** * show it when user doNotAskAgain @@ -32,7 +33,7 @@ class PermissionState( val reason: AuthReason? = null, ) { val stateFlow = MutableStateFlow(false) - suspend fun updateAndGet(): Boolean { + fun updateAndGet(): Boolean { return stateFlow.updateAndGet { check() } } } @@ -74,7 +75,7 @@ private suspend fun asyncRequestPermission( val notificationState by lazy { PermissionState( check = { - checkSelfPermission(Permission.POST_NOTIFICATIONS) + XXPermissions.isGranted(app, Permission.NOTIFICATION_SERVICE) }, request = { asyncRequestPermission(it, Permission.POST_NOTIFICATIONS) @@ -121,7 +122,7 @@ val canDrawOverlaysState by lazy { reason = AuthReason( text = "当前操作需要[悬浮窗权限]\n\n您需要前往应用权限设置打开此权限", confirm = { - XXPermissions.startPermissionActivity(app, Permission.SYSTEM_ALERT_WINDOW) + XXPermissions.startPermissionActivity(app, Manifest.permission.SYSTEM_ALERT_WINDOW) } ), ) @@ -138,7 +139,7 @@ val canWriteExternalStorage by lazy { }, request = { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - asyncRequestPermission(it, Permission.WRITE_EXTERNAL_STORAGE) + asyncRequestPermission(it, Manifest.permission.WRITE_EXTERNAL_STORAGE) } else { PermissionResult.Granted } @@ -146,12 +147,21 @@ val canWriteExternalStorage by lazy { reason = AuthReason( text = "当前操作需要[写入外部存储权限]\n\n您需要前往应用权限设置打开此权限", confirm = { - XXPermissions.startPermissionActivity(app, Permission.WRITE_EXTERNAL_STORAGE) + XXPermissions.startPermissionActivity( + app, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) } ), ) } +val writeSecureSettingsState by lazy { + PermissionState( + check = { checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) }, + ) +} + val shizukuOkState by lazy { PermissionState( check = { @@ -173,12 +183,15 @@ suspend fun updatePermissionState() { notificationState, canDrawOverlaysState, canWriteExternalStorage, - shizukuOkState ).forEach { it.updateAndGet() } if (canQueryPkgState.stateFlow.value != canQueryPkgState.updateAndGet()) { appScope.launchTry { initOrResetAppInfoCache() } } + if (writeSecureSettingsState.stateFlow.value != writeSecureSettingsState.updateAndGet()) { + fixRestartService() + } + shizukuOkState.updateAndGet() } } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/service/GkdAbService.kt b/app/src/main/kotlin/li/songe/gkd/service/GkdAbService.kt index 403ab731b..634ca83ef 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/GkdAbService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/GkdAbService.kt @@ -94,7 +94,7 @@ class GkdAbService : CompositionAbService({ shizukuAliveFlow, storeFlow.map(scope) { s -> s.enableShizukuActivity } ) - val safeGetTasksFc = useSafeGetTasksFc(scope, shizukuCanUsedFlow) + val safeGetTasksFc by lazy { useSafeGetTasksFc(scope, shizukuCanUsedFlow) } val shizukuClickCanUsedFlow = getShizukuCanUsedFlow( scope, @@ -146,7 +146,7 @@ class GkdAbService : CompositionAbService({ val events = mutableListOf() var queryTaskJob: Job? = null fun newQueryTask(byEvent: Boolean = false, byForced: Boolean = false) { - if (!storeFlow.value.enableService) return + if (!storeFlow.value.enableMatch) return queryTaskJob = scope.launchTry(queryThread) { var latestEvent = synchronized(events) { val size = events.size @@ -348,7 +348,7 @@ class GkdAbService : CompositionAbService({ if (evAppId != rightAppId) { return@launch } - if (!storeFlow.value.enableService) return@launch + if (!storeFlow.value.enableMatch) return@launch val eventNode = event.safeSource synchronized(events) { val eventLog = events.lastOrNull() @@ -363,7 +363,7 @@ class GkdAbService : CompositionAbService({ } } - var lastUpdateSubsTime = 0L + var lastUpdateSubsTime = System.currentTimeMillis() - 25000 onAccessibilityEvent {// 借助 无障碍事件 触发自动检测更新 if (it.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {// 筛选降低判断频率 val i = storeFlow.value.updateSubsInterval @@ -378,7 +378,7 @@ class GkdAbService : CompositionAbService({ scope.launch(Dispatchers.IO) { activityRuleFlow.debounce(300).collect { - if (storeFlow.value.enableService && it.currentRules.isNotEmpty()) { + if (storeFlow.value.enableMatch && it.currentRules.isNotEmpty()) { LogUtils.d(it.topActivity, *it.currentRules.map { r -> r.statusText() }.toTypedArray()) diff --git a/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt new file mode 100644 index 000000000..7e314539b --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/service/GkdTileService.kt @@ -0,0 +1,140 @@ +package li.songe.gkd.service + +import android.content.ComponentName +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import kotlinx.coroutines.flow.update +import li.songe.gkd.app +import li.songe.gkd.permission.writeSecureSettingsState +import li.songe.gkd.util.lastRestartA11yServiceTimeFlow +import li.songe.gkd.util.storeFlow +import li.songe.gkd.util.toast + +class GkdTileService : TileService() { + private fun updateTile(): Boolean { + val oldState = qsTile.state + val newState = if (GkdAbService.isRunning.value) { + Tile.STATE_ACTIVE + } else { + Tile.STATE_INACTIVE + } + if (oldState != newState) { + qsTile.state = newState + qsTile.updateTile() + return true + } + return false + } + + private fun autoUpdateTile() { + Handler(Looper.getMainLooper()).postDelayed({ + if (!updateTile()) { + Handler(Looper.getMainLooper()).postDelayed(::updateTile, 250) + } + }, 250) + } + + override fun onTileAdded() { + super.onTileAdded() + updateTile() + } + + override fun onStartListening() { + super.onStartListening() + updateTile() + if (fixRestartService()) { + autoUpdateTile() + } + } + + override fun onClick() { + super.onClick() + if (switchA11yService()) { + autoUpdateTile() + } + } +} + +private fun getServiceNames(): MutableList { + val value = try { + Settings.Secure.getString( + app.contentResolver, + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES + ) + } catch (_: Exception) { + null + } ?: "" + if (value.isEmpty()) return mutableListOf() + return value.split(':').toMutableList() +} + +private fun updateServiceNames(names: List) { + Settings.Secure.putString( + app.contentResolver, + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, + names.joinToString(":") + ) +} + +private fun enableA11yService() { + Settings.Secure.putInt( + app.contentResolver, + Settings.Secure.ACCESSIBILITY_ENABLED, + 1 + ) +} + +fun switchA11yService(): Boolean { + if (!writeSecureSettingsState.updateAndGet()) { + toast("请先授予[写入安全设置]权限") + return false + } + val names = getServiceNames() + if (GkdAbService.isRunning.value) { + names.remove(a11yClsName) + updateServiceNames(names) + storeFlow.update { it.copy(enableService = false) } + toast("关闭无障碍") + } else { + enableA11yService() + if (names.contains(a11yClsName)) { // 当前无障碍异常, 重启服务 + names.remove(a11yClsName) + updateServiceNames(names) + } + names.add(a11yClsName) + updateServiceNames(names) + storeFlow.update { it.copy(enableService = true) } + toast("开启无障碍") + } + return true +} + +fun fixRestartService(): Boolean { + // 1. 服务没有运行 + // 2. 用户配置开启了服务 + // 3. 有写入系统设置权限 + if (!GkdAbService.isRunning.value && storeFlow.value.enableService && writeSecureSettingsState.updateAndGet()) { + val t = System.currentTimeMillis() + if (t - lastRestartA11yServiceTimeFlow.value < 10_000) return false + lastRestartA11yServiceTimeFlow.value = t + val names = getServiceNames() + val a11yBroken = names.contains(a11yClsName) + if (a11yBroken) { + // 无障碍出现故障, 重启服务 + names.remove(a11yClsName) + updateServiceNames(names) + } + names.add(a11yClsName) + updateServiceNames(names) + toast("重启无障碍") + return true + } + return false +} + +private val a11yClsName by lazy { + ComponentName(app, GkdAbService::class.java).flattenToShortString() +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/service/ManageService.kt b/app/src/main/kotlin/li/songe/gkd/service/ManageService.kt index 5dff5f353..5390b57c2 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/ManageService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/ManageService.kt @@ -34,7 +34,7 @@ class ManageService : CompositionService({ clickCountFlow, ) { abRunning, store, ruleSummary, count -> if (!abRunning) return@combine "无障碍未授权" - if (!store.enableService) return@combine "服务已暂停" + if (!store.enableMatch) return@combine "暂停规则匹配" if (store.useCustomNotifText) { return@combine store.customNotifText .replace("\${i}", ruleSummary.globalGroups.size.toString()) diff --git a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt index 1fac6d22a..2cee031b3 100644 --- a/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt +++ b/app/src/main/kotlin/li/songe/gkd/shizuku/ShizukuApi.kt @@ -14,6 +14,7 @@ import android.view.Display import android.view.MotionEvent import com.blankj.utilcode.util.LogUtils import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -178,19 +179,19 @@ fun IInputManager.safeClick(x: Float, y: Float): Boolean? { } } -fun useSafeInjectClickEventFc( - scope: CoroutineScope, - usedFlow: StateFlow, -): (x: Float, y: Float) -> Boolean? { - val inputManagerFlow = usedFlow.map(scope) { if (it) newInputManager() else null } - return { x, y -> - if (usedFlow.value) { - inputManagerFlow.value?.safeClick(x, y) - } else { - null - } - } -} +//fun useSafeInjectClickEventFc( +// scope: CoroutineScope, +// usedFlow: StateFlow, +//): (x: Float, y: Float) -> Boolean? { +// val inputManagerFlow = usedFlow.map(scope) { if (it) newInputManager() else null } +// return { x, y -> +// if (usedFlow.value) { +// inputManagerFlow.value?.safeClick(x, y) +// } else { +// null +// } +// } +//} // 在 大麦 https://i.gkd.li/i/14605104 上测试产生如下 3 种情况 // 1. 点击不生效: 使用传统无障碍屏幕点击, 此种点击可被 大麦 通过 View.setAccessibilityDelegate 屏蔽 @@ -201,7 +202,7 @@ fun useSafeInputTapFc( usedFlow: StateFlow, ): (x: Float, y: Float) -> Boolean? { val serviceWrapperFlow = MutableStateFlow(null) - scope.launch { + scope.launch(Dispatchers.IO) { usedFlow.collect { if (it) { val serviceWrapper = newUserService() diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/TextSwitch.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/TextSwitch.kt index 145b29f37..c06337b48 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/TextSwitch.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/TextSwitch.kt @@ -28,7 +28,13 @@ fun TextSwitch( onCheckedChange: ((Boolean) -> Unit)? = null, ) { Row( - modifier = modifier.itemPadding(), + modifier = modifier.let { + if (modifier == Modifier) { + it.itemPadding() + } else { + it.then(modifier) + } + }, verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt index 034b4e738..a239bed1d 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt @@ -1,7 +1,5 @@ package li.songe.gkd.ui.home -import android.content.Intent -import android.provider.Settings import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -30,10 +28,13 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.ramcosta.composedestinations.navigation.navigate import li.songe.gkd.MainActivity +import li.songe.gkd.a11yServiceEnabledFlow import li.songe.gkd.permission.notificationState import li.songe.gkd.permission.requiredPermission +import li.songe.gkd.permission.writeSecureSettingsState import li.songe.gkd.service.GkdAbService import li.songe.gkd.service.ManageService +import li.songe.gkd.service.switchA11yService import li.songe.gkd.ui.component.AuthCard import li.songe.gkd.ui.component.SettingItem import li.songe.gkd.ui.component.TextSwitch @@ -45,11 +46,11 @@ import li.songe.gkd.ui.style.itemPadding import li.songe.gkd.util.HOME_PAGE_URL import li.songe.gkd.util.LocalNavController import li.songe.gkd.util.launchAsFn +import li.songe.gkd.util.openA11ySettings import li.songe.gkd.util.openUri import li.songe.gkd.util.ruleSummaryFlow import li.songe.gkd.util.storeFlow import li.songe.gkd.util.throttle -import li.songe.gkd.util.tryStartActivity val controlNav = BottomNavItem(label = "主页", icon = Icons.Outlined.Home) @@ -58,13 +59,6 @@ fun useControlPage(): ScaffoldExt { val context = LocalContext.current as MainActivity val navController = LocalNavController.current val vm = viewModel() - val latestRecordDesc by vm.latestRecordDescFlow.collectAsState() - val subsStatus by vm.subsStatusFlow.collectAsState() - val store by storeFlow.collectAsState() - val ruleSummary by ruleSummaryFlow.collectAsState() - - val gkdAccessRunning by GkdAbService.isRunning.collectAsState() - val manageRunning by ManageService.isRunning.collectAsState() val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() val scrollState = rememberScrollState() return ScaffoldExt(navItem = controlNav, @@ -84,29 +78,40 @@ fun useControlPage(): ScaffoldExt { }) } ) { padding -> + val latestRecordDesc by vm.latestRecordDescFlow.collectAsState() + val subsStatus by vm.subsStatusFlow.collectAsState() + val store by storeFlow.collectAsState() + val ruleSummary by ruleSummaryFlow.collectAsState() + + val a11yRunning by GkdAbService.isRunning.collectAsState() + val manageRunning by ManageService.isRunning.collectAsState() + val writeSecureSettings by writeSecureSettingsState.stateFlow.collectAsState() + val a11yServiceEnabled by a11yServiceEnabledFlow.collectAsState() + + // 无障碍故障: 设置中无障碍开启, 但是实际 service 没有运行 + val a11yBroken = !writeSecureSettings && !a11yRunning && a11yServiceEnabled + Column( modifier = Modifier .verticalScroll(scrollState) .padding(padding) ) { - if (!gkdAccessRunning) { - AuthCard( - title = "无障碍权限", - desc = "获取屏幕信息,匹配节点,执行操作", - onAuthClick = { - val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - context.tryStartActivity(intent) - }) - } else { + if (writeSecureSettings) { TextSwitch( - title = "服务开启", - subtitle = "根据规则匹配节点,执行操作", + title = "服务状态", + subtitle = if (store.enableService) "无障碍服务正在运行" else "无障碍服务已关闭", checked = store.enableService, onCheckedChange = { - storeFlow.value = store.copy( - enableService = it - ) + switchA11yService() + }) + } + if (!writeSecureSettings && !a11yRunning) { + AuthCard( + title = "无障碍授权", + desc = if (a11yBroken) "服务故障,请重新授权" else "授权使无障碍服务运行", + onAuthClick = { + openA11ySettings() + // TODO context.mainVm.showA11yAuthDlgFlow.value = true }) } diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt index 7bc2dae71..3966a2ca0 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt @@ -46,6 +46,7 @@ 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.Alignment import androidx.compose.ui.Modifier @@ -79,6 +80,7 @@ import li.songe.gkd.util.findOption import li.songe.gkd.util.isSafeUrl import li.songe.gkd.util.launchAsFn import li.songe.gkd.util.launchTry +import li.songe.gkd.util.map import li.songe.gkd.util.saveFileToDownloads import li.songe.gkd.util.shareFile import li.songe.gkd.util.storeFlow @@ -265,7 +267,7 @@ fun useSubsManagePage(): ScaffoldExt { ) } } - IconButton(onClick = vm.viewModelScope.launchAsFn(Dispatchers.IO) { + IconButton(onClick = { vm.showShareDataIdsFlow.value = selectedIds }) { Icon( @@ -282,6 +284,24 @@ fun useSubsManagePage(): ScaffoldExt { ) } } else { + IconButton(onClick = throttle { + if (storeFlow.value.enableMatch) { + toast("暂停规则匹配") + } else { + toast("开启规则匹配") + } + storeFlow.update { s -> s.copy(enableMatch = !s.enableMatch) } + }) { + val scope = rememberCoroutineScope() + val enableMatch by remember { + storeFlow.map(scope) { it.enableMatch } + }.collectAsState() + val id = if (enableMatch) SafeR.ic_flash_on else SafeR.ic_flash_off + Icon( + painter = painterResource(id = id), + contentDescription = null, + ) + } IconButton(onClick = { showSettingsDlg = true }) { diff --git a/app/src/main/kotlin/li/songe/gkd/util/ComposeExt.kt b/app/src/main/kotlin/li/songe/gkd/util/ComposeExt.kt deleted file mode 100644 index 3138a1125..000000000 --- a/app/src/main/kotlin/li/songe/gkd/util/ComposeExt.kt +++ /dev/null @@ -1,2 +0,0 @@ -package li.songe.gkd.util - diff --git a/app/src/main/kotlin/li/songe/gkd/util/IntentExt.kt b/app/src/main/kotlin/li/songe/gkd/util/IntentExt.kt index 60358adbb..fd280f4cf 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/IntentExt.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/IntentExt.kt @@ -7,12 +7,14 @@ import android.net.Uri import android.os.Build import android.os.Environment import android.provider.MediaStore +import android.provider.Settings import android.webkit.MimeTypeMap import androidx.core.content.FileProvider import com.blankj.utilcode.util.LogUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import li.songe.gkd.MainActivity +import li.songe.gkd.app import li.songe.gkd.permission.canWriteExternalStorage import li.songe.gkd.permission.requiredPermission import java.io.File @@ -71,6 +73,12 @@ fun Context.tryStartActivity(intent: Intent) { } } +fun openA11ySettings() { + val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + app.tryStartActivity(intent) +} + fun Context.openUri(uri: String) { val u = try { Uri.parse(uri) diff --git a/app/src/main/kotlin/li/songe/gkd/util/SafeR.kt b/app/src/main/kotlin/li/songe/gkd/util/SafeR.kt index 1015eb57c..4511f5f3e 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/SafeR.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/SafeR.kt @@ -10,4 +10,6 @@ object SafeR { val app_name: Int = R.string.app_name val ic_status: Int = R.drawable.ic_status val ic_page_info: Int = R.drawable.ic_page_info + val ic_flash_on: Int = R.drawable.ic_flash_on + val ic_flash_off: Int = R.drawable.ic_flash_off } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/util/Store.kt b/app/src/main/kotlin/li/songe/gkd/util/Store.kt index 9f07bd086..3fc0db178 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Store.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Store.kt @@ -42,6 +42,7 @@ private inline fun createStorageFlow( @Serializable data class Store( val enableService: Boolean = true, + val enableMatch: Boolean = true, val enableStatusService: Boolean = true, val excludeFromRecents: Boolean = false, val captureScreenshot: Boolean = false, @@ -112,9 +113,22 @@ val privacyStoreFlow by lazy { createStorageFlow("privacy_store") { PrivacyStore() } } +private fun createLongFlow(key: String, defaultValue: Long): MutableStateFlow { + val stateFlow = MutableStateFlow(kv.getLong(key, defaultValue)) + appScope.launch { + stateFlow.drop(1).collect { + withContext(Dispatchers.IO) { kv.encode(key, it) } + } + } + return stateFlow +} + +val lastRestartA11yServiceTimeFlow by lazy { + createLongFlow("last_restart_a11y_service_time", 0) +} + fun initStore() { storeFlow.value recordStoreFlow.value - privacyStoreFlow.value } diff --git a/app/src/main/res/drawable/ic_flash_off.xml b/app/src/main/res/drawable/ic_flash_off.xml new file mode 100644 index 000000000..a8b4d096e --- /dev/null +++ b/app/src/main/res/drawable/ic_flash_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_flash_on.xml b/app/src/main/res/drawable/ic_flash_on.xml new file mode 100644 index 000000000..f74471707 --- /dev/null +++ b/app/src/main/res/drawable/ic_flash_on.xml @@ -0,0 +1,9 @@ + + +