From 7768bd47327a9900e855a8c7a16d6c8683a15c86 Mon Sep 17 00:00:00 2001 From: lisonge Date: Fri, 19 Jan 2024 22:42:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=87=AA=E5=AE=9A=E4=B9=89=E7=A6=81?= =?UTF-8?q?=E7=94=A8+matchSystemApp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/kotlin/li/songe/gkd/data/AppInfo.kt | 1 + .../main/kotlin/li/songe/gkd/data/AppRule.kt | 5 +- .../kotlin/li/songe/gkd/data/GlobalRule.kt | 22 +- .../li/songe/gkd/data/RawSubscription.kt | 5 + .../kotlin/li/songe/gkd/data/ResolvedRule.kt | 12 +- .../kotlin/li/songe/gkd/data/SubsConfig.kt | 49 ++- .../main/kotlin/li/songe/gkd/ui/DebugPage.kt | 2 +- .../li/songe/gkd/ui/GlobalRuleExcludePage.kt | 317 ++++++++++++++++++ .../li/songe/gkd/ui/GlobalRuleExcludeVm.kt | 56 ++++ .../kotlin/li/songe/gkd/ui/GlobalRulePage.kt | 78 +---- app/src/main/kotlin/li/songe/gkd/ui/SubsVm.kt | 4 +- .../kotlin/li/songe/gkd/util/AppInfoState.kt | 71 ++-- .../kotlin/li/songe/gkd/util/Singleton.kt | 5 + .../kotlin/li/songe/gkd/util/SubsState.kt | 9 - 14 files changed, 509 insertions(+), 127 deletions(-) create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/GlobalRuleExcludePage.kt create mode 100644 app/src/main/kotlin/li/songe/gkd/ui/GlobalRuleExcludeVm.kt diff --git a/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt b/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt index df98d78bd..40b86fab6 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt @@ -8,4 +8,5 @@ data class AppInfo( val icon: Drawable, val versionCode: Int, val versionName: String, + val isSystem: Boolean, ) \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/data/AppRule.kt b/app/src/main/kotlin/li/songe/gkd/data/AppRule.kt index 67a026fdf..936c6b859 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/AppRule.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/AppRule.kt @@ -17,10 +17,11 @@ class AppRule( val appId = app.id val activityIds = getFixActivityIds(app.id, rule.activityIds ?: group.activityIds) val excludeActivityIds = - getFixActivityIds( + (getFixActivityIds( app.id, rule.excludeActivityIds ?: group.excludeActivityIds - ) + (excludeData.activityIds.filter { e -> e.first == appId }.map { e -> e.second }) + ) + (excludeData.activityIds.filter { e -> e.first == appId } + .map { e -> e.second })).distinct() override val type = "app" override fun matchActivity(appId: String, activityId: String?): Boolean { diff --git a/app/src/main/kotlin/li/songe/gkd/data/GlobalRule.kt b/app/src/main/kotlin/li/songe/gkd/data/GlobalRule.kt index 5a345e3d6..14783df00 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/GlobalRule.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/GlobalRule.kt @@ -1,6 +1,7 @@ package li.songe.gkd.data import li.songe.gkd.service.launcherAppId +import li.songe.gkd.util.systemAppsFlow data class GlobalApp( val id: String, @@ -25,6 +26,7 @@ class GlobalRule( val matchAnyApp = rule.matchAnyApp ?: group.matchAnyApp ?: true val matchLauncher = rule.matchLauncher ?: group.matchLauncher ?: false + val matchSystemApp = rule.matchSystemApp ?: group.matchSystemApp ?: false val apps = mutableMapOf().apply { (rule.apps ?: group.apps ?: emptyList()).forEach { a -> this[a.id] = GlobalApp( @@ -40,11 +42,27 @@ class GlobalRule( private val excludeAppIds = apps.filter { e -> !e.value.enable }.keys override fun matchActivity(appId: String, activityId: String?): Boolean { - if (!matchLauncher && appId == launcherAppId) return false - if (!super.matchActivity(appId, activityId)) return false + // 规则自带禁用 if (excludeAppIds.contains(appId)) { return false } + // 用户自定义禁用 + if (excludeData.excludeAppIds.contains(appId)) { + return false + } + if (excludeData.includeAppIds.contains(appId)) { + activityId ?: return true + val app = apps[appId] ?: return true + return !app.excludeActivityIds.any { e -> e.startsWith(activityId) } + } else if (activityId != null && excludeData.activityIds.contains(appId to activityId)) { + return false + } + if (!matchLauncher && appId == launcherAppId) { + return false + } + if (!matchSystemApp && systemAppsFlow.value.contains(appId)) { + return false + } val app = apps[appId] ?: return matchAnyApp activityId ?: return true if (app.excludeActivityIds.any { e -> e.startsWith(activityId) }) { diff --git a/app/src/main/kotlin/li/songe/gkd/data/RawSubscription.kt b/app/src/main/kotlin/li/songe/gkd/data/RawSubscription.kt index 384598230..d3b2dbb1e 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/RawSubscription.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/RawSubscription.kt @@ -125,6 +125,7 @@ data class RawSubscription( interface RawGlobalRuleProps { val matchAnyApp: Boolean? + val matchSystemApp: Boolean? val matchLauncher: Boolean? val apps: List? } @@ -157,6 +158,7 @@ data class RawSubscription( override val snapshotUrls: List?, override val exampleUrls: List?, override val matchAnyApp: Boolean?, + override val matchSystemApp: Boolean?, override val matchLauncher: Boolean?, override val apps: List?, override val rules: List, @@ -216,6 +218,7 @@ data class RawSubscription( override val matches: List, override val excludeMatches: List?, override val matchAnyApp: Boolean?, + override val matchSystemApp: Boolean?, override val matchLauncher: Boolean?, override val apps: List? ) : RawRuleProps, RawGlobalRuleProps @@ -485,6 +488,7 @@ data class RawSubscription( actionMaximumKey = getInt(jsonObject, "actionMaximumKey"), actionCdKey = getInt(jsonObject, "actionCdKey"), matchAnyApp = getBoolean(jsonObject, "matchAnyApp"), + matchSystemApp = getBoolean(jsonObject, "matchSystemApp"), matchLauncher = getBoolean(jsonObject, "matchLauncher"), apps = jsonObject["apps"]?.jsonArray?.mapIndexed { index, jsonElement -> jsonToGlobalApp( @@ -515,6 +519,7 @@ data class RawSubscription( exampleUrls = getStringIArray(jsonObject, "exampleUrls"), actionMaximumKey = getInt(jsonObject, "actionMaximumKey"), actionCdKey = getInt(jsonObject, "actionCdKey"), + matchSystemApp = getBoolean(jsonObject, "matchSystemApp"), matchAnyApp = getBoolean(jsonObject, "matchAnyApp"), matchLauncher = getBoolean(jsonObject, "matchLauncher"), apps = jsonObject["apps"]?.jsonArray?.mapIndexed { index, jsonElement -> diff --git a/app/src/main/kotlin/li/songe/gkd/data/ResolvedRule.kt b/app/src/main/kotlin/li/songe/gkd/data/ResolvedRule.kt index a881b6374..ef748238d 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/ResolvedRule.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/ResolvedRule.kt @@ -153,15 +153,9 @@ sealed class ResolvedRule( val excludeData = ExcludeData.parse(exclude) abstract val type: String - open fun matchActivity(appId: String, activityId: String? = null): Boolean { - if (excludeData.appIds.contains(appId)) { - return false - } - activityId ?: return true - return !excludeData.activityIds.any { e -> - e.first == appId && activityId.startsWith(e.second) - } - } + + // 范围越精确, 优先级越高 + abstract fun matchActivity(appId: String, activityId: String? = null): Boolean } diff --git a/app/src/main/kotlin/li/songe/gkd/data/SubsConfig.kt b/app/src/main/kotlin/li/songe/gkd/data/SubsConfig.kt index 09479db3f..17cd78678 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/SubsConfig.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/SubsConfig.kt @@ -82,24 +82,35 @@ data class SubsConfig( } data class ExcludeData( - val appIds: Set, val activityIds: Set> + val appIds: Map, + val activityIds: Set>, ) { + val excludeAppIds = appIds.entries.filter { e -> e.value }.map { e -> e.key }.toSet() + val includeAppIds = appIds.entries.filter { e -> !e.value }.map { e -> e.key }.toSet() + companion object { fun parse(exclude: String?): ExcludeData { - val appIds = mutableSetOf() + val appIds = mutableMapOf() val activityIds = mutableSetOf>() - (exclude ?: "").split('\n', ',') - .filter { s -> s.isNotBlank() && s.count { c -> c == '/' } <= 1 }.forEach { s -> - val a = s.split('/') - val appId = a[0] - val activityId = a.getOrNull(1) - if (activityId != null) { - activityIds.add(appId to activityId) + (exclude ?: "").split('\n', ',').filter { s -> s.isNotBlank() }.map { s -> s.trim() } + .forEach { s -> + if (s[0] == '!') { + appIds[s.substring(1)] = false } else { - appIds.add(appId) + val a = s.split('/') + val appId = a[0] + val activityId = a.getOrNull(1) + if (activityId != null) { + activityIds.add(appId to activityId) + } else { + appIds[appId] = true + } } } - return ExcludeData(appIds = appIds, activityIds = activityIds) + return ExcludeData( + appIds = appIds, + activityIds = activityIds, + ) } fun parse(appId: String, exclude: String?): ExcludeData { @@ -109,7 +120,13 @@ data class ExcludeData( } fun ExcludeData.stringify(): String { - return (appIds + activityIds.map { e -> "${e.first}/${e.second}" }).sorted().joinToString("\n") + return (appIds.entries.map { e -> + if (e.value) { + e.key + } else { + "!${e.key}" + } + } + activityIds.map { e -> "${e.first}/${e.second}" }).sorted().joinToString("\n") } fun ExcludeData.stringify(appId: String): String { @@ -120,11 +137,11 @@ fun ExcludeData.stringify(appId: String): String { fun ExcludeData.switch(appId: String, activityId: String? = null): ExcludeData { return if (activityId == null) { copy( - appIds = appIds.toMutableSet().apply { - if (contains(appId)) { - remove(appId) + appIds = appIds.toMutableMap().apply { + if (get(appId) == true) { + set(appId, false) } else { - add(appId) + set(appId, true) } }, ) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/DebugPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/DebugPage.kt index 3ba2f96ba..9c8332205 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/DebugPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/DebugPage.kt @@ -254,7 +254,7 @@ fun DebugPage() { Divider() TextSwitch( name = "截屏快照", - desc = "当用户截屏时保存快照(需手动替换快照图片),仅支持部分MIUI14", + desc = "当用户截屏时保存快照(需手动替换快照图片),仅支持部分小米设备", checked = store.captureScreenshot ) { updateStorage( diff --git a/app/src/main/kotlin/li/songe/gkd/ui/GlobalRuleExcludePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/GlobalRuleExcludePage.kt new file mode 100644 index 000000000..1eb5b78d8 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/GlobalRuleExcludePage.kt @@ -0,0 +1,317 @@ +package li.songe.gkd.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.viewModelScope +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootNavGraph +import li.songe.gkd.data.AppInfo +import li.songe.gkd.data.ExcludeData +import li.songe.gkd.data.RawSubscription +import li.songe.gkd.data.SubsConfig +import li.songe.gkd.data.stringify +import li.songe.gkd.db.DbSet +import li.songe.gkd.service.launcherAppId +import li.songe.gkd.ui.component.AppBarTextField +import li.songe.gkd.util.LocalNavController +import li.songe.gkd.util.ProfileTransitions +import li.songe.gkd.util.launchTry +import li.songe.gkd.util.toast + +@RootNavGraph +@Destination(style = ProfileTransitions::class) +@Composable +fun GlobalRuleExcludePage(subsItemId: Long, groupKey: Int) { + val navController = LocalNavController.current + val vm = hiltViewModel() + val rawSubs = vm.rawSubsFlow.collectAsState().value + val group = vm.groupFlow.collectAsState().value + val excludeData = vm.excludeDataFlow.collectAsState().value + val appIdEnable = vm.appIdEnableFlow.collectAsState().value + val showAppInfos = vm.showAppInfosFlow.collectAsState().value + val searchStr by vm.searchStrFlow.collectAsState() + + var showEditDlg by remember { + mutableStateOf(false) + } + var showSearchBar by rememberSaveable { + mutableStateOf(false) + } + val focusRequester = remember { FocusRequester() } + LaunchedEffect(key1 = showSearchBar, block = { + if (showSearchBar && searchStr.isEmpty()) { + focusRequester.requestFocus() + } + }) + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + val listState = rememberLazyListState() + LaunchedEffect(key1 = showAppInfos, block = { + if (showAppInfos.isNotEmpty()) { + listState.animateScrollToItem(0) + } + }) + Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { + TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { + IconButton(onClick = { + navController.popBackStack() + }) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = null, + ) + } + }, title = { + if (showSearchBar) { + AppBarTextField( + value = searchStr, + onValueChange = { newValue -> vm.searchStrFlow.value = newValue.trim() }, + hint = "请输入应用名称", + modifier = Modifier.focusRequester(focusRequester) + ) + } else { + Text(text = "${rawSubs?.name ?: subsItemId}/${group?.name ?: groupKey}") + } + }, actions = { + if (showSearchBar) { + IconButton(onClick = { + if (vm.searchStrFlow.value.isEmpty()) { + showSearchBar = false + } else { + vm.searchStrFlow.value = "" + } + }) { + Icon(Icons.Outlined.Close, contentDescription = null) + } + } else { + IconButton(onClick = { + showSearchBar = true + }) { + Icon(Icons.Default.Search, contentDescription = null) + } + IconButton(onClick = { + showEditDlg = true + }) { + Icon(Icons.Default.Edit, contentDescription = null) + } + } + }) + }, content = { paddingValues -> + LazyColumn(modifier = Modifier.padding(paddingValues), state = listState) { + items(showAppInfos, { it.id }) { appInfo -> + Row( + modifier = Modifier + .height(60.dp) + .padding(4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = rememberDrawablePainter(appInfo.icon), + contentDescription = null, + modifier = Modifier + .size(52.dp) + .clip(CircleShape) + ) + + Spacer(modifier = Modifier.width(10.dp)) + + Column( + modifier = Modifier + .padding(2.dp) + .fillMaxHeight() + .weight(1f), + verticalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = appInfo.name, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth() + ) + + Text( + text = appInfo.id, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth() + ) + } + + Spacer(modifier = Modifier.width(10.dp)) + + if (group != null) { + val checked = getChecked(excludeData, group, appIdEnable, appInfo) + Switch( + checked = checked ?: false, + onCheckedChange = { + if (checked == null) { + toast("规则内已禁用,不可修改") + return@Switch + } + vm.viewModelScope.launchTry { + val subsConfig = (vm.subsConfigFlow.value ?: SubsConfig( + type = SubsConfig.GlobalGroupType, + subsItemId = subsItemId, + groupKey = groupKey, + )).copy( + exclude = excludeData.copy( + appIds = excludeData.appIds.toMutableMap().apply { + set(appInfo.id, !it) + }) + .stringify() + ) + DbSet.subsConfigDao.insert(subsConfig) + } + }, + ) + } + } + } + item { + Spacer(modifier = Modifier.height(40.dp)) + if (showAppInfos.isEmpty()) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = "暂无搜索结果") + Spacer(modifier = Modifier.height(40.dp)) + } + } + } + } + }) + + if (group != null && showEditDlg) { + var source by remember { + mutableStateOf( + excludeData.stringify() + ) + } + val oldSource = remember { source } + AlertDialog( + title = { Text(text = "编辑禁用项") }, + text = { + OutlinedTextField( + value = source, + onValueChange = { source = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + fontSize = 12.sp, + text = tipText + ) + }, + maxLines = 10, + ) + }, + onDismissRequest = { showEditDlg = false }, + dismissButton = { + TextButton(onClick = { showEditDlg = false }) { + Text(text = "取消") + } + }, + confirmButton = { + TextButton(onClick = { + if (oldSource == source) { + toast("禁用项无变动") + return@TextButton + } + showEditDlg = false + val subsConfig = (vm.subsConfigFlow.value ?: SubsConfig( + type = SubsConfig.GlobalGroupType, + subsItemId = subsItemId, + groupKey = groupKey, + )).copy( + exclude = ExcludeData.parse(source).stringify() + ) + vm.viewModelScope.launchTry { + DbSet.subsConfigDao.insert(subsConfig) + } + }) { + Text(text = "更新") + } + }, + ) + } + +} + +private fun getChecked( + excludeData: ExcludeData, + group: RawSubscription.RawGlobalGroup, + appIdEnable: Map, + appInfo: AppInfo +): Boolean? { + val enable = appIdEnable[appInfo.id] + if (enable == false) { + return null + } + excludeData.appIds[appInfo.id]?.let { return !it } + if (appInfo.id == launcherAppId) { + return group.matchLauncher ?: false + } + if (appInfo.isSystem) { + return group.matchSystemApp ?: false + } + return group.matchAnyApp ?: true +} + +private val tipText = """ +以换行或英文逗号分割每条禁用 +示例1-禁用单个页面 +appId/activityId +示例2-禁用整个应用(移除/) +appId +示例3-开启此应用(前置!) +!appId +""".trimIndent() \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/GlobalRuleExcludeVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/GlobalRuleExcludeVm.kt new file mode 100644 index 000000000..770f8bf27 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/GlobalRuleExcludeVm.kt @@ -0,0 +1,56 @@ +package li.songe.gkd.ui + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import li.songe.gkd.data.ExcludeData +import li.songe.gkd.db.DbSet +import li.songe.gkd.ui.destinations.GlobalRuleExcludePageDestination +import li.songe.gkd.util.map +import li.songe.gkd.util.orderedAppInfosFlow +import li.songe.gkd.util.subsIdToRawFlow +import li.songe.gkd.util.subsItemsFlow +import javax.inject.Inject + +@HiltViewModel +class GlobalRuleExcludeVm @Inject constructor(stateHandle: SavedStateHandle) : ViewModel() { + private val args = GlobalRuleExcludePageDestination.argsFrom(stateHandle) + val subsItemFlow = + subsItemsFlow.map(viewModelScope) { it.find { s -> s.id == args.subsItemId } } + + val rawSubsFlow = subsIdToRawFlow.map(viewModelScope) { it[args.subsItemId] } + + val groupFlow = + rawSubsFlow.map(viewModelScope) { r -> r?.globalGroups?.find { g -> g.key == args.groupKey } } + + val appIdEnableFlow = groupFlow.map(viewModelScope) { g -> + (g?.apps ?: emptyList()).associate { a -> a.id to (a.enable ?: true) } + } + + val subsConfigFlow = + DbSet.subsConfigDao.queryGlobalGroupTypeConfig(args.subsItemId, args.groupKey) + .map { it.firstOrNull() } + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + val excludeDataFlow = subsConfigFlow.map(viewModelScope) { s -> ExcludeData.parse(s?.exclude) } + + + val searchStrFlow = MutableStateFlow("") + private val debounceSearchStrFlow = searchStrFlow.debounce(200) + .stateIn(viewModelScope, SharingStarted.Eagerly, searchStrFlow.value) + + val showAppInfosFlow = combine(debounceSearchStrFlow, orderedAppInfosFlow) { str, list -> + if (str.isBlank()) { + list + } else { + (list.filter { a -> a.name.contains(str) } + list.filter { a -> a.id.contains(str) }).distinct() + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/GlobalRulePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/GlobalRulePage.kt index 64fed22ed..b90b87662 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/GlobalRulePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/GlobalRulePage.kt @@ -52,12 +52,11 @@ import com.blankj.utilcode.util.LogUtils import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootNavGraph import kotlinx.coroutines.Dispatchers -import li.songe.gkd.data.ExcludeData import li.songe.gkd.data.RawSubscription import li.songe.gkd.data.SubsConfig -import li.songe.gkd.data.stringify import li.songe.gkd.db.DbSet import li.songe.gkd.ui.component.getDialogResult +import li.songe.gkd.ui.destinations.GlobalRuleExcludePageDestination import li.songe.gkd.ui.destinations.GroupItemPageDestination import li.songe.gkd.util.LocalNavController import li.songe.gkd.util.ProfileTransitions @@ -89,9 +88,6 @@ fun GlobalRulePage(subsItemId: Long, focusGroupKey: Int? = null) { val (editGroupRaw, setEditGroupRaw) = remember { mutableStateOf(null) } - val (excludeGroupRaw, setExcludeGroupRaw) = remember { - mutableStateOf(null) - } val (showGroupItem, setShowGroupItem) = remember { mutableStateOf( null @@ -171,7 +167,16 @@ fun GlobalRulePage(subsItemId: Long, focusGroupKey: Int? = null) { Spacer(modifier = Modifier.width(10.dp)) IconButton(onClick = { - setMenuGroupRaw(group) + if (editable) { + setMenuGroupRaw(group) + } else { + navController.navigate( + GlobalRuleExcludePageDestination( + subsItemId, + group.key + ) + ) + } }) { Icon( imageVector = Icons.Default.MoreVert, @@ -270,8 +275,13 @@ fun GlobalRulePage(subsItemId: Long, focusGroupKey: Int? = null) { Column { Text(text = "编辑禁用", modifier = Modifier .clickable { - setExcludeGroupRaw(menuGroupRaw) setMenuGroupRaw(null) + navController.navigate( + GlobalRuleExcludePageDestination( + subsItemId, + menuGroupRaw.key + ) + ) } .padding(16.dp) .fillMaxWidth()) @@ -372,60 +382,6 @@ fun GlobalRulePage(subsItemId: Long, focusGroupKey: Int? = null) { ) } - if (excludeGroupRaw != null && rawSubs != null) { - var source by remember { - mutableStateOf( - ExcludeData.parse(subsConfigs.find { s -> s.groupKey == excludeGroupRaw.key }?.exclude) - .stringify() - ) - } - val oldSource = remember { source } - AlertDialog( - title = { Text(text = "编辑禁用项") }, - text = { - OutlinedTextField( - value = source, - onValueChange = { source = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { - Text( - fontSize = 12.sp, - text = "请填入需要禁用的 appId/activityId\n以换行或英文逗号分割,示例:\ntv.danmaku.bili 表示在应用内禁用规则\ntv.danmaku.bili/tv.danmaku.bili.MainActivityV2 表示在应用内某页面禁用规则" - ) - }, - maxLines = 10, - ) - }, - onDismissRequest = { setExcludeGroupRaw(null) }, - dismissButton = { - TextButton(onClick = { setExcludeGroupRaw(null) }) { - Text(text = "取消") - } - }, - confirmButton = { - TextButton(onClick = { - if (oldSource == source) { - toast("禁用项无变动") - return@TextButton - } - setExcludeGroupRaw(null) - val newSubsConfig = - (subsConfigs.find { s -> s.groupKey == excludeGroupRaw.key } ?: SubsConfig( - type = SubsConfig.GlobalGroupType, - subsItemId = subsItemId, - groupKey = excludeGroupRaw.key, - )).copy(exclude = ExcludeData.parse(source).stringify()) - vm.viewModelScope.launchTry(Dispatchers.IO) { - DbSet.subsConfigDao.insert(newSubsConfig) - toast("更新成功") - } - }) { - Text(text = "更新") - } - }, - ) - } - if (showGroupItem != null) { AlertDialog( modifier = Modifier.defaultMinSize(300.dp), diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsVm.kt index 70269e7d7..3b5fd0439 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsVm.kt @@ -15,12 +15,11 @@ import li.songe.gkd.data.Tuple3 import li.songe.gkd.db.DbSet import li.songe.gkd.ui.destinations.SubsPageDestination import li.songe.gkd.util.appInfoCacheFlow +import li.songe.gkd.util.collator import li.songe.gkd.util.getGroupRawEnable import li.songe.gkd.util.map import li.songe.gkd.util.subsIdToRawFlow import li.songe.gkd.util.subsItemsFlow -import java.text.Collator -import java.util.Locale import javax.inject.Inject @HiltViewModel @@ -44,7 +43,6 @@ class SubsVm @Inject constructor(stateHandle: SavedStateHandle) : ViewModel() { private val sortAppsFlow = combine( subsRawFlow, appInfoCacheFlow ) { subsRaw, appInfoCache -> - val collator = Collator.getInstance(Locale.CHINESE) (subsRaw?.apps ?: emptyList()).sortedWith { a, b -> // 顺序: 已安装(有名字->无名字)->未安装(有名字(来自订阅)->无名字) collator.compare(appInfoCache[a.id]?.name ?: a.name?.let { "\uFFFF" + it } diff --git a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt index c6ff5999d..707c3d4b2 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt @@ -4,7 +4,6 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.pm.PackageManager import android.os.Build import com.blankj.utilcode.util.AppUtils import kotlinx.coroutines.Dispatchers @@ -14,10 +13,30 @@ import kotlinx.coroutines.sync.withLock import li.songe.gkd.app import li.songe.gkd.appScope import li.songe.gkd.data.AppInfo -import li.songe.gkd.util.Ext.getApplicationInfoExt val appInfoCacheFlow = MutableStateFlow(mapOf()) +val systemAppInfoCacheFlow = + appInfoCacheFlow.map(appScope) { c -> c.filter { a -> a.value.isSystem } } + +val systemAppsFlow = systemAppInfoCacheFlow.map(appScope) { c -> c.keys } + +val orderedAppInfosFlow = appInfoCacheFlow.map(appScope) { c -> + c.values.sortedWith { a, b -> + collator.compare( + if (a.isSystem) { + "\uFFFF${a.name}" + } else { + a.name + }, if (b.isSystem) { + "\uFFFF${b.name}" + } else { + b.name + } + ) + } +} + private val packageReceiver by lazy { object : BroadcastReceiver() { /** @@ -54,26 +73,6 @@ private val packageReceiver by lazy { } -private fun getAppInfo(id: String): AppInfo? { - val packageManager = app.packageManager - val info = try { // 需要权限 - val rawInfo = app.packageManager.getApplicationInfoExt( - id, PackageManager.GET_META_DATA - ) - val info = AppUtils.getAppInfo(id) ?: return null - AppInfo( - id = id, - name = packageManager.getApplicationLabel(rawInfo).toString(), - icon = packageManager.getApplicationIcon(rawInfo), - versionCode = info.versionCode, - versionName = info.versionName - ) - } catch (e: Exception) { - return null - } - return info -} - private val updateAppMutex by lazy { Mutex() } fun updateAppInfo(appIds: List) { @@ -82,8 +81,16 @@ fun updateAppInfo(appIds: List) { updateAppMutex.withLock { val newMap = appInfoCacheFlow.value.toMutableMap() appIds.forEach { appId -> - val newAppInfo = getAppInfo(appId) - if (newAppInfo != null) { + val a = AppUtils.getAppInfo(appId) + if (a != null) { + val newAppInfo = AppInfo( + id = a.packageName, + name = a.name, + icon = a.icon, + versionCode = a.versionCode, + versionName = a.versionName, + isSystem = a.isSystem, + ) newMap[appId] = newAppInfo } else { newMap.remove(appId) @@ -97,4 +104,20 @@ fun updateAppInfo(appIds: List) { fun initAppState() { packageReceiver + appScope.launchTry(Dispatchers.IO) { + updateAppMutex.withLock { + val appMap = mutableMapOf() + AppUtils.getAppsInfo().forEach { a -> + appMap[a.packageName] = AppInfo( + id = a.packageName, + name = a.name, + icon = a.icon, + versionCode = a.versionCode, + versionName = a.versionName, + isSystem = a.isSystem, + ) + } + appInfoCacheFlow.value = appMap + } + } } \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/util/Singleton.kt b/app/src/main/kotlin/li/songe/gkd/util/Singleton.kt index 5ef920d74..47ed63175 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Singleton.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Singleton.kt @@ -15,7 +15,9 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import li.songe.gkd.app import okhttp3.OkHttpClient +import java.text.Collator import java.time.Duration +import java.util.Locale val kv by lazy { MMKV.mmkvWithID("kv")!! } @@ -69,3 +71,6 @@ val imageLoader by lazy { }.build() } + +val collator by lazy { Collator.getInstance(Locale.CHINESE)!! } + diff --git a/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt b/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt index 223577fe9..a455f4285 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt @@ -223,14 +223,5 @@ fun initSubsState() { } subsIdToRawFlow.value = newMap } - var oldAppIds = emptySet() - val appIdsFlow = subsIdToRawFlow.map(appScope) { e -> - e.values.map { s -> s.apps.map { a -> a.id } }.flatten().toSet() - } - appIdsFlow.collect { newAppIds -> - // diff new appId - updateAppInfo(newAppIds.subtract(oldAppIds).toList()) - oldAppIds = newAppIds - } } } \ No newline at end of file