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 2e0e10ce1..41b78a047 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/AppInfo.kt @@ -1,12 +1,35 @@ package li.songe.gkd.data +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo import android.graphics.drawable.Drawable +import android.os.Build +import li.songe.gkd.app data class AppInfo( val id: String, val name: String, val icon: Drawable?, - val versionCode: Int, + val versionCode: Long, val versionName: String?, val isSystem: Boolean, -) \ No newline at end of file + val mtime: Long, +) + +fun PackageInfo.toAppInfo(): AppInfo? { + applicationInfo ?: return null + return AppInfo( + id = packageName, + name = applicationInfo.loadLabel(app.packageManager).toString(), + icon = applicationInfo.loadIcon(app.packageManager), + versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + longVersionCode + } else { + @Suppress("DEPRECATION") + versionCode.toLong() + }, + versionName = versionName, + isSystem = (ApplicationInfo.FLAG_SYSTEM and applicationInfo.flags) != 0, + mtime = lastUpdateTime + ) +} \ 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 936c6b859..ccb0cec5f 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/AppRule.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/AppRule.kt @@ -5,7 +5,7 @@ class AppRule( subsItem: SubsItem, group: RawSubscription.RawAppGroup, rawSubs: RawSubscription, - exclude: String, + exclude: String?, val app: RawSubscription.RawApp, ) : ResolvedRule( rule = rule, 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 14783df00..706b96b0b 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/GlobalRule.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/GlobalRule.kt @@ -15,7 +15,7 @@ class GlobalRule( rule: RawSubscription.RawGlobalRule, group: RawSubscription.RawGlobalGroup, rawSubs: RawSubscription, - exclude: String, + exclude: String?, ) : ResolvedRule( rule = rule, group = group, 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 fa58906b1..03bdffdc3 100644 --- a/app/src/main/kotlin/li/songe/gkd/data/ResolvedRule.kt +++ b/app/src/main/kotlin/li/songe/gkd/data/ResolvedRule.kt @@ -12,7 +12,7 @@ sealed class ResolvedRule( val group: RawSubscription.RawGroupProps, val rawSubs: RawSubscription, val subsItem: SubsItem, - val exclude: String, + val exclude: String?, ) { val key = rule.key val index = group.rules.indexOf(rule) diff --git a/app/src/main/kotlin/li/songe/gkd/service/AbState.kt b/app/src/main/kotlin/li/songe/gkd/service/AbState.kt index 42f66f733..4610dc201 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/AbState.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/AbState.kt @@ -12,12 +12,12 @@ import li.songe.gkd.data.GlobalRule import li.songe.gkd.data.ResolvedRule import li.songe.gkd.data.SubsConfig import li.songe.gkd.db.DbSet -import li.songe.gkd.util.AllRules import li.songe.gkd.util.Ext.getDefaultLauncherAppId -import li.songe.gkd.util.allRulesFlow +import li.songe.gkd.util.RuleSummary import li.songe.gkd.util.increaseClickCount import li.songe.gkd.util.launchTry import li.songe.gkd.util.recordStoreFlow +import li.songe.gkd.util.ruleSummaryFlow data class TopActivity( val appId: String = "", @@ -31,7 +31,7 @@ data class ActivityRule( private val appRules: List = emptyList(), private val globalRules: List = emptyList(), val topActivity: TopActivity = TopActivity(), - val allRules: AllRules = AllRules(), + val ruleSummary: RuleSummary = RuleSummary(), ) { val currentRules = (appRules + globalRules).sortedBy { r -> r.order } } @@ -57,17 +57,17 @@ private fun getFixTopActivity(): TopActivity { fun getAndUpdateCurrentRules(): ActivityRule { val topActivity = getFixTopActivity() val oldActivityRule = activityRuleFlow.value - val allRules = allRulesFlow.value + val allRules = ruleSummaryFlow.value val idChanged = topActivity.appId != oldActivityRule.topActivity.appId val topChanged = idChanged || oldActivityRule.topActivity != topActivity - if (topChanged || oldActivityRule.allRules !== allRules) { + if (topChanged || oldActivityRule.ruleSummary !== allRules) { val newActivityRule = ActivityRule( - allRules = allRules, + ruleSummary = allRules, topActivity = topActivity, appRules = (allRules.appIdToRules[topActivity.appId] ?: emptyList()).filter { rule -> rule.matchActivity(topActivity.appId, topActivity.activityId) }, - globalRules = allRulesFlow.value.globalRules.filter { r -> + globalRules = ruleSummaryFlow.value.globalRules.filter { r -> r.matchActivity(topActivity.appId, topActivity.activityId) }, ) 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 bb4b807f0..2499f9a7e 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/GkdAbService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/GkdAbService.kt @@ -210,8 +210,8 @@ class GkdAbService : CompositionAbService({ } } val t = System.currentTimeMillis() - if (t - lastTriggerTime < 10_000 || t - appChangeTime < 5_000) { - scope.launch(actionThread) {// 在任意规则触发10s内或APP切换5s内使用主动探测查询 + if (t - lastTriggerTime < 5_000 || t - appChangeTime < 5_000) { + scope.launch(actionThread) {// 在任意规则触发5s内或APP切换5s内使用主动探测查询 delay(300) if (queryTaskJob?.isActive != true) { newQueryTask() 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 216cc5577..d62644fe2 100644 --- a/app/src/main/kotlin/li/songe/gkd/service/ManageService.kt +++ b/app/src/main/kotlin/li/songe/gkd/service/ManageService.kt @@ -15,9 +15,9 @@ import li.songe.gkd.composition.CompositionService import li.songe.gkd.notif.abNotif import li.songe.gkd.notif.createNotif import li.songe.gkd.notif.defaultChannel -import li.songe.gkd.util.allRulesFlow import li.songe.gkd.util.clickCountFlow import li.songe.gkd.util.map +import li.songe.gkd.util.ruleSummaryFlow import li.songe.gkd.util.storeFlow class ManageService : CompositionService({ @@ -27,7 +27,7 @@ class ManageService : CompositionService({ val scope = useScope() scope.launch { combine( - allRulesFlow, + ruleSummaryFlow, clickCountFlow, storeFlow.map(scope) { it.enableService }, GkdAbService.isRunning diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt new file mode 100644 index 000000000..da027fc96 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/AppConfigPage.kt @@ -0,0 +1,129 @@ +package li.songe.gkd.ui + +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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootNavGraph +import li.songe.gkd.util.LocalNavController +import li.songe.gkd.util.ProfileTransitions +import li.songe.gkd.util.appInfoCacheFlow +import li.songe.gkd.util.ruleSummaryFlow + +@RootNavGraph +@Destination(style = ProfileTransitions::class) +@Composable +fun AppConfigPage(appId: String) { + val navController = LocalNavController.current + val vm = hiltViewModel() + val appInfoCache by appInfoCacheFlow.collectAsState() + val appInfo = appInfoCache[appId] + val ruleSummary by ruleSummaryFlow.collectAsState() + + val globalGroups = ruleSummary.globalGroups + + val appGroups = ruleSummary.appIdToAllGroups[appId] ?: emptyList() + + Scaffold(topBar = { + TopAppBar(navigationIcon = { + IconButton(onClick = { + navController.popBackStack() + }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + ) + } + }, title = { + Text( + text = appInfo?.name ?: appId, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + ) + }, actions = {}) + }, content = { contentPadding -> + LazyColumn( + modifier = Modifier.padding(contentPadding) + ) { + items(appGroups) { (group, enable) -> + Row( + modifier = Modifier + .padding(10.dp, 6.dp) + .fillMaxWidth() + .height(45.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + verticalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = group.name, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth() + ) + if (group.valid) { + Text( + text = group.desc ?: "", + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + fontSize = 14.sp + ) + } else { + Text( + text = "非法选择器", + modifier = Modifier.fillMaxWidth(), + fontSize = 14.sp, + color = MaterialTheme.colorScheme.error + ) + } + } + Spacer(modifier = Modifier.width(10.dp)) + IconButton(onClick = {}) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "more", + ) + } + Spacer(modifier = Modifier.width(10.dp)) + Switch(checked = enable, modifier = Modifier, onCheckedChange = {}) + } + } + } + }) +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/AppConfigVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/AppConfigVm.kt new file mode 100644 index 000000000..3487bf4fd --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/AppConfigVm.kt @@ -0,0 +1,11 @@ +package li.songe.gkd.ui + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class AppConfigVm @Inject constructor(stateHandle: SavedStateHandle) : ViewModel() { + +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/CategoryPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/CategoryPage.kt index 27bbc2cbc..75b67a866 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/CategoryPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/CategoryPage.kt @@ -49,6 +49,7 @@ import kotlinx.coroutines.Dispatchers import li.songe.gkd.data.CategoryConfig import li.songe.gkd.data.RawSubscription import li.songe.gkd.db.DbSet +import li.songe.gkd.ui.home.enableGroupRadioOptions import li.songe.gkd.util.LocalNavController import li.songe.gkd.util.ProfileTransitions import li.songe.gkd.util.launchTry diff --git a/app/src/main/kotlin/li/songe/gkd/ui/ControlPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/ControlPage.kt deleted file mode 100644 index a380c7162..000000000 --- a/app/src/main/kotlin/li/songe/gkd/ui/ControlPage.kt +++ /dev/null @@ -1,220 +0,0 @@ -package li.songe.gkd.ui - -import android.content.Intent -import android.net.Uri -import android.provider.Settings -import androidx.compose.foundation.clickable -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.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.automirrored.filled.OpenInNew -import androidx.compose.material.icons.filled.Home -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.app.NotificationManagerCompat -import androidx.hilt.navigation.compose.hiltViewModel -import li.songe.gkd.MainActivity -import li.songe.gkd.appScope -import li.songe.gkd.service.GkdAbService -import li.songe.gkd.service.ManageService -import li.songe.gkd.ui.component.AuthCard -import li.songe.gkd.ui.component.TextSwitch -import li.songe.gkd.ui.destinations.ClickLogPageDestination -import li.songe.gkd.util.HOME_PAGE_URL -import li.songe.gkd.util.LocalNavController -import li.songe.gkd.util.checkOrRequestNotifPermission -import li.songe.gkd.util.launchTry -import li.songe.gkd.util.navigate -import li.songe.gkd.util.storeFlow -import li.songe.gkd.util.updateStorage -import li.songe.gkd.util.usePollState - -val controlNav = BottomNavItem(label = "主页", icon = Icons.Default.Home) - -@Composable -fun ControlPage() { - val context = LocalContext.current as MainActivity - val navController = LocalNavController.current - val vm = hiltViewModel() - val latestRecordDesc by vm.latestRecordDescFlow.collectAsState() - val subsStatus by vm.subsStatusFlow.collectAsState() - val store by storeFlow.collectAsState() - - val gkdAccessRunning by GkdAbService.isRunning.collectAsState() - val manageRunning by ManageService.isRunning.collectAsState() - val canDrawOverlays by usePollState { Settings.canDrawOverlays(context) } - val canNotif by usePollState { - NotificationManagerCompat.from(context).areNotificationsEnabled() - } - - Column( - modifier = Modifier.verticalScroll( - state = rememberScrollState() - ) - ) { - if (!gkdAccessRunning) { - AuthCard( - title = "无障碍权限", - desc = "用于获取屏幕信息,点击屏幕上的控件", - onAuthClick = { - appScope.launchTry { - val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - // android.content.ActivityNotFoundException - context.startActivity(intent) - } - }) - } else { - TextSwitch( - name = "服务开启", - desc = "保持服务开启,根据订阅规则匹配屏幕目标节点", - checked = store.enableService, - onCheckedChange = { - updateStorage( - storeFlow, store.copy( - enableService = it - ) - ) - }) - } - HorizontalDivider() - - if (!canNotif) { - AuthCard(title = "通知权限", - desc = "用于显示各类服务状态数据及前后台提示", - onAuthClick = { - checkOrRequestNotifPermission(context) - }) - HorizontalDivider() - } - - if (!canDrawOverlays) { - AuthCard( - title = "悬浮窗权限", - desc = "用于后台提示,显示保存快照按钮等功能", - onAuthClick = { - appScope.launchTry { - val intent = Intent( - Settings.ACTION_MANAGE_OVERLAY_PERMISSION, - ) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - context.startActivity(intent) - } - }) - HorizontalDivider() - } - - TextSwitch( - name = "常驻通知", - desc = "在通知栏显示服务运行状态及统计数据", - checked = manageRunning && store.enableStatusService, - onCheckedChange = { - if (it) { - if (!checkOrRequestNotifPermission(context)) { - return@TextSwitch - } - updateStorage( - storeFlow, store.copy( - enableStatusService = true - ) - ) - ManageService.start(context) - } else { - updateStorage( - storeFlow, store.copy( - enableStatusService = false - ) - ) - ManageService.stop(context) - } - }) - HorizontalDivider() - - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable { - appScope.launchTry { - context.startActivity( - Intent( - Intent.ACTION_VIEW, Uri.parse(HOME_PAGE_URL) - ) - ) - } - } - .padding(10.dp, 5.dp), - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = "使用说明", fontSize = 18.sp - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = HOME_PAGE_URL, - fontSize = 14.sp, - color = MaterialTheme.colorScheme.primary, - ) - } - Icon(imageVector = Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null) - } - HorizontalDivider() - - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable { - navController.navigate(ClickLogPageDestination) - } - .padding(10.dp, 5.dp), - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = "点击记录", fontSize = 18.sp - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = "如误触可在此快速定位关闭规则", fontSize = 14.sp - ) - } - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null - ) - } - HorizontalDivider() - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(10.dp, 5.dp) - ) { - Text(text = subsStatus, fontSize = 18.sp) - if (latestRecordDesc != null) { - Text( - text = "最近点击: $latestRecordDesc", fontSize = 14.sp - ) - } - } - - } -} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/ControlVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/ControlVm.kt deleted file mode 100644 index 64d6fefcd..000000000 --- a/app/src/main/kotlin/li/songe/gkd/ui/ControlVm.kt +++ /dev/null @@ -1,48 +0,0 @@ -package li.songe.gkd.ui - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn -import li.songe.gkd.db.DbSet -import li.songe.gkd.util.allRulesFlow -import li.songe.gkd.util.appInfoCacheFlow -import li.songe.gkd.util.clickCountFlow -import li.songe.gkd.util.subsIdToRawFlow -import javax.inject.Inject - -@HiltViewModel -class ControlVm @Inject constructor() : ViewModel() { - private val latestRecordFlow = - DbSet.clickLogDao.queryLatest().stateIn(viewModelScope, SharingStarted.Eagerly, null) - - val latestRecordDescFlow = combine( - latestRecordFlow, subsIdToRawFlow, appInfoCacheFlow - ) { latestRecord, subsIdToRaw, appInfoCache -> - if (latestRecord == null) return@combine null - val groupName = - subsIdToRaw[latestRecord.subsId]?.apps?.find { a -> a.id == latestRecord.appId }?.groups?.find { g -> g.key == latestRecord.groupKey }?.name - val appName = appInfoCache[latestRecord.appId]?.name - val appShowName = appName ?: latestRecord.appId ?: "" - if (groupName != null) { - if (groupName.contains(appShowName)) { - groupName - } else { - "$appShowName-$groupName" - } - } else { - appShowName - } - }.stateIn(viewModelScope, SharingStarted.Eagerly, null) - - val subsStatusFlow = combine(allRulesFlow, clickCountFlow) { allRules, clickCount -> - allRules.numText + if (clickCount > 0) { - "/${clickCount}点击" - } else { - "" - } - }.stateIn(viewModelScope, SharingStarted.Eagerly, "") - -} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/GlobalRuleExcludePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/GlobalRuleExcludePage.kt index 9e402ec11..c472078b3 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/GlobalRuleExcludePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/GlobalRuleExcludePage.kt @@ -2,6 +2,7 @@ package li.songe.gkd.ui import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -11,20 +12,27 @@ 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.layout.wrapContentSize 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.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Sort import androidx.compose.material.icons.filled.Android -import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -77,6 +85,8 @@ fun GlobalRuleExcludePage(subsItemId: Long, groupKey: Int) { val excludeData = vm.excludeDataFlow.collectAsState().value val showAppInfos = vm.showAppInfosFlow.collectAsState().value val searchStr by vm.searchStrFlow.collectAsState() + val showSystemApp by vm.showSystemAppFlow.collectAsState() + val sortByMtime by vm.sortByMtimeFlow.collectAsState() var showEditDlg by remember { mutableStateOf(false) @@ -97,6 +107,7 @@ fun GlobalRuleExcludePage(subsItemId: Long, groupKey: Int) { listState.animateScrollToItem(0) } }) + var expanded by remember { mutableStateOf(false) } Scaffold(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { TopAppBar(scrollBehavior = scrollBehavior, navigationIcon = { IconButton(onClick = { @@ -116,7 +127,12 @@ fun GlobalRuleExcludePage(subsItemId: Long, groupKey: Int) { modifier = Modifier.focusRequester(focusRequester) ) } else { - Text(text = "${rawSubs?.name ?: subsItemId}/${group?.name ?: groupKey}") + Text( + text = "${rawSubs?.name ?: subsItemId}/${group?.name ?: groupKey}", + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + ) } }, actions = { if (showSearchBar) { @@ -138,7 +154,81 @@ fun GlobalRuleExcludePage(subsItemId: Long, groupKey: Int) { IconButton(onClick = { showEditDlg = true }) { - Icon(Icons.Default.Edit, contentDescription = null) + Icon(Icons.Outlined.Edit, contentDescription = null) + } + + IconButton(onClick = { + expanded = true + }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Sort, + contentDescription = null + ) + } + Box( + modifier = Modifier + .wrapContentSize(Alignment.TopStart) + ) { + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = !sortByMtime, + onClick = { + vm.sortByMtimeFlow.value = false + } + ) + Text("按名称") + } + }, + onClick = { + vm.sortByMtimeFlow.value = false + }, + ) + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = sortByMtime, + onClick = { vm.sortByMtimeFlow.value = true } + ) + Text("按更新时间") + } + } + }, + onClick = { + vm.sortByMtimeFlow.value = true + }, + ) + HorizontalDivider() + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = showSystemApp, + onCheckedChange = { + vm.showSystemAppFlow.value = !vm.showSystemAppFlow.value + }) + Text("显示系统应用") + } + }, + onClick = { + vm.showSystemAppFlow.value = !vm.showSystemAppFlow.value + }, + ) + } } } }) diff --git a/app/src/main/kotlin/li/songe/gkd/ui/GlobalRuleExcludeVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/GlobalRuleExcludeVm.kt index d2a33581a..9def39c86 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/GlobalRuleExcludeVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/GlobalRuleExcludeVm.kt @@ -38,11 +38,32 @@ class GlobalRuleExcludeVm @Inject constructor(stateHandle: SavedStateHandle) : V 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() + val sortByMtimeFlow = MutableStateFlow(false) + val showSystemAppFlow = MutableStateFlow(false) + val showAppInfosFlow = combine( + debounceSearchStrFlow, + orderedAppInfosFlow, + sortByMtimeFlow, + showSystemAppFlow + ) { str, list, sortByMtime, showSystemApp -> + list.let { + if (sortByMtime) { + it.sortedBy { a -> -a.mtime } + } else { + it + } + }.let { + if (!showSystemApp) { + it.filter { a -> !a.isSystem } + } else { + it + } + }.let { + if (str.isBlank()) { + it + } else { + (it.filter { a -> a.name.contains(str) } + it.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/HomePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/HomePage.kt deleted file mode 100644 index 8f2cefe4b..000000000 --- a/app/src/main/kotlin/li/songe/gkd/ui/HomePage.kt +++ /dev/null @@ -1,67 +0,0 @@ -package li.songe.gkd.ui - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Icon -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.hilt.navigation.compose.hiltViewModel -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.RootNavGraph -import li.songe.gkd.util.ProfileTransitions - -val BottomNavItems = listOf( - subsNav, controlNav, settingsNav -) - -data class BottomNavItem( - val label: String, - val icon: ImageVector, -) - -@RootNavGraph(start = true) -@Destination(style = ProfileTransitions::class) -@Composable -fun HomePage() { - val vm = hiltViewModel() - val tab by vm.tabFlow.collectAsState() - - Scaffold(topBar = { - TopAppBar(title = { - Text( - text = tab.label, - ) - }) - }, bottomBar = { - NavigationBar { - BottomNavItems.forEach { navItem -> - NavigationBarItem(selected = tab == navItem, modifier = Modifier, onClick = { - vm.tabFlow.value = navItem - }, icon = { - Icon( - imageVector = navItem.icon, - contentDescription = navItem.label - ) - }, label = { - Text(text = navItem.label) - }) - } - } - }, content = { padding -> - Box(modifier = Modifier.padding(padding)) { - when (tab) { - subsNav -> SubsManagePage() - controlNav -> ControlPage() - settingsNav -> SettingsPage() - } - } - }) -} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/HomePageVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/HomePageVm.kt deleted file mode 100644 index 1bf72cfc8..000000000 --- a/app/src/main/kotlin/li/songe/gkd/ui/HomePageVm.kt +++ /dev/null @@ -1,120 +0,0 @@ -package li.songe.gkd.ui - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import io.ktor.client.call.body -import io.ktor.client.plugins.onUpload -import io.ktor.client.request.forms.formData -import io.ktor.client.request.forms.submitFormWithBinaryData -import io.ktor.client.statement.bodyAsText -import io.ktor.http.Headers -import io.ktor.http.HttpHeaders -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import li.songe.gkd.appScope -import li.songe.gkd.data.GithubPoliciesAsset -import li.songe.gkd.data.RpcError -import li.songe.gkd.data.SubsItem -import li.songe.gkd.db.DbSet -import li.songe.gkd.util.FILE_UPLOAD_URL -import li.songe.gkd.util.LoadStatus -import li.songe.gkd.util.authActionFlow -import li.songe.gkd.util.checkUpdate -import li.songe.gkd.util.client -import li.songe.gkd.util.initFolder -import li.songe.gkd.util.launchTry -import li.songe.gkd.util.logZipDir -import li.songe.gkd.util.newVersionApkDir -import li.songe.gkd.util.snapshotZipDir -import li.songe.gkd.util.storeFlow -import java.io.File -import javax.inject.Inject - -@HiltViewModel -class HomePageVm @Inject constructor() : ViewModel() { - val tabFlow = MutableStateFlow(controlNav) - - init { - appScope.launchTry(Dispatchers.IO) { - val localSubsItem = SubsItem( - id = -2, order = -2, mtime = System.currentTimeMillis() - ) - if (!DbSet.subsItemDao.query().first().any { s -> s.id == localSubsItem.id }) { - DbSet.subsItemDao.insert(localSubsItem) - } - } - - if (storeFlow.value.autoCheckAppUpdate) { - appScope.launch { - try { - checkUpdate() - } catch (e: Exception) { - e.printStackTrace() - } - } - } - - viewModelScope.launchTry(Dispatchers.IO) { - // 每次进入删除缓存 - listOf(snapshotZipDir, newVersionApkDir, logZipDir).forEach { dir -> - if (dir.isDirectory && dir.exists()) { - dir.listFiles()?.forEach { file -> - if (file.isFile) { - file.delete() - } - } - } - } - } - - viewModelScope.launchTry(Dispatchers.IO) { - // 在某些机型由于未知原因创建失败 - // 在此保证每次重新打开APP都能重新检测创建 - initFolder() - } - } - - val uploadStatusFlow = MutableStateFlow?>(null) - var uploadJob: Job? = null - - fun uploadZip(zipFile: File) { - uploadJob = viewModelScope.launchTry(Dispatchers.IO) { - uploadStatusFlow.value = LoadStatus.Loading() - try { - val response = - client.submitFormWithBinaryData(url = FILE_UPLOAD_URL, formData = formData { - append("\"file\"", zipFile.readBytes(), Headers.build { - append(HttpHeaders.ContentType, "application/x-zip-compressed") - append(HttpHeaders.ContentDisposition, "filename=\"file.zip\"") - }) - }) { - onUpload { bytesSentTotal, contentLength -> - if (uploadStatusFlow.value is LoadStatus.Loading) { - uploadStatusFlow.value = - LoadStatus.Loading(bytesSentTotal / contentLength.toFloat()) - } - } - } - if (response.headers["X_RPC_OK"] == "true") { - val policiesAsset = response.body() - uploadStatusFlow.value = LoadStatus.Success(policiesAsset) - } else if (response.headers["X_RPC_OK"] == "false") { - uploadStatusFlow.value = LoadStatus.Failure(response.body()) - } else { - uploadStatusFlow.value = LoadStatus.Failure(Exception(response.bodyAsText())) - } - } catch (e: Exception) { - uploadStatusFlow.value = LoadStatus.Failure(e) - } - } - } - - override fun onCleared() { - super.onCleared() - authActionFlow.value = null - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsManageVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsManageVm.kt deleted file mode 100644 index f05d3ae13..000000000 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsManageVm.kt +++ /dev/null @@ -1,144 +0,0 @@ -package li.songe.gkd.ui - -import android.webkit.URLUtil -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.blankj.utilcode.util.LogUtils -import dagger.hilt.android.lifecycle.HiltViewModel -import io.ktor.client.call.body -import io.ktor.client.request.get -import io.ktor.client.statement.bodyAsText -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import li.songe.gkd.data.RawSubscription -import li.songe.gkd.data.SubsItem -import li.songe.gkd.data.SubsVersion -import li.songe.gkd.db.DbSet -import li.songe.gkd.util.client -import li.songe.gkd.util.launchTry -import li.songe.gkd.util.subsIdToRawFlow -import li.songe.gkd.util.subsItemsFlow -import li.songe.gkd.util.toast -import li.songe.gkd.util.updateSubscription -import javax.inject.Inject - - -@HiltViewModel -class SubsManageVm @Inject constructor() : ViewModel() { - - fun addSubsFromUrl(url: String) = viewModelScope.launchTry(Dispatchers.IO) { - - if (refreshingFlow.value) return@launchTry - if (!URLUtil.isNetworkUrl(url)) { - toast("非法链接") - return@launchTry - } - val subItems = subsItemsFlow.value - if (subItems.any { it.updateUrl == url }) { - toast("订阅链接已存在") - return@launchTry - } - refreshingFlow.value = true - try { - val text = try { - client.get(url).bodyAsText() - } catch (e: Exception) { - e.printStackTrace() - LogUtils.d(e) - toast("下载订阅文件失败") - return@launchTry - } - val newSubsRaw = try { - RawSubscription.parse(text) - } catch (e: Exception) { - e.printStackTrace() - LogUtils.d(e) - toast("解析订阅文件失败") - return@launchTry - } - if (subItems.any { it.id == newSubsRaw.id }) { - toast("订阅已存在") - return@launchTry - } - if (newSubsRaw.id < 0) { - toast("订阅id不可为${newSubsRaw.id}\n负数id为内部使用") - return@launchTry - } - val newItem = SubsItem( - id = newSubsRaw.id, - updateUrl = newSubsRaw.updateUrl ?: url, - order = if (subItems.isEmpty()) 1 else (subItems.maxBy { it.order }.order + 1) - ) - updateSubscription(newSubsRaw) - DbSet.subsItemDao.insert(newItem) - toast("成功添加订阅") - } finally { - refreshingFlow.value = false - } - - } - - val refreshingFlow = MutableStateFlow(false) - fun refreshSubs() = viewModelScope.launch(Dispatchers.IO) { - if (refreshingFlow.value) return@launch - refreshingFlow.value = true - var errorNum = 0 - val oldSubItems = subsItemsFlow.value - val newSubsItems = oldSubItems.mapNotNull { oldItem -> - if (oldItem.updateUrl == null) return@mapNotNull null - val oldSubsRaw = subsIdToRawFlow.value[oldItem.id] - try { - if (oldSubsRaw?.checkUpdateUrl != null) { - try { - val subsVersion = - client.get(oldSubsRaw.checkUpdateUrl).body() - LogUtils.d("快速检测更新成功", subsVersion) - if (subsVersion.id != oldSubsRaw.id) { - toast("${oldItem.id}:checkUpdateUrl获取id不一致") - return@mapNotNull null - } - if (subsVersion.version <= oldSubsRaw.version) { - return@mapNotNull null - } - } catch (e: Exception) { - LogUtils.d("快速检测更新失败", oldItem, e) - } - } - val newSubsRaw = RawSubscription.parse( - client.get(oldSubsRaw?.updateUrl ?: oldItem.updateUrl).bodyAsText() - ) - if (newSubsRaw.id != oldItem.id) { - toast("${oldItem.id}:updateUrl获取id不一致") - return@mapNotNull null - } - if (oldSubsRaw != null && newSubsRaw.version <= oldSubsRaw.version) { - return@mapNotNull null - } - val newItem = oldItem.copy( - mtime = System.currentTimeMillis(), - ) - updateSubscription(newSubsRaw) - newItem - } catch (e: Exception) { - e.printStackTrace() - errorNum++ - null - } - } - if (newSubsItems.isEmpty()) { - if (errorNum == oldSubItems.size) { - toast("更新失败") - } else { - toast("暂无更新") - } - } else { - DbSet.subsItemDao.update(*newSubsItems.toTypedArray()) - toast("更新 ${newSubsItems.size} 条订阅") - } - delay(500) - refreshingFlow.value = false - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/SubsPage.kt index 03657017f..09ef4c2a5 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsPage.kt @@ -2,26 +2,35 @@ package li.songe.gkd.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Sort import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -41,6 +50,7 @@ import androidx.compose.ui.Modifier 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.window.Dialog import androidx.hilt.navigation.compose.hiltViewModel @@ -109,6 +119,10 @@ fun SubsPage( } }) val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + var expanded by remember { mutableStateOf(false) } + val showUninstallApp by vm.showUninstallAppFlow.collectAsState() + val sortByMtime by vm.sortByMtimeFlow.collectAsState() + Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { @@ -130,7 +144,12 @@ fun SubsPage( modifier = Modifier.focusRequester(focusRequester) ) } else { - Text(text = "${subsRaw?.name ?: subsItemId}/应用规则") + Text( + text = "${subsRaw?.name ?: subsItemId}/应用规则", + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + ) } }, actions = { if (showSearchBar) { @@ -149,6 +168,76 @@ fun SubsPage( }) { Icon(Icons.Outlined.Search, contentDescription = null) } + IconButton(onClick = { expanded = true }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Sort, + contentDescription = null + ) + } + Box( + modifier = Modifier + .wrapContentSize(Alignment.TopStart) + ) { + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = !sortByMtime, + onClick = { vm.sortByMtimeFlow.value = false } + ) + Text("按名称") + } + }, + onClick = { + vm.sortByMtimeFlow.value = false + }, + ) + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = sortByMtime, + onClick = { vm.sortByMtimeFlow.value = true } + ) + Text("按更新时间") + } + } + }, + onClick = { + vm.sortByMtimeFlow.value = true + }, + ) + HorizontalDivider() + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = showUninstallApp, + onCheckedChange = { + vm.showUninstallAppFlow.value = it + }) + Text("显示未安装应用") + } + }, + onClick = { + vm.showUninstallAppFlow.value = !showUninstallApp + }, + ) + } + } + } }) }, 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 3b5fd0439..053e8d954 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsVm.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/SubsVm.kt @@ -40,23 +40,40 @@ class SubsVm @Inject constructor(stateHandle: SavedStateHandle) : ViewModel() { private val categoryConfigsFlow = DbSet.categoryConfigDao.queryConfig(args.subsItemId) .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + val sortByMtimeFlow = MutableStateFlow(false) + val showUninstallAppFlow = MutableStateFlow(false) private val sortAppsFlow = combine( - subsRawFlow, appInfoCacheFlow - ) { subsRaw, appInfoCache -> - (subsRaw?.apps ?: emptyList()).sortedWith { a, b -> + subsRawFlow, appInfoCacheFlow, showUninstallAppFlow, sortByMtimeFlow + ) { subsRaw, appInfoCache, showUninstallApp, sortByMtime -> + val apps = (subsRaw?.apps ?: emptyList()).let { + if (showUninstallApp) { + it + } else { + it.filter { a -> appInfoCache.containsKey(a.id) } + } + } + apps.sortedWith { a, b -> // 顺序: 已安装(有名字->无名字)->未安装(有名字(来自订阅)->无名字) collator.compare(appInfoCache[a.id]?.name ?: a.name?.let { "\uFFFF" + it } ?: ("\uFFFF\uFFFF" + a.id), appInfoCache[b.id]?.name ?: b.name?.let { "\uFFFF" + it } ?: ("\uFFFF\uFFFF" + b.id)) + }.let { + if (sortByMtime) { + it.sortedBy { a -> -(appInfoCache[a.id]?.mtime ?: 0) } + } else { + it + } } }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + val searchStrFlow = MutableStateFlow("") private val debounceSearchStr = searchStrFlow.debounce(200) .stateIn(viewModelScope, SharingStarted.Eagerly, searchStrFlow.value) + private val appAndConfigsFlow = combine( subsRawFlow, sortAppsFlow, diff --git a/app/src/main/kotlin/li/songe/gkd/ui/component/AppBarTextField.kt b/app/src/main/kotlin/li/songe/gkd/ui/component/AppBarTextField.kt index e2206501d..9e3d716e2 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/component/AppBarTextField.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/component/AppBarTextField.kt @@ -15,14 +15,11 @@ import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults.indicatorLine import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.takeOrElse @@ -31,7 +28,6 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp /** * https://stackoverflow.com/questions/73664765 @@ -58,7 +54,7 @@ fun AppBarTextField( val textColor = textStyle.color.takeOrElse { MaterialTheme.colorScheme.onSurface } - val mergedTextStyle = textStyle.merge(TextStyle(color = textColor, lineHeight = 50.sp)) + val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) // request focus when this composable is first initialized // val focusRequester = FocusRequester() diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt new file mode 100644 index 000000000..adc7bb43c --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/AppListPage.kt @@ -0,0 +1,302 @@ +package li.songe.gkd.ui.home + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.layout.wrapContentSize +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.automirrored.filled.Sort +import androidx.compose.material.icons.filled.Android +import androidx.compose.material.icons.filled.Apps +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +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.hilt.navigation.compose.hiltViewModel +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import li.songe.gkd.ui.component.AppBarTextField +import li.songe.gkd.ui.destinations.AppConfigPageDestination +import li.songe.gkd.util.LocalNavController +import li.songe.gkd.util.navigate +import li.songe.gkd.util.ruleSummaryFlow + +val appListNav = BottomNavItem( + label = "应用", icon = Icons.Default.Apps +) + +@Composable +fun useAppListPage(): ScaffoldExt { + val navController = LocalNavController.current + + val vm = hiltViewModel() + val showSystemApp by vm.showSystemAppFlow.collectAsState() + val sortByMtime by vm.sortByMtimeFlow.collectAsState() + val orderedAppInfos by vm.appInfosFlow.collectAsState() + val searchStr by vm.searchStrFlow.collectAsState() + val ruleSummary by ruleSummaryFlow.collectAsState() + + val globalDesc = if (ruleSummary.globalGroups.isNotEmpty()) { + "${ruleSummary.globalGroups.size}全局" + } else { + null + } + + var expanded by remember { mutableStateOf(false) } + var showSearchBar by rememberSaveable { + mutableStateOf(false) + } + val focusRequester = remember { FocusRequester() } + LaunchedEffect(key1 = showSearchBar, block = { + if (showSearchBar && searchStr.isEmpty()) { + focusRequester.requestFocus() + } + if (!showSearchBar) { + vm.searchStrFlow.value = "" + } + }) + val listState = rememberLazyListState() + LaunchedEffect(key1 = orderedAppInfos, block = { + listState.scrollToItem(0) + }) + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + return ScaffoldExt(navItem = appListNav, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + TopAppBar(scrollBehavior = scrollBehavior, title = { + if (showSearchBar) { + AppBarTextField( + value = searchStr, + onValueChange = { newValue -> vm.searchStrFlow.value = newValue.trim() }, + hint = "请输入应用名称", + modifier = Modifier.focusRequester(focusRequester) + ) + } else { + Text( + text = appListNav.label, + ) + } + }, actions = { + if (showSearchBar) { + IconButton(onClick = { + showSearchBar = false + }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null + ) + } + } else { + IconButton(onClick = { + showSearchBar = true + }) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null + ) + } + IconButton(onClick = { + expanded = true + }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Sort, + contentDescription = null + ) + } + Box( + modifier = Modifier + .wrapContentSize(Alignment.TopStart) + ) { + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = !sortByMtime, + onClick = { + vm.sortByMtimeFlow.value = false + } + ) + Text("按名称") + } + }, + onClick = { + vm.sortByMtimeFlow.value = false + }, + ) + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = sortByMtime, + onClick = { vm.sortByMtimeFlow.value = true } + ) + Text("按更新时间") + } + } + }, + onClick = { + vm.sortByMtimeFlow.value = true + }, + ) + HorizontalDivider() + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = showSystemApp, + onCheckedChange = { + vm.showSystemAppFlow.value = + !vm.showSystemAppFlow.value + }) + Text("显示系统应用") + } + }, + onClick = { + vm.showSystemAppFlow.value = !vm.showSystemAppFlow.value + }, + ) + } + } + } + }) + }) { padding -> + LazyColumn( + modifier = Modifier.padding(padding), + state = listState + ) { + items(orderedAppInfos, { it.id }) { appInfo -> + Row( + modifier = Modifier + .height(60.dp) + .clickable { + navController.navigate(AppConfigPageDestination(appInfo.id)) + } + .padding(4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + if (appInfo.icon != null) { + Image( + painter = rememberDrawablePainter(appInfo.icon), + contentDescription = null, + modifier = Modifier + .size(52.dp) + .clip(CircleShape) + ) + } else { + Icon( + imageVector = Icons.Default.Android, + contentDescription = null, + modifier = Modifier + .size(52.dp) + .padding(4.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() + ) + val appGroups = ruleSummary.appIdToAllGroups[appInfo.id] ?: emptyList() + + val appDesc = if (appGroups.isNotEmpty()) { + when (val disabledCount = appGroups.count { g -> !g.second }) { + 0 -> { + "${appGroups.size}组规则" + } + + appGroups.size -> { + "${appGroups.size}组规则/${disabledCount}关闭" + } + + else -> { + "${appGroups.size}组规则/${appGroups.size - disabledCount}启用/${disabledCount}关闭" + } + } + } else { + null + } + + val desc = if (globalDesc != null) { + if (appDesc != null) { + "$globalDesc/$appDesc" + } else { + globalDesc + } + } else { + appDesc ?: "暂无规则" + } + + Text( + text = desc, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..0abb2491b --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/ControlPage.kt @@ -0,0 +1,234 @@ +package li.songe.gkd.ui.home + +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.app.NotificationManagerCompat +import androidx.hilt.navigation.compose.hiltViewModel +import li.songe.gkd.MainActivity +import li.songe.gkd.appScope +import li.songe.gkd.service.GkdAbService +import li.songe.gkd.service.ManageService +import li.songe.gkd.ui.component.AuthCard +import li.songe.gkd.ui.component.TextSwitch +import li.songe.gkd.ui.destinations.ClickLogPageDestination +import li.songe.gkd.util.HOME_PAGE_URL +import li.songe.gkd.util.LocalNavController +import li.songe.gkd.util.checkOrRequestNotifPermission +import li.songe.gkd.util.launchTry +import li.songe.gkd.util.navigate +import li.songe.gkd.util.storeFlow +import li.songe.gkd.util.updateStorage +import li.songe.gkd.util.usePollState + +val controlNav = BottomNavItem(label = "主页", icon = Icons.Outlined.Home) + +@Composable +fun useControlPage(): ScaffoldExt { + val context = LocalContext.current as MainActivity + val navController = LocalNavController.current + val vm = hiltViewModel() + val latestRecordDesc by vm.latestRecordDescFlow.collectAsState() + val subsStatus by vm.subsStatusFlow.collectAsState() + val store by storeFlow.collectAsState() + + val gkdAccessRunning by GkdAbService.isRunning.collectAsState() + val manageRunning by ManageService.isRunning.collectAsState() + val canDrawOverlays by usePollState { Settings.canDrawOverlays(context) } + val canNotif by usePollState { + NotificationManagerCompat.from(context).areNotificationsEnabled() + } + + return ScaffoldExt(navItem = controlNav, topBar = { + TopAppBar(title = { + Text( + text = controlNav.label, + ) + }) + }, content = { padding -> + Column( + modifier = Modifier + .padding(padding) + .verticalScroll( + state = rememberScrollState() + ) + ) { + if (!gkdAccessRunning) { + AuthCard( + title = "无障碍权限", + desc = "用于获取屏幕信息,点击屏幕上的控件", + onAuthClick = { + appScope.launchTry { + val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + // android.content.ActivityNotFoundException + context.startActivity(intent) + } + }) + } else { + TextSwitch( + name = "服务开启", + desc = "保持服务开启,根据订阅规则匹配屏幕目标节点", + checked = store.enableService, + onCheckedChange = { + updateStorage( + storeFlow, store.copy( + enableService = it + ) + ) + }) + } + HorizontalDivider() + + if (!canNotif) { + AuthCard(title = "通知权限", + desc = "用于显示各类服务状态数据及前后台提示", + onAuthClick = { + checkOrRequestNotifPermission(context) + }) + HorizontalDivider() + } + + if (!canDrawOverlays) { + AuthCard( + title = "悬浮窗权限", + desc = "用于后台提示,显示保存快照按钮等功能", + onAuthClick = { + appScope.launchTry { + val intent = Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + ) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(intent) + } + }) + HorizontalDivider() + } + + TextSwitch( + name = "常驻通知", + desc = "在通知栏显示服务运行状态及统计数据", + checked = manageRunning && store.enableStatusService, + onCheckedChange = { + if (it) { + if (!checkOrRequestNotifPermission(context)) { + return@TextSwitch + } + updateStorage( + storeFlow, store.copy( + enableStatusService = true + ) + ) + ManageService.start(context) + } else { + updateStorage( + storeFlow, store.copy( + enableStatusService = false + ) + ) + ManageService.stop(context) + } + }) + HorizontalDivider() + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { + appScope.launchTry { + context.startActivity( + Intent( + Intent.ACTION_VIEW, Uri.parse(HOME_PAGE_URL) + ) + ) + } + } + .padding(10.dp, 5.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "使用说明", fontSize = 18.sp + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = HOME_PAGE_URL, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.primary, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Outlined.OpenInNew, + contentDescription = null + ) + } + HorizontalDivider() + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { + navController.navigate(ClickLogPageDestination) + } + .padding(10.dp, 5.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "点击记录", fontSize = 18.sp + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "如误触可在此快速定位关闭规则", fontSize = 14.sp + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null + ) + } + HorizontalDivider() + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp, 5.dp) + ) { + Text(text = subsStatus, fontSize = 18.sp) + if (latestRecordDesc != null) { + Text( + text = "最近点击: $latestRecordDesc", fontSize = 14.sp + ) + } + } + + } + }) +} diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/HomePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/HomePage.kt new file mode 100644 index 000000000..5cdc5a188 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/HomePage.kt @@ -0,0 +1,66 @@ +package li.songe.gkd.ui.home + +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.hilt.navigation.compose.hiltViewModel +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootNavGraph +import li.songe.gkd.util.ProfileTransitions + +data class BottomNavItem( + val label: String, + val icon: ImageVector, +) + +@RootNavGraph(start = true) +@Destination(style = ProfileTransitions::class) +@Composable +fun HomePage() { + val vm = hiltViewModel() + val tab by vm.tabFlow.collectAsState() + + val appListPage = useAppListPage() + val subsPage = useSubsManagePage() + val controlPage = useControlPage() + val settingsPage = useSettingsPage() + + val pages = arrayOf(appListPage, subsPage, controlPage, settingsPage) + + val currentPage = pages.find { p -> p.navItem === tab } + ?: controlPage + + Scaffold( + modifier = currentPage.modifier, + topBar = currentPage.topBar, + bottomBar = { + NavigationBar { + pages.forEach { page -> + NavigationBarItem( + selected = tab == page.navItem, + modifier = Modifier, + onClick = { + vm.tabFlow.value = page.navItem + }, + icon = { + Icon( + imageVector = page.navItem.icon, + contentDescription = page.navItem.label + ) + }, + label = { + Text(text = page.navItem.label) + }) + } + } + }, + content = currentPage.content + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/HomeVm.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/HomeVm.kt new file mode 100644 index 000000000..6884e951b --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/HomeVm.kt @@ -0,0 +1,309 @@ +package li.songe.gkd.ui.home + +import android.webkit.URLUtil +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.blankj.utilcode.util.LogUtils +import dagger.hilt.android.lifecycle.HiltViewModel +import io.ktor.client.call.body +import io.ktor.client.plugins.onUpload +import io.ktor.client.request.forms.formData +import io.ktor.client.request.forms.submitFormWithBinaryData +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import li.songe.gkd.appScope +import li.songe.gkd.data.GithubPoliciesAsset +import li.songe.gkd.data.RawSubscription +import li.songe.gkd.data.RpcError +import li.songe.gkd.data.SubsItem +import li.songe.gkd.data.SubsVersion +import li.songe.gkd.db.DbSet +import li.songe.gkd.util.FILE_UPLOAD_URL +import li.songe.gkd.util.LoadStatus +import li.songe.gkd.util.appInfoCacheFlow +import li.songe.gkd.util.authActionFlow +import li.songe.gkd.util.checkUpdate +import li.songe.gkd.util.clickCountFlow +import li.songe.gkd.util.client +import li.songe.gkd.util.initFolder +import li.songe.gkd.util.launchTry +import li.songe.gkd.util.logZipDir +import li.songe.gkd.util.newVersionApkDir +import li.songe.gkd.util.orderedAppInfosFlow +import li.songe.gkd.util.ruleSummaryFlow +import li.songe.gkd.util.snapshotZipDir +import li.songe.gkd.util.storeFlow +import li.songe.gkd.util.subsIdToRawFlow +import li.songe.gkd.util.subsItemsFlow +import li.songe.gkd.util.toast +import li.songe.gkd.util.updateSubscription +import java.io.File +import javax.inject.Inject + +@HiltViewModel +class HomeVm @Inject constructor() : ViewModel() { + val tabFlow = MutableStateFlow(controlNav) + + init { + appScope.launchTry(Dispatchers.IO) { + val localSubsItem = SubsItem( + id = -2, order = -2, mtime = System.currentTimeMillis() + ) + if (!DbSet.subsItemDao.query().first().any { s -> s.id == localSubsItem.id }) { + DbSet.subsItemDao.insert(localSubsItem) + } + } + + viewModelScope.launchTry(Dispatchers.IO) { + // 每次进入删除缓存 + listOf(snapshotZipDir, newVersionApkDir, logZipDir).forEach { dir -> + if (dir.isDirectory && dir.exists()) { + dir.listFiles()?.forEach { file -> + if (file.isFile) { + file.delete() + } + } + } + } + } + + viewModelScope.launchTry(Dispatchers.IO) { + // 在某些机型由于未知原因创建失败 + // 在此保证每次重新打开APP都能重新检测创建 + initFolder() + } + + if (storeFlow.value.autoCheckAppUpdate) { + appScope.launch { + try { + checkUpdate() + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + + val uploadStatusFlow = MutableStateFlow?>(null) + var uploadJob: Job? = null + + fun uploadZip(zipFile: File) { + uploadJob = viewModelScope.launchTry(Dispatchers.IO) { + uploadStatusFlow.value = LoadStatus.Loading() + try { + val response = + client.submitFormWithBinaryData(url = FILE_UPLOAD_URL, formData = formData { + append("\"file\"", zipFile.readBytes(), Headers.build { + append(HttpHeaders.ContentType, "application/x-zip-compressed") + append(HttpHeaders.ContentDisposition, "filename=\"file.zip\"") + }) + }) { + onUpload { bytesSentTotal, contentLength -> + if (uploadStatusFlow.value is LoadStatus.Loading) { + uploadStatusFlow.value = + LoadStatus.Loading(bytesSentTotal / contentLength.toFloat()) + } + } + } + if (response.headers["X_RPC_OK"] == "true") { + val policiesAsset = response.body() + uploadStatusFlow.value = LoadStatus.Success(policiesAsset) + } else if (response.headers["X_RPC_OK"] == "false") { + uploadStatusFlow.value = LoadStatus.Failure(response.body()) + } else { + uploadStatusFlow.value = LoadStatus.Failure(Exception(response.bodyAsText())) + } + } catch (e: Exception) { + uploadStatusFlow.value = LoadStatus.Failure(e) + } + } + } + + override fun onCleared() { + super.onCleared() + authActionFlow.value = null + } + + private val latestRecordFlow = + DbSet.clickLogDao.queryLatest().stateIn(viewModelScope, SharingStarted.Eagerly, null) + val latestRecordDescFlow = combine( + latestRecordFlow, subsIdToRawFlow, appInfoCacheFlow + ) { latestRecord, subsIdToRaw, appInfoCache -> + if (latestRecord == null) return@combine null + val groupName = + subsIdToRaw[latestRecord.subsId]?.apps?.find { a -> a.id == latestRecord.appId }?.groups?.find { g -> g.key == latestRecord.groupKey }?.name + val appName = appInfoCache[latestRecord.appId]?.name + val appShowName = appName ?: latestRecord.appId ?: "" + if (groupName != null) { + if (groupName.contains(appShowName)) { + groupName + } else { + "$appShowName-$groupName" + } + } else { + appShowName + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + val subsStatusFlow = combine(ruleSummaryFlow, clickCountFlow) { allRules, clickCount -> + allRules.numText + if (clickCount > 0) { + "/${clickCount}点击" + } else { + "" + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, "") + + fun addSubsFromUrl(url: String) = viewModelScope.launchTry(Dispatchers.IO) { + if (refreshingFlow.value) return@launchTry + if (!URLUtil.isNetworkUrl(url)) { + toast("非法链接") + return@launchTry + } + val subItems = subsItemsFlow.value + if (subItems.any { it.updateUrl == url }) { + toast("订阅链接已存在") + return@launchTry + } + refreshingFlow.value = true + try { + val text = try { + client.get(url).bodyAsText() + } catch (e: Exception) { + e.printStackTrace() + LogUtils.d(e) + toast("下载订阅文件失败") + return@launchTry + } + val newSubsRaw = try { + RawSubscription.parse(text) + } catch (e: Exception) { + e.printStackTrace() + LogUtils.d(e) + toast("解析订阅文件失败") + return@launchTry + } + if (subItems.any { it.id == newSubsRaw.id }) { + toast("订阅已存在") + return@launchTry + } + if (newSubsRaw.id < 0) { + toast("订阅id不可为${newSubsRaw.id}\n负数id为内部使用") + return@launchTry + } + val newItem = SubsItem( + id = newSubsRaw.id, + updateUrl = newSubsRaw.updateUrl ?: url, + order = if (subItems.isEmpty()) 1 else (subItems.maxBy { it.order }.order + 1) + ) + updateSubscription(newSubsRaw) + DbSet.subsItemDao.insert(newItem) + toast("成功添加订阅") + } finally { + refreshingFlow.value = false + } + + } + + val refreshingFlow = MutableStateFlow(false) + fun refreshSubs() = viewModelScope.launch(Dispatchers.IO) { + if (refreshingFlow.value) return@launch + refreshingFlow.value = true + var errorNum = 0 + val oldSubItems = subsItemsFlow.value + val newSubsItems = oldSubItems.mapNotNull { oldItem -> + if (oldItem.updateUrl == null) return@mapNotNull null + val oldSubsRaw = subsIdToRawFlow.value[oldItem.id] + try { + if (oldSubsRaw?.checkUpdateUrl != null) { + try { + val subsVersion = + client.get(oldSubsRaw.checkUpdateUrl).body() + LogUtils.d("快速检测更新成功", subsVersion) + if (subsVersion.id != oldSubsRaw.id) { + toast("${oldItem.id}:checkUpdateUrl获取id不一致") + return@mapNotNull null + } + if (subsVersion.version <= oldSubsRaw.version) { + return@mapNotNull null + } + } catch (e: Exception) { + LogUtils.d("快速检测更新失败", oldItem, e) + } + } + val newSubsRaw = RawSubscription.parse( + client.get(oldSubsRaw?.updateUrl ?: oldItem.updateUrl).bodyAsText() + ) + if (newSubsRaw.id != oldItem.id) { + toast("${oldItem.id}:updateUrl获取id不一致") + return@mapNotNull null + } + if (oldSubsRaw != null && newSubsRaw.version <= oldSubsRaw.version) { + return@mapNotNull null + } + val newItem = oldItem.copy( + mtime = System.currentTimeMillis(), + ) + updateSubscription(newSubsRaw) + newItem + } catch (e: Exception) { + e.printStackTrace() + errorNum++ + null + } + } + if (newSubsItems.isEmpty()) { + if (errorNum == oldSubItems.size) { + toast("更新失败") + } else { + toast("暂无更新") + } + } else { + DbSet.subsItemDao.update(*newSubsItems.toTypedArray()) + toast("更新 ${newSubsItems.size} 条订阅") + } + delay(500) + refreshingFlow.value = false + } + + val sortByMtimeFlow = MutableStateFlow(false) + val showSystemAppFlow = MutableStateFlow(false) + val searchStrFlow = MutableStateFlow("") + private val debounceSearchStrFlow = searchStrFlow.debounce(200) + .stateIn(viewModelScope, SharingStarted.Eagerly, searchStrFlow.value) + val appInfosFlow = orderedAppInfosFlow.combine(showSystemAppFlow) { appInfos, showSystemApp -> + if (showSystemApp) { + appInfos + } else { + appInfos.filter { a -> !a.isSystem } + } + }.combine(sortByMtimeFlow) { appInfos, sortByMtime -> + if (sortByMtime) { + appInfos.sortedBy { a -> -a.mtime } + } else { + appInfos + } + }.combine(debounceSearchStrFlow) { appInfos, debounceSearchStr -> + if (debounceSearchStr.isBlank()) { + appInfos + } else { + (appInfos.filter { a -> a.name.contains(debounceSearchStr) } + appInfos.filter { a -> + a.id.contains( + debounceSearchStr + ) + }).distinct() + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/li/songe/gkd/ui/home/ScaffoldExt.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/ScaffoldExt.kt new file mode 100644 index 000000000..6c64197e7 --- /dev/null +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/ScaffoldExt.kt @@ -0,0 +1,22 @@ +package li.songe.gkd.ui.home + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +data class ScaffoldExt( + val navItem: BottomNavItem, + val modifier: Modifier = Modifier, + val topBar: @Composable () -> Unit = { + TopAppBar(title = { + Text( + text = navItem.label, + ) + }) + }, + val floatingActionButton: @Composable () -> Unit = {}, + val content: @Composable (PaddingValues) -> Unit +) + diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SettingsPage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt similarity index 69% rename from app/src/main/kotlin/li/songe/gkd/ui/SettingsPage.kt rename to app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt index 93d8e2b20..42f7ae0ef 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SettingsPage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SettingsPage.kt @@ -1,4 +1,4 @@ -package li.songe.gkd.ui +package li.songe.gkd.ui.home import android.content.Intent import android.provider.Settings @@ -15,7 +15,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider @@ -67,16 +67,15 @@ import li.songe.gkd.util.updateStorage import java.io.File val settingsNav = BottomNavItem( - label = "设置", icon = Icons.Default.Settings + label = "设置", icon = Icons.Outlined.Settings ) @Composable -fun SettingsPage() { - Icons.Default.Settings +fun useSettingsPage(): ScaffoldExt { val context = LocalContext.current as MainActivity val navController = LocalNavController.current val store by storeFlow.collectAsState() - val vm = hiltViewModel() + val vm = hiltViewModel() val uploadStatus by vm.uploadStatusFlow.collectAsState() var showSubsIntervalDlg by remember { @@ -96,171 +95,6 @@ fun SettingsPage() { val checkUpdating by checkUpdatingFlow.collectAsState() - Column( - modifier = Modifier.verticalScroll( - state = rememberScrollState() - ) - ) { - TextSwitch(name = "后台隐藏", - desc = "在[最近任务]界面中隐藏本应用", - checked = store.excludeFromRecents, - onCheckedChange = { - updateStorage( - storeFlow, store.copy( - excludeFromRecents = it - ) - ) - }) - HorizontalDivider() - - TextSwitch(name = "前台悬浮窗", - desc = "添加透明悬浮窗,关闭可能导致不点击/点击缓慢", - checked = store.enableAbFloatWindow, - onCheckedChange = { - updateStorage( - storeFlow, store.copy( - enableAbFloatWindow = it - ) - ) - }) - HorizontalDivider() - - TextSwitch(name = "点击提示", - desc = "触发点击时提示:[${store.clickToast}]", - checked = store.toastWhenClick, - modifier = Modifier.clickable { - showToastInputDlg = true - }, - onCheckedChange = { - if (it && !Settings.canDrawOverlays(context)) { - authActionFlow.value = canDrawOverlaysAuthAction - return@TextSwitch - } - updateStorage( - storeFlow, store.copy( - toastWhenClick = it - ) - ) - }) - HorizontalDivider() - - Row(modifier = Modifier - .clickable { - showSubsIntervalDlg = true - } - .padding(10.dp, 15.dp), verticalAlignment = Alignment.CenterVertically) { - Text( - modifier = Modifier.weight(1f), text = "自动更新订阅", fontSize = 18.sp - ) - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = updateTimeRadioOptions.find { it.second == store.updateSubsInterval }?.first - ?: store.updateSubsInterval.toString(), fontSize = 14.sp - ) - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = "more" - ) - } - } - HorizontalDivider() - - TextSwitch(name = "自动更新应用", - desc = "打开应用时自动检测是否存在新版本", - checked = store.autoCheckAppUpdate, - onCheckedChange = { - updateStorage( - storeFlow, store.copy( - autoCheckAppUpdate = it - ) - ) - }) - HorizontalDivider() - - SettingItem(title = if (checkUpdating) "检查更新ing" else "检查更新", onClick = { - appScope.launchTry { - if (checkUpdatingFlow.value) return@launchTry - val newVersion = checkUpdate() - if (newVersion == null) { - toast("暂无更新") - } - } - }) - HorizontalDivider() - - Row(modifier = Modifier - .clickable { - showEnableDarkThemeDlg = true - } - .padding(10.dp, 15.dp), verticalAlignment = Alignment.CenterVertically) { - Text( - modifier = Modifier.weight(1f), text = "深色模式", fontSize = 18.sp - ) - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = darkThemeRadioOptions.find { it.second == store.enableDarkTheme }?.first - ?: store.enableDarkTheme.toString(), fontSize = 14.sp - ) - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = "more" - ) - } - } - HorizontalDivider() - - TextSwitch(name = "保存日志", - desc = "保存最近7天的日志,大概占用您5M的空间", - checked = store.log2FileSwitch, - onCheckedChange = { - updateStorage( - storeFlow, store.copy( - log2FileSwitch = it - ) - ) - if (!it) { - appScope.launchTry(Dispatchers.IO) { - val logFiles = LogUtils.getLogFiles() - if (logFiles.isNotEmpty()) { - logFiles.forEach { f -> - f.delete() - } - toast("已删除全部日志") - } - } - } - }) - HorizontalDivider() - - SettingItem(title = "分享日志", onClick = { - vm.viewModelScope.launchTry(Dispatchers.IO) { - val logFiles = LogUtils.getLogFiles() - if (logFiles.isNotEmpty()) { - showShareLogDlg = true - } else { - toast("暂无日志") - } - } - }) - HorizontalDivider() - - SettingItem(title = "高级模式", onClick = { - navController.navigate(DebugPageDestination) - }) - HorizontalDivider() - - SettingItem(title = "关于", onClick = { - navController.navigate(AboutPageDestination) - }) - - Spacer(modifier = Modifier.height(40.dp)) - } - - if (showSubsIntervalDlg) { Dialog(onDismissRequest = { showSubsIntervalDlg = false }) { Card( @@ -502,6 +336,174 @@ fun SettingsPage() { else -> {} } + + return ScaffoldExt(navItem = settingsNav) { padding -> + Column( + modifier = Modifier + .padding(padding) + .verticalScroll( + state = rememberScrollState() + ) + ) { + TextSwitch(name = "后台隐藏", + desc = "在[最近任务]界面中隐藏本应用", + checked = store.excludeFromRecents, + onCheckedChange = { + updateStorage( + storeFlow, store.copy( + excludeFromRecents = it + ) + ) + }) + HorizontalDivider() + + TextSwitch(name = "前台悬浮窗", + desc = "添加透明悬浮窗,关闭可能导致不点击/点击缓慢", + checked = store.enableAbFloatWindow, + onCheckedChange = { + updateStorage( + storeFlow, store.copy( + enableAbFloatWindow = it + ) + ) + }) + HorizontalDivider() + + TextSwitch(name = "点击提示", + desc = "触发点击时提示:[${store.clickToast}]", + checked = store.toastWhenClick, + modifier = Modifier.clickable { + showToastInputDlg = true + }, + onCheckedChange = { + if (it && !Settings.canDrawOverlays(context)) { + authActionFlow.value = canDrawOverlaysAuthAction + return@TextSwitch + } + updateStorage( + storeFlow, store.copy( + toastWhenClick = it + ) + ) + }) + HorizontalDivider() + + Row(modifier = Modifier + .clickable { + showSubsIntervalDlg = true + } + .padding(10.dp, 15.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + modifier = Modifier.weight(1f), text = "自动更新订阅", fontSize = 18.sp + ) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = updateTimeRadioOptions.find { it.second == store.updateSubsInterval }?.first + ?: store.updateSubsInterval.toString(), fontSize = 14.sp + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = "more" + ) + } + } + HorizontalDivider() + + TextSwitch(name = "自动更新应用", + desc = "打开应用时自动检测是否存在新版本", + checked = store.autoCheckAppUpdate, + onCheckedChange = { + updateStorage( + storeFlow, store.copy( + autoCheckAppUpdate = it + ) + ) + }) + HorizontalDivider() + + SettingItem(title = if (checkUpdating) "检查更新ing" else "检查更新", onClick = { + appScope.launchTry { + if (checkUpdatingFlow.value) return@launchTry + val newVersion = checkUpdate() + if (newVersion == null) { + toast("暂无更新") + } + } + }) + HorizontalDivider() + + Row(modifier = Modifier + .clickable { + showEnableDarkThemeDlg = true + } + .padding(10.dp, 15.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + modifier = Modifier.weight(1f), text = "深色模式", fontSize = 18.sp + ) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = darkThemeRadioOptions.find { it.second == store.enableDarkTheme }?.first + ?: store.enableDarkTheme.toString(), fontSize = 14.sp + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = "more" + ) + } + } + HorizontalDivider() + + TextSwitch(name = "保存日志", + desc = "保存最近7天的日志,大概占用您5M的空间", + checked = store.log2FileSwitch, + onCheckedChange = { + updateStorage( + storeFlow, store.copy( + log2FileSwitch = it + ) + ) + if (!it) { + appScope.launchTry(Dispatchers.IO) { + val logFiles = LogUtils.getLogFiles() + if (logFiles.isNotEmpty()) { + logFiles.forEach { f -> + f.delete() + } + toast("已删除全部日志") + } + } + } + }) + HorizontalDivider() + + SettingItem(title = "分享日志", onClick = { + vm.viewModelScope.launchTry(Dispatchers.IO) { + val logFiles = LogUtils.getLogFiles() + if (logFiles.isNotEmpty()) { + showShareLogDlg = true + } else { + toast("暂无日志") + } + } + }) + HorizontalDivider() + + SettingItem(title = "高级模式", onClick = { + navController.navigate(DebugPageDestination) + }) + HorizontalDivider() + + SettingItem(title = "关于", onClick = { + navController.navigate(AboutPageDestination) + }) + + Spacer(modifier = Modifier.height(40.dp)) + } + } } private val updateTimeRadioOptions = listOf( diff --git a/app/src/main/kotlin/li/songe/gkd/ui/SubsManagePage.kt b/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt similarity index 98% rename from app/src/main/kotlin/li/songe/gkd/ui/SubsManagePage.kt rename to app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt index 76bc78aab..f77c8e16c 100644 --- a/app/src/main/kotlin/li/songe/gkd/ui/SubsManagePage.kt +++ b/app/src/main/kotlin/li/songe/gkd/ui/home/SubsManagePage.kt @@ -1,4 +1,4 @@ -package li.songe.gkd.ui +package li.songe.gkd.ui.home import android.annotation.SuppressLint import android.content.Intent @@ -27,7 +27,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.pullrefresh.PullRefreshIndicator @@ -81,12 +80,12 @@ val subsNav = BottomNavItem( @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @Composable -fun SubsManagePage() { +fun useSubsManagePage(): ScaffoldExt { val context = LocalContext.current val scope = rememberCoroutineScope() val navController = LocalNavController.current - val vm = hiltViewModel() + val vm = hiltViewModel() val subItems by subsItemsFlow.collectAsState() val subsIdToRaw by subsIdToRawFlow.collectAsState() @@ -127,72 +126,6 @@ fun SubsManagePage() { } }) - Scaffold( - floatingActionButton = { - FloatingActionButton(onClick = { - if (!vm.refreshingFlow.value) { - showAddLinkDialog = true - } else { - toast("正在刷新订阅,请稍后添加") - } - }) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = "info", - ) - } - }, - ) { _ -> - Box( - modifier = Modifier - .fillMaxSize() - .pullRefresh(pullRefreshState, subItems.isNotEmpty()) - ) { - LazyColumn( - state = state.listState, - modifier = Modifier - .reorderable(state) - .detectReorderAfterLongPress(state) - .fillMaxHeight(), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - itemsIndexed(orderSubItems.value, { _, subItem -> subItem.id }) { index, subItem -> - ReorderableItem(state, key = subItem.id) { isDragging -> - val elevation = animateDpAsState( - if (isDragging) 16.dp else 0.dp, label = "" - ) - Card( - modifier = Modifier - .shadow(elevation.value) - .animateItemPlacement() - .padding(vertical = 3.dp, horizontal = 8.dp) - .clickable { - menuSubItem = subItem - }, - shape = RoundedCornerShape(8.dp), - ) { - SubsItemCard( - subsItem = subItem, - rawSubscription = subsIdToRaw[subItem.id], - index = index + 1, - onCheckedChange = { checked -> - vm.viewModelScope.launch { - DbSet.subsItemDao.update(subItem.copy(enable = checked)) - } - }, - ) - } - } - } - } - PullRefreshIndicator( - refreshing = refreshing, - state = pullRefreshState, - modifier = Modifier.align(Alignment.TopCenter), - ) - } - } - menuSubItem?.let { menuSubItemVal -> Dialog(onDismissRequest = { menuSubItem = null }) { Card( @@ -281,7 +214,6 @@ fun SubsManagePage() { } } - deleteSubItem?.let { deleteSubItemVal -> AlertDialog(onDismissRequest = { deleteSubItem = null }, title = { Text(text = "是否删除 ${subsIdToRaw[deleteSubItemVal.id]?.name}?") }, @@ -342,4 +274,72 @@ fun SubsManagePage() { } }) } + + return ScaffoldExt( + navItem = subsNav, + floatingActionButton = { + FloatingActionButton(onClick = { + if (!vm.refreshingFlow.value) { + showAddLinkDialog = true + } else { + toast("正在刷新订阅,请稍后添加") + } + }) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = "info", + ) + } + }, + ) { padding -> + Box( + modifier = Modifier + .padding(padding) + .fillMaxSize() + .pullRefresh(pullRefreshState, subItems.isNotEmpty()) + ) { + LazyColumn( + state = state.listState, + modifier = Modifier + .reorderable(state) + .detectReorderAfterLongPress(state) + .fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + itemsIndexed(orderSubItems.value, { _, subItem -> subItem.id }) { index, subItem -> + ReorderableItem(state, key = subItem.id) { isDragging -> + val elevation = animateDpAsState( + if (isDragging) 16.dp else 0.dp, label = "" + ) + Card( + modifier = Modifier + .shadow(elevation.value) + .animateItemPlacement() + .padding(vertical = 3.dp, horizontal = 8.dp) + .clickable { + menuSubItem = subItem + }, + shape = RoundedCornerShape(8.dp), + ) { + SubsItemCard( + subsItem = subItem, + rawSubscription = subsIdToRaw[subItem.id], + index = index + 1, + onCheckedChange = { checked -> + vm.viewModelScope.launch { + DbSet.subsItemDao.update(subItem.copy(enable = checked)) + } + }, + ) + } + } + } + } + PullRefreshIndicator( + refreshing = refreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + ) + } + } } \ No newline at end of file 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 707c3d4b2..cbae91643 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/AppInfoState.kt @@ -4,8 +4,8 @@ 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 import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.sync.Mutex @@ -13,6 +13,7 @@ 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.data.toAppInfo val appInfoCacheFlow = MutableStateFlow(mapOf()) @@ -78,19 +79,17 @@ private val updateAppMutex by lazy { Mutex() } fun updateAppInfo(appIds: List) { if (appIds.isEmpty()) return appScope.launchTry(Dispatchers.IO) { + val packageManager = app.packageManager updateAppMutex.withLock { val newMap = appInfoCacheFlow.value.toMutableMap() appIds.forEach { appId -> - 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, - ) + val info = try { + packageManager.getPackageInfo(appId, 0) + } catch (e: PackageManager.NameNotFoundException) { + null + } + val newAppInfo = info?.toAppInfo() + if (newAppInfo != null) { newMap[appId] = newAppInfo } else { newMap.remove(appId) @@ -101,22 +100,18 @@ 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, - ) - } + app.packageManager.getInstalledPackages(0) + .forEach { packageInfo -> + val info = packageInfo.toAppInfo() + if (info != null) { + appMap[packageInfo.packageName] = info + } + } appInfoCacheFlow.value = appMap } } diff --git a/app/src/main/kotlin/li/songe/gkd/util/Constants.kt b/app/src/main/kotlin/li/songe/gkd/util/Constants.kt index 31da26d64..aff54dddf 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Constants.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Constants.kt @@ -16,7 +16,7 @@ const val SERVER_SCRIPT_URL = const val REPOSITORY_URL = "https://github.com/gkd-kit/gkd" -const val HOME_PAGE_URL = "https://gkd.li/" +const val HOME_PAGE_URL = "https://gkd.li" @Suppress("SENSELESS_COMPARISON") val GIT_COMMIT_URL = if (BuildConfig.GIT_COMMIT_ID != null) { 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 df74cd841..6f0440e45 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/Store.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/Store.kt @@ -53,7 +53,7 @@ data class Store( val captureVolumeChange: Boolean = false, val autoCheckAppUpdate: Boolean = true, val toastWhenClick: Boolean = true, - val clickToast: String = "跳过", + val clickToast: String = "GKD", val autoClearMemorySubs: Boolean = true, val hideSnapshotStatusBar: Boolean = false, val enableShizuku: Boolean = false, 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 48ed76c5f..6a90db1c4 100644 --- a/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt +++ b/app/src/main/kotlin/li/songe/gkd/util/SubsState.kt @@ -75,11 +75,12 @@ fun getGroupRawEnable( } ?: rawGroup.enable ?: true } -data class AllRules( +data class RuleSummary( val globalRules: List = emptyList(), val globalGroups: List = emptyList(), val appIdToRules: Map> = emptyMap(), val appIdToGroups: Map> = emptyMap(), + val appIdToAllGroups: Map>> = emptyMap(), ) { private val appSize = appIdToRules.keys.size private val appGroupSize = appIdToGroups.values.sumOf { s -> s.size } @@ -103,7 +104,7 @@ data class AllRules( } } -val allRulesFlow by lazy { +val ruleSummaryFlow by lazy { combine( subsItemsFlow, subsIdToRawFlow, @@ -115,9 +116,10 @@ val allRulesFlow by lazy { val appSubsConfigs = subsConfigs.filter { c -> c.type == SubsConfig.AppType } val groupSubsConfigs = subsConfigs.filter { c -> c.type == SubsConfig.AppGroupType } val appRules = mutableMapOf>() + val appGroups = mutableMapOf>() + val appAllGroups = mutableMapOf>>() val globalRules = mutableListOf() val globalGroups = mutableListOf() - val appGroups = mutableMapOf>() subsItems.filter { it.enable }.forEach { subsItem -> val rawSubs = subsIdToRaw[subsItem.id] ?: return@forEach @@ -137,7 +139,6 @@ val allRulesFlow by lazy { rawSubs = rawSubs, subsItem = subsItem, exclude = subGlobalSubsConfigs.find { c -> c.groupKey == groupRaw.key }?.exclude - ?: "" ) } subGlobalGroupToRules[groupRaw] = subRules @@ -156,37 +157,41 @@ val allRulesFlow by lazy { val subCategoryConfigs = categoryConfigs.filter { c -> c.subsItemId == subsItem.id } rawSubs.apps.filter { appRaw -> // 筛选 当前启用的 app 订阅规则 - (subAppSubsConfigs.find { c -> c.appId == appRaw.id }?.enable + appRaw.groups.isNotEmpty() && (subAppSubsConfigs.find { c -> c.appId == appRaw.id }?.enable ?: (appInfoCache[appRaw.id] != null)) }.forEach { appRaw -> val subAppGroups = mutableListOf() val appGroupConfigs = subGroupSubsConfigs.filter { c -> c.appId == appRaw.id } val subAppGroupToRules = mutableMapOf>() - appRaw.groups.filter { groupRaw -> - groupRaw.valid && getGroupRawEnable( + val groupAndEnables = appRaw.groups.map { groupRaw -> + val enable = groupRaw.valid && getGroupRawEnable( groupRaw, appGroupConfigs, rawSubs.groupToCategoryMap[groupRaw], subCategoryConfigs ) - }.forEach { groupRaw -> - subAppGroups.add(groupRaw) - val subRules = groupRaw.rules.map { ruleRaw -> - AppRule( - rule = ruleRaw, - group = groupRaw, - app = appRaw, - rawSubs = rawSubs, - subsItem = subsItem, - exclude = appGroupConfigs.find { c -> c.groupKey == groupRaw.key }?.exclude - ?: "" - ) - } - subAppGroupToRules[groupRaw] = subRules - if (subRules.isNotEmpty()) { - val rules = appRules[appRaw.id] ?: mutableListOf() - appRules[appRaw.id] = rules - rules.addAll(subRules) + groupRaw to enable + } + appAllGroups[appRaw.id] = (appAllGroups[appRaw.id] ?: emptyList()) + groupAndEnables + groupAndEnables.forEach { (groupRaw, enable) -> + if (enable) { + subAppGroups.add(groupRaw) + val subRules = groupRaw.rules.map { ruleRaw -> + AppRule( + rule = ruleRaw, + group = groupRaw, + app = appRaw, + rawSubs = rawSubs, + subsItem = subsItem, + exclude = appGroupConfigs.find { c -> c.groupKey == groupRaw.key }?.exclude + ) + } + subAppGroupToRules[groupRaw] = subRules + if (subRules.isNotEmpty()) { + val rules = appRules[appRaw.id] ?: mutableListOf() + appRules[appRaw.id] = rules + rules.addAll(subRules) + } } } if (subAppGroups.isNotEmpty()) { @@ -199,13 +204,14 @@ val allRulesFlow by lazy { } } } - AllRules( - appIdToRules = appRules, + RuleSummary( globalRules = globalRules, globalGroups = globalGroups, + appIdToRules = appRules, appIdToGroups = appGroups, + appIdToAllGroups = appAllGroups ) - }.stateIn(appScope, SharingStarted.Eagerly, AllRules()) + }.stateIn(appScope, SharingStarted.Eagerly, RuleSummary()) } fun initSubsState() {