feat: 新功能/优化 (#47, #48, #317, #376)

0.支持控制常驻通知栏
1.支持全局规则
2.优化规则触发逻辑
3.优化界面布局
4.优化申请权限流程
5.优化复制规则组为json5
6.快照新增editable字段
7.允许开启匹配未安装应用
This commit is contained in:
lisonge 2023-12-22 17:09:00 +08:00
parent 5c430e0dbe
commit 8c99ba7f4c
56 changed files with 2710 additions and 1518 deletions

View File

@ -0,0 +1,302 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "d1a618bf8475b588793fb1d201815a08",
"entities": [
{
"tableName": "subs_item",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `ctime` INTEGER NOT NULL, `mtime` INTEGER NOT NULL, `enable` INTEGER NOT NULL, `enable_update` INTEGER NOT NULL, `order` INTEGER NOT NULL, `update_url` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ctime",
"columnName": "ctime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mtime",
"columnName": "mtime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "enable",
"columnName": "enable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "enableUpdate",
"columnName": "enable_update",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updateUrl",
"columnName": "update_url",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "snapshot",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT, `activity_id` TEXT, `app_name` TEXT, `app_version_code` INTEGER, `app_version_name` TEXT, `screen_height` INTEGER NOT NULL, `screen_width` INTEGER NOT NULL, `is_landscape` INTEGER NOT NULL, `github_asset_id` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "app_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "activityId",
"columnName": "activity_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "appName",
"columnName": "app_name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "appVersionCode",
"columnName": "app_version_code",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "appVersionName",
"columnName": "app_version_name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "screenHeight",
"columnName": "screen_height",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "screenWidth",
"columnName": "screen_width",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isLandscape",
"columnName": "is_landscape",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "githubAssetId",
"columnName": "github_asset_id",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "subs_config",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `type` INTEGER NOT NULL, `enable` INTEGER NOT NULL, `subs_item_id` INTEGER NOT NULL, `app_id` TEXT NOT NULL, `group_key` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "enable",
"columnName": "enable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "subsItemId",
"columnName": "subs_item_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "app_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "groupKey",
"columnName": "group_key",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "click_log",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT, `activity_id` TEXT, `subs_id` INTEGER NOT NULL, `subs_version` INTEGER NOT NULL DEFAULT 0, `group_key` INTEGER NOT NULL, `group_type` INTEGER NOT NULL DEFAULT 2, `rule_index` INTEGER NOT NULL, `rule_key` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "app_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "activityId",
"columnName": "activity_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "subsId",
"columnName": "subs_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "subsVersion",
"columnName": "subs_version",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "groupKey",
"columnName": "group_key",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "groupType",
"columnName": "group_type",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "2"
},
{
"fieldPath": "ruleIndex",
"columnName": "rule_index",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ruleKey",
"columnName": "rule_key",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "category_config",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `enable` INTEGER, `subs_item_id` INTEGER NOT NULL, `category_key` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "enable",
"columnName": "enable",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "subsItemId",
"columnName": "subs_item_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "categoryKey",
"columnName": "category_key",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd1a618bf8475b588793fb1d201815a08')"
]
}
}

View File

@ -39,7 +39,6 @@
<activity
android:name=".MainActivity"
android:configChanges="uiMode|screenSize|orientation|keyboardHidden|touchscreen|smallestScreenSize|screenLayout|navigation|mnc|mcc|locale|layoutDirection|keyboard|fontWeightAdjustment|fontScale|density|colorMode"
android:exported="true"
android:theme="@style/SplashScreenTheme">

View File

@ -12,29 +12,29 @@ import com.dylanc.activityresult.launcher.RequestPermissionLauncher
import com.dylanc.activityresult.launcher.StartActivityLauncher
import com.ramcosta.composedestinations.DestinationsNavHost
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import li.songe.gkd.composition.CompositionActivity
import li.songe.gkd.composition.CompositionExt.useLifeCycleLog
import li.songe.gkd.ui.NavGraphs
import li.songe.gkd.ui.component.ConfirmDialog
import li.songe.gkd.ui.theme.AppTheme
import li.songe.gkd.util.AuthDialog
import li.songe.gkd.util.LocalLauncher
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.LocalPickContentLauncher
import li.songe.gkd.util.LocalRequestPermissionLauncher
import li.songe.gkd.util.UpgradeDialog
import li.songe.gkd.util.launchTry
import li.songe.gkd.util.map
import li.songe.gkd.util.storeFlow
@AndroidEntryPoint
class MainActivity : CompositionActivity({
useLifeCycleLog()
val launcher = StartActivityLauncher(this)
val pickContentLauncher = PickContentLauncher(this)
val requestPermissionLauncher = RequestPermissionLauncher(this)
lifecycleScope.launchTry(Dispatchers.IO) {
lifecycleScope.launch {
storeFlow.map(lifecycleScope) { s -> s.excludeFromRecents }.collect {
(app.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).let { manager ->
manager.appTasks.forEach { task ->
@ -48,6 +48,8 @@ class MainActivity : CompositionActivity({
val navController = rememberNavController()
AppTheme {
ConfirmDialog()
AuthDialog()
UpgradeDialog()
CompositionLocalProvider(
LocalLauncher provides launcher,

View File

@ -0,0 +1,47 @@
package li.songe.gkd.data
import li.songe.gkd.service.TopActivity
import li.songe.selector.Selector
class AppRule(
matches: List<Selector>,
excludeMatches: List<Selector>,
actionDelay: Long,
quickFind: Boolean,
matchDelay: Long,
matchTime: Long?,
resetMatch: String?,
key: Int?,
preKeys: Set<Int>,
index: Int,
rule: RawSubscription.RawAppRule,
subsItem: SubsItem,
group: RawSubscription.RawAppGroup,
rawSubs: RawSubscription,
val appId: String,
val activityIds: List<String>,
val excludeActivityIds: List<String>,
val app: RawSubscription.RawApp,
) : ResolvedRule(
matches = matches,
excludeMatches = excludeMatches,
actionDelay = actionDelay,
quickFind = quickFind,
matchDelay = matchDelay,
matchTime = matchTime,
resetMatch = resetMatch,
key = key,
preKeys = preKeys,
index = index,
rule = rule,
group = group,
subsItem = subsItem,
rawSubs = rawSubs,
) {
override fun matchActivity(topActivity: TopActivity?): Boolean {
topActivity ?: return false
topActivity.activityId ?: return true
if (excludeActivityIds.any { topActivity.activityId.startsWith(it) }) return false
return activityIds.isEmpty() || activityIds.any { topActivity.activityId.startsWith(it) }
}
}

View File

@ -16,6 +16,7 @@ data class AttrInfo(
val focusable: Boolean,
val checkable: Boolean,
val checked: Boolean,
val editable: Boolean,
val longClickable: Boolean,
val visibleToUser: Boolean,
@ -63,6 +64,7 @@ data class AttrInfo(
focusable = node.isFocusable,
checkable = node.isCheckable,
checked = node.isChecked,
editable = node.isEditable,
longClickable = node.isLongClickable,
visibleToUser = node.isVisibleToUser,

View File

@ -21,7 +21,9 @@ data class ClickLog(
@ColumnInfo(name = "app_id") val appId: String? = null,
@ColumnInfo(name = "activity_id") val activityId: String? = null,
@ColumnInfo(name = "subs_id") val subsId: Long,
@ColumnInfo(name = "subs_version", defaultValue = "0") val subsVersion: Int,
@ColumnInfo(name = "group_key") val groupKey: Int,
@ColumnInfo(name = "group_type", defaultValue = "2") val groupType: Int,
@ColumnInfo(name = "rule_index") val ruleIndex: Int,
@ColumnInfo(name = "rule_key") val ruleKey: Int? = null,
) : Parcelable {

View File

@ -0,0 +1,119 @@
package li.songe.gkd.data
import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.GestureDescription
import android.graphics.Path
import android.graphics.Rect
import android.view.ViewConfiguration
import android.view.accessibility.AccessibilityNodeInfo
import com.blankj.utilcode.util.ScreenUtils
import kotlinx.serialization.Serializable
typealias ActionFc = (context: AccessibilityService, node: AccessibilityNodeInfo) -> ActionResult
@Serializable
data class GkdAction(
val selector: String,
val quickFind: Boolean = false,
val action: String? = null,
)
@Serializable
data class ActionResult(
val action: String,
val result: Boolean,
)
val clickNode: ActionFc = { _, node ->
ActionResult(
action = "clickNode", result = node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
)
}
val clickCenter: ActionFc = { context, node ->
val react = Rect()
node.getBoundsInScreen(react)
val x = (react.right + react.left) / 2f
val y = (react.bottom + react.top) / 2f
ActionResult(
action = "clickCenter",
result = if (0 <= x && 0 <= y && x <= ScreenUtils.getScreenWidth() && y <= ScreenUtils.getScreenHeight()) {
val gestureDescription = GestureDescription.Builder()
val path = Path()
path.moveTo(x, y)
gestureDescription.addStroke(
GestureDescription.StrokeDescription(
path, 0, ViewConfiguration.getTapTimeout().toLong()
)
)
context.dispatchGesture(gestureDescription.build(), null, null)
true
} else {
false
}
)
}
val click: ActionFc = { context, node ->
if (node.isClickable) clickNode(context, node) else clickCenter(context, node)
}
val longClickNode: ActionFc = { _, node ->
ActionResult(
action = "longClickNode",
result = node.performAction(AccessibilityNodeInfo.ACTION_LONG_CLICK)
)
}
val longClickCenter: ActionFc = { context, node ->
val react = Rect()
node.getBoundsInScreen(react)
val x = (react.right + react.left) / 2f
val y = (react.bottom + react.top) / 2f
// 内部的 DEFAULT_LONG_PRESS_TIMEOUT 常量是 400
// 而 ViewConfiguration.getLongPressTimeout() 返回 300, 这将导致触发普通的 click 事件
ActionResult(
action = "longClickCenter",
result = if (0 <= x && 0 <= y && x <= ScreenUtils.getScreenWidth() && y <= ScreenUtils.getScreenHeight()) {
val gestureDescription = GestureDescription.Builder()
val path = Path()
path.moveTo(x, y)
gestureDescription.addStroke(
GestureDescription.StrokeDescription(
path, 0, 400L
)
)
// TODO 传入处理 callback
context.dispatchGesture(gestureDescription.build(), null, null)
true
} else {
false
}
)
}
val longClick: ActionFc = { context, node ->
if (node.isLongClickable) longClickNode(context, node) else longClickCenter(context, node)
}
val backFc: ActionFc = { context, _ ->
ActionResult(
action = "back",
result = context.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK)
)
}
fun getActionFc(action: String?): ActionFc {
return when (action) {
"clickNode" -> clickNode
"clickCenter" -> clickCenter
"back" -> backFc
"longClick" -> longClick
"longClickNode" -> longClickNode
"longClickCenter" -> longClickCenter
else -> click
}
}

View File

@ -0,0 +1,60 @@
package li.songe.gkd.data
import li.songe.gkd.service.TopActivity
import li.songe.selector.Selector
data class GlobalApp(
val id: String,
val enable: Boolean,
val activityIds: List<String>,
val excludeActivityIds: List<String>,
)
class GlobalRule(
matches: List<Selector>,
excludeMatches: List<Selector>,
actionDelay: Long,
quickFind: Boolean,
matchDelay: Long,
matchTime: Long?,
resetMatch: String?,
key: Int?,
preKeys: Set<Int>,
index: Int,
subsItem: SubsItem,
rule: RawSubscription.RawGlobalRule,
group: RawSubscription.RawGlobalGroup,
rawSubs: RawSubscription,
val apps: Map<String, GlobalApp>,
val matchAnyApp: Boolean,
) : ResolvedRule(
matches = matches,
excludeMatches = excludeMatches,
actionDelay = actionDelay,
quickFind = quickFind,
matchDelay = matchDelay,
matchTime = matchTime,
resetMatch = resetMatch,
key = key,
preKeys = preKeys,
index = index,
rule = rule,
group = group,
subsItem = subsItem,
rawSubs = rawSubs,
) {
private val excludeAppIds = apps.filter { e -> !e.value.enable }.keys
override fun matchActivity(topActivity: TopActivity?): Boolean {
topActivity ?: return false
if (excludeAppIds.contains(topActivity.appId)) {
return false
}
val app = apps[topActivity.appId] ?: return matchAnyApp
topActivity.activityId ?: return true
if (app.excludeActivityIds.any { e -> e.startsWith(topActivity.activityId) }) {
return false
}
return app.activityIds.isEmpty() || app.activityIds.any { e -> e.startsWith(topActivity.activityId) }
}
}

View File

@ -115,7 +115,7 @@ data class NodeInfo(
// https://github.com/gkd-kit/gkd/issues/28
ToastUtils.showShort("节点数量至多保留$MAX_KEEP_SIZE,丢弃后续节点")
LogUtils.w(
rootNodeInfo.packageName, topActivityFlow.value?.activityId, "节点数量过多"
rootNodeInfo.packageName, topActivityFlow.value.activityId, "节点数量过多"
)
break
}

View File

@ -0,0 +1,608 @@
package li.songe.gkd.data
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.long
import li.songe.gkd.util.json
import li.songe.gkd.util.json5ToJson
import li.songe.selector.Selector
@Serializable
data class RawSubscription(
val id: Long,
val name: String,
val version: Int,
val author: String? = null,
val updateUrl: String? = null,
val supportUri: String? = null,
val checkUpdateUrl: String? = null,
val apps: List<RawApp> = emptyList(),
val categories: List<RawCategory> = emptyList(),
val globalGroups: List<RawGlobalGroup> = emptyList(),
) {
@IgnoredOnParcel
val categoryToGroupsMap by lazy {
val allAppGroups = apps.flatMap { a -> a.groups.map { g -> g to a } }
allAppGroups.groupBy { g ->
categories.find { c -> g.first.name.startsWith(c.name) }
}
}
@IgnoredOnParcel
val groupToCategoryMap by lazy {
val map = mutableMapOf<RawAppGroup, RawCategory>()
categoryToGroupsMap.forEach { (key, value) ->
value.forEach { (g) ->
if (key != null) {
map[g] = key
}
}
}
map
}
@IgnoredOnParcel
val appGroups by lazy {
apps.flatMap { a -> a.groups }
}
@IgnoredOnParcel
val allGroupSize by lazy {
globalGroups.size + appGroups.size
}
@Serializable
data class RawApp(
val id: String,
val name: String?,
val groups: List<RawAppGroup> = emptyList(),
)
@Serializable
data class RawCategory(val key: Int, val name: String, val enable: Boolean?)
interface RawCommonProps {
val actionCd: Long?
val actionDelay: Long?
val quickFind: Boolean?
val matchDelay: Long?
val matchTime: Long?
val actionMaximum: Int?
val resetMatch: String?
val actionCdKey: Int?
val actionMaximumKey: Int?
val snapshotUrls: List<String>?
val exampleUrls: List<String>?
}
interface RawRuleProps : RawCommonProps {
val name: String?
val key: Int?
val preKeys: List<Int>?
val action: String?
val matches: List<String>
val excludeMatches: List<String>?
}
interface RawGroupProps : RawCommonProps {
val name: String
val key: Int
val desc: String?
val enable: Boolean?
val rules: List<RawRuleProps>
}
interface RawAppRuleProps {
val activityIds: List<String>?
val excludeActivityIds: List<String>?
}
interface RawGlobalRuleProps {
val matchAnyApp: Boolean?
val apps: List<RawGlobalApp>?
}
@Serializable
data class RawGlobalApp(
val id: String,
val enable: Boolean?,
val activityIds: List<String>?,
val excludeActivityIds: List<String>?,
)
@Serializable
data class RawGlobalGroup(
override val name: String,
override val key: Int,
override val desc: String?,
override val enable: Boolean?,
override val actionCd: Long?,
override val actionDelay: Long?,
override val quickFind: Boolean?,
override val matchDelay: Long?,
override val matchTime: Long?,
override val actionMaximum: Int?,
override val resetMatch: String?,
override val actionCdKey: Int?,
override val actionMaximumKey: Int?,
override val snapshotUrls: List<String>?,
override val exampleUrls: List<String>?,
override val matchAnyApp: Boolean?,
override val apps: List<RawGlobalApp>?,
override val rules: List<RawGlobalRule>,
) : RawGroupProps, RawGlobalRuleProps {
@IgnoredOnParcel
val valid by lazy {
rules.all { r ->
r.matches.all { s -> Selector.check(s) } && (r.excludeMatches
?: emptyList()).all { s ->
Selector.check(
s
)
}
}
}
@IgnoredOnParcel
val allExampleUrls by lazy {
mutableListOf<String>().apply {
if (exampleUrls != null) {
addAll(exampleUrls)
}
rules.forEach { r ->
if (r.exampleUrls != null) {
addAll(r.exampleUrls)
}
}
}
}
}
@Serializable
data class RawGlobalRule(
override val actionCd: Long?,
override val actionDelay: Long?,
override val quickFind: Boolean?,
override val matchDelay: Long?,
override val matchTime: Long?,
override val actionMaximum: Int?,
override val resetMatch: String?,
override val actionCdKey: Int?,
override val actionMaximumKey: Int?,
override val snapshotUrls: List<String>?,
override val exampleUrls: List<String>?,
override val name: String?,
override val key: Int?,
override val preKeys: List<Int>?,
override val action: String?,
override val matches: List<String>,
override val excludeMatches: List<String>?,
override val matchAnyApp: Boolean?,
override val apps: List<RawGlobalApp>?
) : RawRuleProps, RawGlobalRuleProps
@Serializable
data class RawAppGroup(
override val name: String,
override val key: Int,
override val desc: String?,
override val enable: Boolean?,
override val actionCdKey: Int?,
override val actionMaximumKey: Int?,
override val actionCd: Long?,
override val actionDelay: Long?,
override val quickFind: Boolean?,
override val actionMaximum: Int?,
override val matchDelay: Long?,
override val matchTime: Long?,
override val resetMatch: String?,
override val snapshotUrls: List<String>?,
override val exampleUrls: List<String>?,
override val activityIds: List<String>?,
override val excludeActivityIds: List<String>?,
override val rules: List<RawAppRule>,
) : RawGroupProps, RawAppRuleProps {
@IgnoredOnParcel
val valid by lazy {
rules.all { r ->
r.matches.all { s -> Selector.check(s) } && (r.excludeMatches
?: emptyList()).all { s ->
Selector.check(
s
)
}
}
}
@IgnoredOnParcel
val allExampleUrls by lazy {
mutableListOf<String>().apply {
if (exampleUrls != null) {
addAll(exampleUrls)
}
rules.forEach { r ->
if (r.exampleUrls != null) {
addAll(r.exampleUrls)
}
}
}
}
}
@Serializable
data class RawAppRule(
override val name: String?,
override val key: Int?,
override val preKeys: List<Int>?,
override val action: String?,
override val matches: List<String>,
override val excludeMatches: List<String>?,
override val actionCdKey: Int?,
override val actionMaximumKey: Int?,
override val actionCd: Long?,
override val actionDelay: Long?,
override val quickFind: Boolean?,
override val actionMaximum: Int?,
override val matchDelay: Long?,
override val matchTime: Long?,
override val resetMatch: String?,
override val snapshotUrls: List<String>?,
override val exampleUrls: List<String>?,
override val activityIds: List<String>?,
override val excludeActivityIds: List<String>?,
) : RawRuleProps, RawAppRuleProps
companion object {
private fun getStringIArray(json: JsonObject? = null, name: String): List<String>? {
return when (val element = json?.get(name)) {
JsonNull, null -> null
is JsonObject -> error("Element ${this::class} can not be object")
is JsonArray -> element.map {
when (it) {
is JsonObject, is JsonArray, JsonNull -> error("Element ${this::class} is not a int")
is JsonPrimitive -> it.content
}
}
is JsonPrimitive -> listOf(element.content)
}
}
@Suppress("SameParameterValue")
private fun getIntIArray(json: JsonObject? = null, name: String): List<Int>? {
return when (val element = json?.get(name)) {
JsonNull, null -> null
is JsonArray -> element.map {
when (it) {
is JsonObject, is JsonArray, JsonNull -> error("Element $it is not a int")
is JsonPrimitive -> it.int
}
}
is JsonPrimitive -> listOf(element.int)
else -> error("Element $element is not a Array")
}
}
private fun getString(json: JsonObject? = null, key: String): String? =
when (val p = json?.get(key)) {
JsonNull, null -> null
is JsonPrimitive -> {
if (p.isString) {
p.content
} else {
error("Element $p is not a string")
}
}
else -> error("Element $p is not a string")
}
private fun getLong(json: JsonObject? = null, key: String): Long? =
when (val p = json?.get(key)) {
JsonNull, null -> null
is JsonPrimitive -> {
p.long
}
else -> error("Element $p is not a long")
}
private fun getInt(json: JsonObject? = null, key: String): Int? =
when (val p = json?.get(key)) {
JsonNull, null -> null
is JsonPrimitive -> {
p.int
}
else -> error("Element $p is not a int")
}
@Suppress("SameParameterValue")
private fun getBoolean(json: JsonObject? = null, key: String): Boolean? =
when (val p = json?.get(key)) {
JsonNull, null -> null
is JsonPrimitive -> {
p.boolean
}
else -> error("Element $p is not a boolean")
}
private fun jsonToRuleRaw(rulesRawJson: JsonElement): RawAppRule {
val rulesJson = when (rulesRawJson) {
JsonNull -> error("miss current rule")
is JsonObject -> rulesRawJson
is JsonPrimitive, is JsonArray -> JsonObject(mapOf("matches" to rulesRawJson))
}
return RawAppRule(
activityIds = getStringIArray(rulesJson, "activityIds"),
excludeActivityIds = getStringIArray(rulesJson, "excludeActivityIds"),
matches = (getStringIArray(
rulesJson, "matches"
) ?: emptyList()),
excludeMatches = getStringIArray(rulesJson, "excludeMatches"),
key = getInt(rulesJson, "key"),
name = getString(rulesJson, "name"),
actionCd = getLong(rulesJson, "actionCd") ?: getLong(rulesJson, "cd"),
actionDelay = getLong(rulesJson, "actionDelay") ?: getLong(rulesJson, "delay"),
preKeys = getIntIArray(rulesJson, "preKeys"),
action = getString(rulesJson, "action"),
quickFind = getBoolean(rulesJson, "quickFind"),
actionMaximum = getInt(rulesJson, "actionMaximum"),
matchDelay = getLong(rulesJson, "matchDelay"),
matchTime = getLong(rulesJson, "matchTime"),
resetMatch = getString(rulesJson, "resetMatch"),
snapshotUrls = getStringIArray(rulesJson, "snapshotUrls"),
exampleUrls = getStringIArray(rulesJson, "exampleUrls"),
actionMaximumKey = getInt(rulesJson, "actionMaximumKey"),
actionCdKey = getInt(rulesJson, "actionCdKey"),
)
}
private fun jsonToGroupRaw(groupRawJson: JsonElement, groupIndex: Int): RawAppGroup {
val groupJson = when (groupRawJson) {
JsonNull -> error("group must not be null")
is JsonObject -> groupRawJson
is JsonPrimitive, is JsonArray -> JsonObject(mapOf("rules" to groupRawJson))
}
return RawAppGroup(
activityIds = getStringIArray(groupJson, "activityIds"),
excludeActivityIds = getStringIArray(groupJson, "excludeActivityIds"),
actionCd = getLong(groupJson, "actionCd") ?: getLong(groupJson, "cd"),
actionDelay = getLong(groupJson, "actionDelay") ?: getLong(groupJson, "delay"),
name = getString(groupJson, "name") ?: error("miss group name"),
desc = getString(groupJson, "desc"),
enable = getBoolean(groupJson, "enable"),
key = getInt(groupJson, "key") ?: groupIndex,
rules = when (val rulesJson = groupJson["rules"]) {
null, JsonNull -> emptyList()
is JsonPrimitive, is JsonObject -> JsonArray(listOf(rulesJson))
is JsonArray -> rulesJson
}.map {
jsonToRuleRaw(it)
},
quickFind = getBoolean(groupJson, "quickFind"),
actionMaximum = getInt(groupJson, "actionMaximum"),
matchDelay = getLong(groupJson, "matchDelay"),
matchTime = getLong(groupJson, "matchTime"),
resetMatch = getString(groupJson, "resetMatch"),
snapshotUrls = getStringIArray(groupJson, "snapshotUrls"),
exampleUrls = getStringIArray(groupJson, "exampleUrls"),
actionMaximumKey = getInt(groupJson, "actionMaximumKey"),
actionCdKey = getInt(groupJson, "actionCdKey"),
)
}
private fun jsonToAppRaw(appsJson: JsonObject, appIndex: Int): RawApp {
return RawApp(
id = getString(appsJson, "id") ?: error("miss subscription.apps[$appIndex].id"),
name = getString(appsJson, "name"),
groups = (when (val groupsJson = appsJson["groups"]) {
null, JsonNull -> emptyList()
is JsonPrimitive, is JsonObject -> JsonArray(listOf(groupsJson))
is JsonArray -> groupsJson
}).mapIndexed { index, jsonElement ->
jsonToGroupRaw(jsonElement, index)
},
)
}
private fun jsonToGlobalApp(jsonObject: JsonObject, index: Int): RawGlobalApp {
return RawGlobalApp(
id = getString(jsonObject, "id") ?: error("miss apps[$index].id"),
enable = getBoolean(jsonObject, "enable"),
activityIds = getStringIArray(jsonObject, "activityIds"),
excludeActivityIds = getStringIArray(jsonObject, "excludeActivityIds"),
)
}
private fun jsonToGlobalRule(jsonObject: JsonObject): RawGlobalRule {
return RawGlobalRule(
key = getInt(jsonObject, "key"),
name = getString(jsonObject, "name"),
actionCd = getLong(jsonObject, "actionCd"),
actionDelay = getLong(jsonObject, "actionDelay"),
quickFind = getBoolean(jsonObject, "quickFind"),
actionMaximum = getInt(jsonObject, "actionMaximum"),
matchDelay = getLong(jsonObject, "matchDelay"),
matchTime = getLong(jsonObject, "matchTime"),
resetMatch = getString(jsonObject, "resetMatch"),
snapshotUrls = getStringIArray(jsonObject, "snapshotUrls"),
exampleUrls = getStringIArray(jsonObject, "exampleUrls"),
actionMaximumKey = getInt(jsonObject, "actionMaximumKey"),
actionCdKey = getInt(jsonObject, "actionCdKey"),
matchAnyApp = getBoolean(jsonObject, "matchAnyApp"),
apps = jsonObject["apps"]?.jsonArray?.mapIndexed { index, jsonElement ->
jsonToGlobalApp(
jsonElement.jsonObject, index
)
},
action = getString(jsonObject, "action"),
preKeys = getIntIArray(jsonObject, "preKeys"),
excludeMatches = getStringIArray(jsonObject, "excludeMatches"),
matches = getStringIArray(jsonObject, "matches") ?: error("miss matches"),
)
}
private fun jsonToGlobalGroups(jsonObject: JsonObject, groupIndex: Int): RawGlobalGroup {
return RawGlobalGroup(key = getInt(jsonObject, "key")
?: error("miss group[$groupIndex].key"),
name = getString(jsonObject, "name") ?: error("miss group[$groupIndex].name"),
desc = getString(jsonObject, "desc"),
enable = getBoolean(jsonObject, "enable"),
actionCd = getLong(jsonObject, "actionCd"),
actionDelay = getLong(jsonObject, "actionDelay"),
quickFind = getBoolean(jsonObject, "quickFind"),
actionMaximum = getInt(jsonObject, "actionMaximum"),
matchDelay = getLong(jsonObject, "matchDelay"),
matchTime = getLong(jsonObject, "matchTime"),
resetMatch = getString(jsonObject, "resetMatch"),
snapshotUrls = getStringIArray(jsonObject, "snapshotUrls"),
exampleUrls = getStringIArray(jsonObject, "exampleUrls"),
actionMaximumKey = getInt(jsonObject, "actionMaximumKey"),
actionCdKey = getInt(jsonObject, "actionCdKey"),
matchAnyApp = getBoolean(jsonObject, "matchAnyApp"),
apps = jsonObject["apps"]?.jsonArray?.mapIndexed { index, jsonElement ->
jsonToGlobalApp(
jsonElement.jsonObject, index
)
},
rules = jsonObject["rules"]?.jsonArray?.map { jsonElement ->
jsonToGlobalRule(jsonElement.jsonObject)
} ?: emptyList()
)
}
private fun jsonToSubscriptionRaw(rootJson: JsonObject): RawSubscription {
return RawSubscription(id = getLong(rootJson, "id") ?: error("miss subscription.id"),
name = getString(rootJson, "name") ?: error("miss subscription.name"),
version = getInt(rootJson, "version") ?: error("miss subscription.version"),
author = getString(rootJson, "author"),
updateUrl = getString(rootJson, "updateUrl"),
supportUri = getString(rootJson, "supportUri"),
checkUpdateUrl = getString(rootJson, "checkUpdateUrl"),
apps = rootJson["apps"]?.jsonArray?.mapIndexed { index, jsonElement ->
jsonToAppRaw(
jsonElement.jsonObject, index
)
} ?: emptyList(),
categories = rootJson["categories"]?.jsonArray?.mapIndexed { index, jsonElement ->
RawCategory(
key = getInt(jsonElement.jsonObject, "key")
?: error("miss categories[$index].key"),
name = getString(jsonElement.jsonObject, "name")
?: error("miss categories[$index].name"),
enable = getBoolean(jsonElement.jsonObject, "enable"),
)
} ?: emptyList(),
globalGroups = rootJson["globalGroups"]?.jsonArray?.mapIndexed { index, jsonElement ->
jsonToGlobalGroups(jsonElement.jsonObject, index)
} ?: emptyList()
)
}
private fun <T> List<T>.findDuplicatedItem(predicate: (T) -> Any?): T? {
forEach { v ->
val key = predicate(v)
if (key != null && any { v2 -> v2 !== v && predicate(v2) == key }) {
return v
}
}
return null
}
fun parse(source: String, json5: Boolean = true): RawSubscription {
val text = if (json5) json5ToJson(source) else source
val subscription = jsonToSubscriptionRaw(json.parseToJsonElement(text).jsonObject)
subscription.categories.findDuplicatedItem { v -> v.key }?.let { v ->
error("duplicated category: key=${v.key}")
}
subscription.globalGroups.findDuplicatedItem { v -> v.key }?.let { v ->
error("duplicated global group: key=${v.key}")
}
subscription.globalGroups.forEach { g ->
g.rules.findDuplicatedItem { v -> v.key }?.let { v ->
error("duplicated global rule: key=${v.key}, groupKey=${g.key}")
}
}
subscription.apps.findDuplicatedItem { v -> v.id }?.let { v ->
error("duplicated app: ${v.id}")
}
subscription.apps.forEach { a ->
a.groups.findDuplicatedItem { v -> v.key }?.let { v ->
error("duplicated app group: key=${v.key}")
}
a.groups.forEach { g ->
g.rules.findDuplicatedItem { v -> v.key }?.let { v ->
error("duplicated app rule: key=${v.key}, groupKey=${g.key}, appId=${a.id} ")
}
}
}
return subscription
}
fun parseRawApp(source: String, json5: Boolean = true): RawApp {
val text = if (json5) json5ToJson(source) else source
val a = jsonToAppRaw(json.parseToJsonElement(text).jsonObject, 0)
a.groups.findDuplicatedItem { v -> v.key }?.let { v ->
error("duplicated app group: key=${v.key}")
}
a.groups.forEach { g ->
g.rules.findDuplicatedItem { v -> v.key }?.let { v ->
error("duplicated app rule: key=${v.key}, groupKey=${g.key}")
}
}
return a
}
fun parseRawGroup(source: String, json5: Boolean = true): RawAppGroup {
val text = if (json5) json5ToJson(source) else source
val g = jsonToGroupRaw(json.parseToJsonElement(text).jsonObject, 0)
g.rules.findDuplicatedItem { v -> v.key }?.let { v ->
error("duplicated app rule: key=${v.key}")
}
return g
}
fun parseRawGlobalGroup(source: String, json5: Boolean = true): RawGlobalGroup {
val text = if (json5) json5ToJson(source) else source
val g = jsonToGlobalGroups(json.parseToJsonElement(text).jsonObject, 0)
g.rules.findDuplicatedItem { v -> v.key }?.let { v ->
error("duplicated global rule: key=${v.key}")
}
return g
}
}
}

View File

@ -0,0 +1,129 @@
package li.songe.gkd.data
import android.view.accessibility.AccessibilityNodeInfo
import kotlinx.coroutines.Job
import li.songe.gkd.service.TopActivity
import li.songe.gkd.service.lastTriggerAppRule
import li.songe.gkd.service.querySelector
import li.songe.selector.Selector
sealed class ResolvedRule(
val matches: List<Selector>,
val excludeMatches: List<Selector>,
val actionDelay: Long,
val quickFind: Boolean,
val matchDelay: Long,
val matchTime: Long?,
val resetMatch: String?,
val key: Int?,
val preKeys: Set<Int>,
val index: Int,
val rule: RawSubscription.RawRuleProps,
val group: RawSubscription.RawGroupProps,
val rawSubs: RawSubscription,
val subsItem: SubsItem,
) {
var preAppRules: Set<ResolvedRule> = emptySet()
var actionDelayTriggerTime = 0L
var actionDelayJob: Job? = null
fun checkDelay(): Boolean {
if (actionDelay > 0 && actionDelayTriggerTime == 0L) {
actionDelayTriggerTime = System.currentTimeMillis()
return true
}
return false
}
val actionCd = (if (rule.actionCdKey != null) {
group.rules.find { r -> r.key == rule.actionCdKey }?.actionCd ?: group.actionCd
} else {
null
} ?: rule.actionCd ?: group.actionCd ?: 1000L)
var actionTriggerTime = Value(0L)
fun trigger() {
actionTriggerTime.value = System.currentTimeMillis()
// 重置延迟点
actionDelayTriggerTime = 0L
actionCount.value++
lastTriggerAppRule = this
}
val actionMaximum = ((if (rule.actionMaximumKey != null) {
group.rules.find { r -> r.key == rule.actionMaximumKey }?.actionMaximum
?: group.actionMaximum
} else {
null
}) ?: rule.actionMaximum ?: group.actionMaximum)
var actionCount = Value(0)
var matchChangedTime = 0L
val matchLimitTime = (matchTime ?: 0) + matchDelay
val resetMatchTypeWhenActivity = when (resetMatch) {
"app" -> false
"activity" -> true
else -> true
}
fun query(nodeInfo: AccessibilityNodeInfo?): AccessibilityNodeInfo? {
if (nodeInfo == null) return null
var target: AccessibilityNodeInfo? = null
for (selector in matches) {
target = nodeInfo.querySelector(selector, quickFind) ?: return null
}
for (selector in excludeMatches) {
if (nodeInfo.querySelector(selector, quickFind) != null) return null
}
return target
}
val performAction = getActionFc(rule.action)
var matchDelayJob: Job? = null
val statusCode: Int
get() {
if (actionMaximum != null) {
if (actionCount.value >= actionMaximum) {
return 1 // 达到最大执行次数
}
}
if (preAppRules.isNotEmpty()) { // 需要提前点击某个规则
lastTriggerAppRule ?: return 2
return if (preAppRules.any { it === lastTriggerAppRule }) {
0
} else {
3 // 上一个点击的规则不在当前需要点击的列表
}
}
val t = System.currentTimeMillis()
if (matchDelay > 0 && t - matchChangedTime < matchDelay) {
return 4 // 处于匹配延迟中
}
if (matchTime != null && t - matchChangedTime > matchLimitTime) {
return 5 // 超出匹配时间
}
if (actionTriggerTime.value + actionCd > t) {
return 6 // 处于冷却时间
}
if (actionDelayTriggerTime > 0) {
if (actionDelayTriggerTime + actionDelay > t) {
return 7 // 处于点击延迟中
}
}
return 0
}
abstract fun matchActivity(topActivity: TopActivity?): Boolean
}

View File

@ -1,243 +0,0 @@
package li.songe.gkd.data
import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.GestureDescription
import android.graphics.Path
import android.graphics.Rect
import android.view.ViewConfiguration
import android.view.accessibility.AccessibilityNodeInfo
import com.blankj.utilcode.util.LogUtils
import com.blankj.utilcode.util.ScreenUtils
import kotlinx.serialization.Serializable
import li.songe.gkd.service.lastTriggerRule
import li.songe.gkd.service.openAdOptimized
import li.songe.gkd.service.querySelector
import li.songe.selector.Selector
class Value<T>(var value: T)
data class Rule(
/**
* length>0
*/
val matches: List<Selector> = emptyList(),
val excludeMatches: List<Selector> = emptyList(),
val actionDelay: Long = 0,
val quickFind: Boolean = false,
val matchDelay: Long?,
val matchTime: Long?,
val resetMatch: String?,
val appId: String,
val activityIds: Set<String> = emptySet(),
val excludeActivityIds: Set<String> = emptySet(),
val key: Int? = null,
val preKeys: Set<Int> = emptySet(),
val index: Int = 0,
val rule: SubscriptionRaw.RuleRaw,
val group: SubscriptionRaw.GroupRaw,
val app: SubscriptionRaw.AppRaw,
val subsItem: SubsItem,
) {
/**
* 优化: 切换 APP 后短时间内, 如果存在开屏广告的规则并且没有一次触发, 则尽量使开屏广告运行
*/
val isOpenAd = group.name.startsWith("开屏广告")
/**
* 任意一个元素是上次点击过的
*/
var preRules: Set<Rule> = emptySet()
var actionDelayTriggerTime = 0L
fun triggerDelay() {
// 触发延迟, 一段时间内此规则不可利用
actionDelayTriggerTime = System.currentTimeMillis()
LogUtils.d(
"触发延迟",
"subsId:${subsItem.id}, gKey=${group.key}, gName:${group.name}, ruleIndex:${index}, rKey:${key}, delay:${actionDelay}"
)
}
val actionCd = defaultMiniCd.coerceAtLeast(
((if (rule.actionCdKey != null) {
group.rules.find { r -> r.key == rule.actionCdKey }?.actionCd ?: group.actionCd
?: app.actionCd
} else {
null
}) ?: rule.actionCd ?: group.actionCd ?: app.actionCd ?: defaultMiniCd)
)
var actionTriggerTime = Value(0L)
fun trigger() {
actionTriggerTime.value = System.currentTimeMillis()
// 重置延迟点
actionDelayTriggerTime = 0L
actionCount.value++
lastTriggerRule = this
if (isOpenAd && openAdOptimized == true) {
openAdOptimized = false
}
}
val actionMaximum = ((if (rule.actionMaximumKey != null) {
group.rules.find { r -> r.key == rule.actionMaximumKey }?.actionMaximum
?: group.actionMaximum ?: app.actionMaximum
} else {
null
}) ?: rule.actionMaximum ?: group.actionMaximum ?: app.actionMaximum)
var actionCount = Value(0)
var matchChangeTime = 0L
val matchAllTime = (matchTime ?: 0) + (matchDelay ?: 0)
val resetMatchTypeWhenActivity = when (resetMatch) {
"app" -> false
"activity" -> true
else -> true
}
fun query(nodeInfo: AccessibilityNodeInfo?): AccessibilityNodeInfo? {
if (nodeInfo == null) return null
var target: AccessibilityNodeInfo? = null
for (selector in matches) {
target = nodeInfo.querySelector(selector, quickFind) ?: return null
}
for (selector in excludeMatches) {
if (nodeInfo.querySelector(selector, quickFind) != null) return null
}
return target
}
private val matchAnyActivity = activityIds.isEmpty() && excludeActivityIds.isEmpty()
fun matchActivityId(activityId: String?): Boolean {
if (matchAnyActivity || activityId == null) return true
if (excludeActivityIds.any { activityId.startsWith(it) }) return false
if (activityIds.isEmpty()) return true
return activityIds.any { activityId.startsWith(it) }
}
val performAction = getActionFc(rule.action)
companion object {
const val defaultMiniCd = 1000L
}
}
typealias ActionFc = (context: AccessibilityService, node: AccessibilityNodeInfo) -> ActionResult
@Serializable
data class GkdAction(
val selector: String,
val quickFind: Boolean = false,
val action: String? = null,
)
@Serializable
data class ActionResult(
val action: String,
val result: Boolean,
)
val clickNode: ActionFc = { _, node ->
ActionResult(
action = "clickNode", result = node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
)
}
val clickCenter: ActionFc = { context, node ->
val react = Rect()
node.getBoundsInScreen(react)
val x = (react.right + react.left) / 2f
val y = (react.bottom + react.top) / 2f
ActionResult(
action = "clickCenter",
result = if (0 <= x && 0 <= y && x <= ScreenUtils.getScreenWidth() && y <= ScreenUtils.getScreenHeight()) {
val gestureDescription = GestureDescription.Builder()
val path = Path()
path.moveTo(x, y)
gestureDescription.addStroke(
GestureDescription.StrokeDescription(
path, 0, ViewConfiguration.getTapTimeout().toLong()
)
)
context.dispatchGesture(gestureDescription.build(), null, null)
true
} else {
false
}
)
}
val click: ActionFc = { context, node ->
if (node.isClickable) clickNode(context, node) else clickCenter(context, node)
}
val longClickNode: ActionFc = { _, node ->
ActionResult(
action = "longClickNode",
result = node.performAction(AccessibilityNodeInfo.ACTION_LONG_CLICK)
)
}
val longClickCenter: ActionFc = { context, node ->
val react = Rect()
node.getBoundsInScreen(react)
val x = (react.right + react.left) / 2f
val y = (react.bottom + react.top) / 2f
// 内部的 DEFAULT_LONG_PRESS_TIMEOUT 常量是 400
// 而 ViewConfiguration.getLongPressTimeout() 返回 300, 这将导致触发普通的 click 事件
ActionResult(
action = "longClickCenter",
result = if (0 <= x && 0 <= y && x <= ScreenUtils.getScreenWidth() && y <= ScreenUtils.getScreenHeight()) {
val gestureDescription = GestureDescription.Builder()
val path = Path()
path.moveTo(x, y)
gestureDescription.addStroke(
GestureDescription.StrokeDescription(
path, 0, 400L
)
)
// TODO 传入处理 callback
context.dispatchGesture(gestureDescription.build(), null, null)
true
} else {
false
}
)
}
val longClick: ActionFc = { context, node ->
if (node.isLongClickable) longClickNode(context, node) else longClickCenter(context, node)
}
val backFc: ActionFc = { context, _ ->
ActionResult(
action = "back",
result = context.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK)
)
}
fun getActionFc(action: String?): ActionFc {
return when (action) {
"clickNode" -> clickNode
"clickCenter" -> clickCenter
"back" -> backFc
"longClick" -> longClick
"longClickNode" -> longClickNode
"longClickCenter" -> longClickCenter
else -> click
}
}

View File

@ -29,7 +29,8 @@ data class SubsConfig(
companion object {
const val AppType = 1
const val GroupType = 2
const val AppGroupType = 2
const val GlobalGroupType = 3
}
@Dao
@ -57,14 +58,17 @@ data class SubsConfig(
fun query(): Flow<List<SubsConfig>>
@Query("SELECT * FROM subs_config WHERE type=${AppType} and subs_item_id=:subsItemId")
@Query("SELECT * FROM subs_config WHERE type=${AppType} AND subs_item_id=:subsItemId")
fun queryAppTypeConfig(subsItemId: Long): Flow<List<SubsConfig>>
@Query("SELECT * FROM subs_config WHERE type=${GroupType} and subs_item_id=:subsItemId")
@Query("SELECT * FROM subs_config WHERE type=${AppGroupType} AND subs_item_id=:subsItemId")
fun querySubsGroupTypeConfig(subsItemId: Long): Flow<List<SubsConfig>>
@Query("SELECT * FROM subs_config WHERE type=${GroupType} and subs_item_id=:subsItemId and app_id=:appId")
fun queryGroupTypeConfig(subsItemId: Long, appId: String): Flow<List<SubsConfig>>
@Query("SELECT * FROM subs_config WHERE type=${AppGroupType} AND subs_item_id=:subsItemId AND app_id=:appId")
fun queryAppGroupTypeConfig(subsItemId: Long, appId: String): Flow<List<SubsConfig>>
@Query("SELECT * FROM subs_config WHERE type=${GlobalGroupType} AND subs_item_id=:subsItemId")
fun queryGlobalGroupTypeConfig(subsItemId: Long): Flow<List<SubsConfig>>
}
}

View File

@ -9,12 +9,9 @@ import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import li.songe.gkd.db.DbSet
import li.songe.gkd.util.subsFolder
import java.io.File
import li.songe.gkd.util.deleteSubscription
@Entity(
tableName = "subs_item",
@ -31,37 +28,14 @@ data class SubsItem(
) {
val subsFile by lazy {
File(subsFolder.absolutePath.plus("/${id}.json"))
}
suspend fun removeAssets() {
withContext(IO) {
subsFile.exists() && subsFile.delete()
}
deleteSubscription(id)
DbSet.subsItemDao.delete(this)
DbSet.subsConfigDao.delete(id)
DbSet.clickLogDao.deleteBySubsId(id)
DbSet.categoryConfigDao.deleteBySubsItemId(id)
}
companion object {
fun getSubscriptionRaw(subsItemId: Long): SubscriptionRaw? {
return try {
val file = File(subsFolder.absolutePath.plus("/${subsItemId}.json"))
if (!file.exists()) {
return null
}
return SubscriptionRaw.parse(file.readText())
} catch (e: Exception) {
e.printStackTrace()
null
}
}
}
@Dao
interface SubsItemDao {
@Update
@ -74,7 +48,7 @@ data class SubsItem(
suspend fun delete(vararg users: SubsItem): Int
@Query("UPDATE subs_item SET mtime=:mtime WHERE id=:id")
suspend fun updateMtime(id: Long, mtime: Long): Int
suspend fun updateMtime(id: Long, mtime: Long = System.currentTimeMillis()): Int
@Query("SELECT * FROM subs_item ORDER BY `order`")
fun query(): Flow<List<SubsItem>>

View File

@ -1,406 +0,0 @@
package li.songe.gkd.data
import blue.endless.jankson.Jankson
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.long
import li.songe.gkd.util.json
import li.songe.selector.Selector
@Serializable
data class SubscriptionRaw(
val id: Long,
val name: String,
val version: Int,
val author: String? = null,
val updateUrl: String? = null,
val supportUri: String? = null,
val checkUpdateUrl: String? = null,
val apps: List<AppRaw> = emptyList(),
val categories: List<Category> = emptyList(),
) {
@IgnoredOnParcel
val categoriesGroups by lazy {
val allAppGroups =
apps.flatMap { a -> a.groups.map { g -> g to a } }
allAppGroups.groupBy { g ->
categories.find { c -> g.first.name.startsWith(c.name) }
}
}
@IgnoredOnParcel
val groupToCategoryMap by lazy {
val map = mutableMapOf<GroupRaw, Category>()
categoriesGroups.forEach { (key, value) ->
value.forEach { (g) ->
if (key != null) {
map[g] = key
}
}
}
map
}
@Serializable
data class Category(val key: Int, val name: String, val enable: Boolean?)
interface CommonProps {
val activityIds: List<String>?
val excludeActivityIds: List<String>?
val actionCd: Long?
val actionDelay: Long?
val quickFind: Boolean?
val matchDelay: Long?
val matchTime: Long?
val actionMaximum: Int?
val resetMatch: String?
}
@Serializable
data class AppRaw(
val id: String,
val name: String?,
override val actionCd: Long?,
override val actionDelay: Long?,
override val quickFind: Boolean?,
override val actionMaximum: Int?,
override val matchDelay: Long?,
override val matchTime: Long?,
override val resetMatch: String?,
override val activityIds: List<String>?,
override val excludeActivityIds: List<String>?,
val groups: List<GroupRaw> = emptyList(),
) : CommonProps
@Serializable
data class GroupRaw(
val name: String,
val key: Int,
val desc: String?,
val enable: Boolean?,
override val actionCd: Long?,
override val actionDelay: Long?,
override val quickFind: Boolean?,
override val actionMaximum: Int?,
override val matchDelay: Long?,
override val matchTime: Long?,
override val resetMatch: String?,
override val activityIds: List<String>?,
override val excludeActivityIds: List<String>?,
val rules: List<RuleRaw>,
val snapshotUrls: List<String>?,
val exampleUrls: List<String>?,
) : CommonProps {
@IgnoredOnParcel
val valid by lazy {
rules.all { r ->
r.matches.all { s -> Selector.check(s) } && (r.excludeMatches
?: emptyList()).all { s ->
Selector.check(
s
)
}
}
}
@IgnoredOnParcel
val allExampleUrls by lazy {
mutableListOf<String>().apply {
if (exampleUrls != null) {
addAll(exampleUrls)
}
rules.forEach { r ->
if (r.exampleUrls != null) {
addAll(r.exampleUrls)
}
}
}
}
}
@Serializable
data class RuleRaw(
val name: String?,
val key: Int?,
val preKeys: List<Int>?,
val action: String?,
val actionCdKey: Int?,
val actionMaximumKey: Int?,
override val actionCd: Long?,
override val actionDelay: Long?,
override val quickFind: Boolean?,
override val actionMaximum: Int?,
override val matchDelay: Long?,
override val matchTime: Long?,
override val resetMatch: String?,
override val activityIds: List<String>?,
override val excludeActivityIds: List<String>?,
val matches: List<String>,
val excludeMatches: List<String>?,
val snapshotUrls: List<String>?,
val exampleUrls: List<String>?,
) : CommonProps
companion object {
private fun getStringIArray(json: JsonObject? = null, name: String): List<String>? {
return when (val element = json?.get(name)) {
JsonNull, null -> null
is JsonObject -> error("Element ${this::class} can not be object")
is JsonArray -> element.map {
when (it) {
is JsonObject, is JsonArray, JsonNull -> error("Element ${this::class} is not a int")
is JsonPrimitive -> it.content
}
}
is JsonPrimitive -> listOf(element.content)
}
}
@Suppress("SameParameterValue")
private fun getIntIArray(json: JsonObject? = null, name: String): List<Int>? {
return when (val element = json?.get(name)) {
JsonNull, null -> null
is JsonArray -> element.map {
when (it) {
is JsonObject, is JsonArray, JsonNull -> error("Element $it is not a int")
is JsonPrimitive -> it.int
}
}
is JsonPrimitive -> listOf(element.int)
else -> error("Element $element is not a Array")
}
}
private fun getString(json: JsonObject? = null, key: String): String? =
when (val p = json?.get(key)) {
JsonNull, null -> null
is JsonPrimitive -> {
if (p.isString) {
p.content
} else {
error("Element $p is not a string")
}
}
else -> error("Element $p is not a string")
}
private fun getLong(json: JsonObject? = null, key: String): Long? =
when (val p = json?.get(key)) {
JsonNull, null -> null
is JsonPrimitive -> {
p.long
}
else -> error("Element $p is not a long")
}
private fun getInt(json: JsonObject? = null, key: String): Int? =
when (val p = json?.get(key)) {
JsonNull, null -> null
is JsonPrimitive -> {
p.int
}
else -> error("Element $p is not a int")
}
@Suppress("SameParameterValue")
private fun getBoolean(json: JsonObject? = null, key: String): Boolean? =
when (val p = json?.get(key)) {
JsonNull, null -> null
is JsonPrimitive -> {
p.boolean
}
else -> error("Element $p is not a boolean")
}
private fun jsonToRuleRaw(rulesRawJson: JsonElement): RuleRaw {
val rulesJson = when (rulesRawJson) {
JsonNull -> error("miss current rule")
is JsonObject -> rulesRawJson
is JsonPrimitive, is JsonArray -> JsonObject(mapOf("matches" to rulesRawJson))
}
return RuleRaw(
activityIds = getStringIArray(rulesJson, "activityIds"),
excludeActivityIds = getStringIArray(rulesJson, "excludeActivityIds"),
actionCd = getLong(rulesJson, "actionCd") ?: getLong(rulesJson, "cd"),
actionDelay = getLong(rulesJson, "actionDelay") ?: getLong(rulesJson, "delay"),
matches = (getStringIArray(
rulesJson, "matches"
) ?: emptyList()),
excludeMatches = getStringIArray(rulesJson, "excludeMatches"),
key = getInt(rulesJson, "key"),
name = getString(rulesJson, "name"),
preKeys = getIntIArray(rulesJson, "preKeys"),
action = getString(rulesJson, "action"),
quickFind = getBoolean(rulesJson, "quickFind"),
actionMaximum = getInt(rulesJson, "actionMaximum"),
matchDelay = getLong(rulesJson, "matchDelay"),
matchTime = getLong(rulesJson, "matchTime"),
resetMatch = getString(rulesJson, "resetMatch"),
snapshotUrls = getStringIArray(rulesJson, "snapshotUrls"),
exampleUrls = getStringIArray(rulesJson, "exampleUrls"),
actionMaximumKey = getInt(rulesJson, "actionMaximumKey"),
actionCdKey = getInt(rulesJson, "actionCdKey"),
)
}
private fun jsonToGroupRaw(groupsRawJson: JsonElement, groupIndex: Int): GroupRaw {
val groupsJson = when (groupsRawJson) {
JsonNull -> error("")
is JsonObject -> groupsRawJson
is JsonPrimitive, is JsonArray -> JsonObject(mapOf("rules" to groupsRawJson))
}
return GroupRaw(
activityIds = getStringIArray(groupsJson, "activityIds"),
excludeActivityIds = getStringIArray(groupsJson, "excludeActivityIds"),
actionCd = getLong(groupsJson, "actionCd") ?: getLong(groupsJson, "cd"),
actionDelay = getLong(groupsJson, "actionDelay") ?: getLong(groupsJson, "delay"),
name = getString(groupsJson, "name") ?: error("miss group name"),
desc = getString(groupsJson, "desc"),
enable = getBoolean(groupsJson, "enable"),
key = getInt(groupsJson, "key") ?: groupIndex,
rules = when (val rulesJson = groupsJson["rules"]) {
null, JsonNull -> emptyList()
is JsonPrimitive, is JsonObject -> JsonArray(listOf(rulesJson))
is JsonArray -> rulesJson
}.map {
jsonToRuleRaw(it)
},
quickFind = getBoolean(groupsJson, "quickFind"),
actionMaximum = getInt(groupsJson, "actionMaximum"),
matchDelay = getLong(groupsJson, "matchDelay"),
matchTime = getLong(groupsJson, "matchTime"),
resetMatch = getString(groupsJson, "resetMatch"),
snapshotUrls = getStringIArray(groupsJson, "snapshotUrls"),
exampleUrls = getStringIArray(groupsJson, "exampleUrls"),
)
}
private fun jsonToAppRaw(appsJson: JsonObject, appIndex: Int): AppRaw {
return AppRaw(
activityIds = getStringIArray(appsJson, "activityIds"),
excludeActivityIds = getStringIArray(appsJson, "excludeActivityIds"),
actionCd = getLong(appsJson, "actionCd") ?: getLong(appsJson, "cd"),
actionDelay = getLong(appsJson, "actionDelay") ?: getLong(appsJson, "delay"),
id = getString(appsJson, "id") ?: error("miss subscription.apps[$appIndex].id"),
name = getString(appsJson, "name"),
groups = (when (val groupsJson = appsJson["groups"]) {
null, JsonNull -> emptyList()
is JsonPrimitive, is JsonObject -> JsonArray(listOf(groupsJson))
is JsonArray -> groupsJson
}).mapIndexed { index, jsonElement ->
jsonToGroupRaw(jsonElement, index)
},
quickFind = getBoolean(appsJson, "quickFind"),
actionMaximum = getInt(appsJson, "actionMaximum"),
matchDelay = getLong(appsJson, "matchDelay"),
matchTime = getLong(appsJson, "matchTime"),
resetMatch = getString(appsJson, "resetMatch")
)
}
private fun jsonToSubscriptionRaw(rootJson: JsonObject): SubscriptionRaw {
return SubscriptionRaw(id = getLong(rootJson, "id") ?: error("miss subscription.id"),
name = getString(rootJson, "name") ?: error("miss subscription.name"),
version = getInt(rootJson, "version") ?: error("miss subscription.version"),
author = getString(rootJson, "author"),
updateUrl = getString(rootJson, "updateUrl"),
supportUri = getString(rootJson, "supportUri"),
checkUpdateUrl = getString(rootJson, "checkUpdateUrl"),
apps = rootJson["apps"]?.jsonArray?.mapIndexed { index, jsonElement ->
jsonToAppRaw(
jsonElement.jsonObject, index
)
} ?: emptyList(),
categories = rootJson["categories"]?.jsonArray?.mapIndexed { index, jsonElement ->
Category(
key = getInt(jsonElement.jsonObject, "key")
?: error("miss categories[$index].key"),
name = getString(jsonElement.jsonObject, "name")
?: error("miss categories[$index].name"),
enable = getBoolean(jsonElement.jsonObject, "enable"),
)
} ?: emptyList()
)
}
// 订阅文件状态: 文件不存在, 文件正常, 文件损坏(损坏原因)
fun stringify(source: SubscriptionRaw) = json.encodeToString(source)
fun parse(source: String, json5: Boolean = true): SubscriptionRaw {
val text = if (json5) Jankson.builder().build().load(source).toJson() else source
val obj = jsonToSubscriptionRaw(json.parseToJsonElement(text).jsonObject)
// 校验 category 不重复
obj.categories.forEach { c ->
if (obj.categories.find { c2 -> c2 !== c && c2.key == c.key } != null) {
error("duplicated category: key:${c.key} ")
}
}
// 校验 appId 不重复
val duplicatedApps = obj.apps.groupingBy { it }.eachCount().filter { it.value > 1 }.keys
if (duplicatedApps.isNotEmpty()) {
error("duplicated app: ${duplicatedApps.map { it.id }}")
}
obj.apps.forEach { appRaw ->
// 校验 group key 不重复
val duplicatedGroups =
appRaw.groups.groupingBy { it }.eachCount().filter { it.value > 1 }.keys
if (duplicatedGroups.isNotEmpty()) {
error("app:${appRaw.id}, duplicated group: ${duplicatedGroups.map { it.key }}")
}
// 校验 rule key 不重复
appRaw.groups.forEach { groupRaw ->
val duplicatedRules =
groupRaw.rules.mapNotNull { r -> r.key }.groupingBy { it }.eachCount()
.filter { it.value > 1 }.keys
if (duplicatedRules.isNotEmpty()) {
error("app:${appRaw.id}, group:${groupRaw.key}, duplicated rule: $duplicatedRules")
}
}
}
return obj
}
fun parseAppRaw(source: String, json5: Boolean = true): AppRaw {
val text = if (json5) Jankson.builder().build().load(source).toJson() else source
return jsonToAppRaw(json.parseToJsonElement(text).jsonObject, 0)
}
fun parseGroupRaw(source: String, json5: Boolean = true): GroupRaw {
val text = if (json5) Jankson.builder().build().load(source).toJson() else source
return jsonToGroupRaw(json.parseToJsonElement(text).jsonObject, 0)
}
}
}

View File

@ -0,0 +1,3 @@
package li.songe.gkd.data
class Value<T>(var value: T)

View File

@ -10,9 +10,13 @@ import li.songe.gkd.data.SubsConfig
import li.songe.gkd.data.SubsItem
@Database(
version = 3,
version = 4,
entities = [SubsItem::class, Snapshot::class, SubsConfig::class, ClickLog::class, CategoryConfig::class],
autoMigrations = [AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3)]
autoMigrations = [
AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3),
AutoMigration(from = 3, to = 4),
]
)
abstract class AppDb : RoomDatabase() {
abstract fun subsItemDao(): SubsItem.SubsItemDao

View File

@ -12,12 +12,13 @@ import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.withTimeout
import li.songe.gkd.app
import li.songe.gkd.appScope
import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.SubsItem
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.util.DEFAULT_SUBS_UPDATE_URL
import li.songe.gkd.util.client
import li.songe.gkd.util.dbFolder
import li.songe.gkd.util.launchTry
import li.songe.gkd.util.updateSubscription
import java.io.File
object DbSet {
@ -50,7 +51,7 @@ object DbSet {
subsItemDao.insert(defaultSubsItem)
val newSubsRaw = try {
withTimeout(3000) {
SubscriptionRaw.parse(
RawSubscription.parse(
client.get(defaultSubsItem.updateUrl!!).bodyAsText()
)
}
@ -59,7 +60,7 @@ object DbSet {
LogUtils.d(e)
return@launchTry
}
defaultSubsItem.subsFile.writeText(SubscriptionRaw.stringify(newSubsRaw))
updateSubscription(newSubsRaw)
subsItemDao.update(defaultSubsItem.copy(mtime = System.currentTimeMillis()))
}
}

View File

@ -9,11 +9,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.blankj.utilcode.util.ServiceUtils
import com.blankj.utilcode.util.ToastUtils
import com.torrydo.floatingbubbleview.FloatingBubbleListener
import com.torrydo.floatingbubbleview.service.expandable.BubbleBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import li.songe.gkd.app
import li.songe.gkd.appScope
import li.songe.gkd.composition.CompositionExt.useLifeCycleLog
@ -74,6 +74,11 @@ class FloatingService : CompositionFbService({
})
resolve(builder)
}
isRunning.value = true
onDestroy {
isRunning.value = false
}
}) {
override fun onCreate() {
@ -86,11 +91,9 @@ class FloatingService : CompositionFbService({
}
companion object {
fun isRunning() = ServiceUtils.isServiceRunning(FloatingService::class.java)
val isRunning = MutableStateFlow(false)
fun stop(context: Context = app) {
if (isRunning()) {
context.stopService(Intent(context, FloatingService::class.java))
}
context.stopService(Intent(context, FloatingService::class.java))
}
}
}

View File

@ -3,7 +3,7 @@ package li.songe.gkd.debug
import android.content.Context
import android.content.Intent
import com.blankj.utilcode.util.LogUtils
import com.blankj.utilcode.util.ServiceUtils
import com.blankj.utilcode.util.ToastUtils
import io.ktor.http.ContentType
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.call
@ -33,9 +33,9 @@ import li.songe.gkd.appScope
import li.songe.gkd.composition.CompositionService
import li.songe.gkd.data.DeviceInfo
import li.songe.gkd.data.GkdAction
import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.RpcError
import li.songe.gkd.data.SubsItem
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.db.DbSet
import li.songe.gkd.debug.SnapshotExt.captureSnapshot
import li.songe.gkd.notif.createNotif
@ -49,6 +49,7 @@ import li.songe.gkd.util.launchTry
import li.songe.gkd.util.map
import li.songe.gkd.util.storeFlow
import li.songe.gkd.util.subsItemsFlow
import li.songe.gkd.util.updateSubscription
import java.io.File
@ -56,14 +57,13 @@ class HttpService : CompositionService({
val context = this
val scope = CoroutineScope(Dispatchers.IO)
val httpSubsItem = SubsItem(
id = -1L,
order = -1,
enableUpdate = false,
)
val httpSubsRawFlow = MutableStateFlow<SubscriptionRaw?>(null)
val httpSubsRawFlow = MutableStateFlow<RawSubscription?>(null)
fun createServer(port: Int): CIOApplicationEngine {
return embeddedServer(CIO, port) {
install(KtorCorsPlugin)
@ -106,8 +106,8 @@ class HttpService : CompositionService({
val subsStr =
"""{"name":"内存订阅","id":-1,"version":0,"author":"@gkd-kit/inspect","apps":${call.receiveText()}}"""
try {
val httpSubsRaw = SubscriptionRaw.parse(subsStr)
httpSubsItem.subsFile.writeText(SubscriptionRaw.stringify(httpSubsRaw))
val httpSubsRaw = RawSubscription.parse(subsStr)
updateSubscription(httpSubsRaw)
DbSet.subsItemDao.insert((subsItemsFlow.value.find { s -> s.id == httpSubsItem.id }
?: httpSubsItem).copy(mtime = System.currentTimeMillis()))
} catch (e: Exception) {
@ -116,7 +116,7 @@ class HttpService : CompositionService({
call.respond(RpcOk())
}
post("/execSelector") {
if (!GkdAbService.isRunning()) {
if (!GkdAbService.isRunning.value) {
throw RpcError("无障碍没有运行")
}
val gkdAction = call.receive<GkdAction>()
@ -132,7 +132,17 @@ class HttpService : CompositionService({
scope.launchTry(Dispatchers.IO) {
storeFlow.map(scope) { s -> s.httpServerPort }.collect { port ->
server?.stop()
server = createServer(port).apply { start() }
server = try {
createServer(port).apply { start() }
} catch (e: Exception) {
LogUtils.d("HTTP服务启动失败", e)
null
}
if (server == null) {
ToastUtils.showShort("HTTP服务启动失败,您可以尝试切换端口后重新启动")
stopSelf()
return@collect
}
createNotif(
context, httpChannel.id, httpNotif.copy(text = "HTTP服务正在运行-端口$port")
)
@ -153,14 +163,16 @@ class HttpService : CompositionService({
scope.cancel()
}
}
isRunning.value = true
onDestroy {
isRunning.value = false
}
}) {
companion object {
fun isRunning() = ServiceUtils.isServiceRunning(HttpService::class.java)
val isRunning = MutableStateFlow(false)
fun stop(context: Context = app) {
if (isRunning()) {
context.stopService(Intent(context, HttpService::class.java))
}
context.stopService(Intent(context, HttpService::class.java))
}
fun start(context: Context = app) {
@ -177,7 +189,7 @@ data class RpcOk(
fun clearHttpSubs() {
// 如果 app 被直接在任务列表划掉, HTTP订阅会没有清除, 所以在后续的第一次启动时清除
if (HttpService.isRunning()) return
if (HttpService.isRunning.value) return
appScope.launchTry(Dispatchers.IO) {
delay(1000)
if (storeFlow.value.autoClearMemorySubs) {

View File

@ -5,7 +5,7 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import com.blankj.utilcode.util.LogUtils
import com.blankj.utilcode.util.ServiceUtils
import kotlinx.coroutines.flow.MutableStateFlow
import li.songe.gkd.app
import li.songe.gkd.composition.CompositionExt.useLifeCycleLog
import li.songe.gkd.composition.CompositionService
@ -28,6 +28,11 @@ class ScreenshotService : CompositionService({
screenshotUtil?.destroy()
screenshotUtil = null
}
isRunning.value = true
onDestroy {
isRunning.value = false
}
}) {
companion object {
suspend fun screenshot() = screenshotUtil?.execute()
@ -40,11 +45,9 @@ class ScreenshotService : CompositionService({
context.startForegroundService(intent)
}
fun isRunning() = ServiceUtils.isServiceRunning(ScreenshotService::class.java)
val isRunning = MutableStateFlow(false)
fun stop(context: Context = app) {
if (isRunning()) {
context.stopService(Intent(context, ScreenshotService::class.java))
}
context.stopService(Intent(context, ScreenshotService::class.java))
}
}
}

View File

@ -14,7 +14,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.encodeToString
import li.songe.gkd.app
import li.songe.gkd.data.ComplexSnapshot
import li.songe.gkd.data.RpcError
import li.songe.gkd.data.createComplexSnapshot
@ -22,18 +21,16 @@ import li.songe.gkd.data.toSnapshot
import li.songe.gkd.db.DbSet
import li.songe.gkd.service.GkdAbService
import li.songe.gkd.util.keepNullJson
import li.songe.gkd.util.snapshotFolder
import li.songe.gkd.util.snapshotZipDir
import li.songe.gkd.util.storeFlow
import java.io.File
import kotlin.math.min
object SnapshotExt {
val snapshotDir by lazy {
app.getExternalFilesDir("snapshot")!!.apply { if (!exists()) mkdir() }
}
private fun getSnapshotParentPath(snapshotId: Long) =
"${snapshotDir.absolutePath}/${snapshotId}"
"${snapshotFolder.absolutePath}/${snapshotId}"
fun getSnapshotPath(snapshotId: Long) =
"${getSnapshotParentPath(snapshotId)}/${snapshotId}.json"
@ -67,7 +64,7 @@ object SnapshotExt {
private val captureLoading = MutableStateFlow(false)
suspend fun captureSnapshot(skipScreenshot: Boolean = false): ComplexSnapshot {
if (!GkdAbService.isRunning()) {
if (!GkdAbService.isRunning.value) {
throw RpcError("无障碍不可用")
}
if (captureLoading.value) {
@ -89,7 +86,7 @@ object SnapshotExt {
)
} else {
GkdAbService.currentScreenshot() ?: withTimeoutOrNull(3_000) {
if (!ScreenshotService.isRunning()) {
if (!ScreenshotService.isRunning.value) {
return@withTimeoutOrNull null
}
ScreenshotService.screenshot()

View File

@ -19,7 +19,7 @@ val abNotif by lazy {
title = "GKD",
text = "无障碍正在运行",
ongoing = true,
autoCancel = false
autoCancel = false,
)
}
val screenshotNotif by lazy {
@ -29,7 +29,7 @@ val screenshotNotif by lazy {
title = "GKD",
text = "截屏服务正在运行",
ongoing = true,
autoCancel = false
autoCancel = false,
)
}
@ -40,9 +40,10 @@ val floatingNotif by lazy {
title = "GKD",
text = "悬浮窗按钮正在显示",
ongoing = true,
autoCancel = false
autoCancel = false,
)
}
val httpNotif by lazy {
Notif(
id = 103,
@ -50,6 +51,6 @@ val httpNotif by lazy {
title = "GKD",
text = "HTTP服务正在运行",
ongoing = true,
autoCancel = false
autoCancel = false,
)
}

View File

@ -13,7 +13,7 @@ import androidx.core.app.NotificationManagerCompat
import li.songe.gkd.MainActivity
fun createChannel(context: Context, notifChannel: NotifChannel) {
val importance = NotificationManager.IMPORTANCE_DEFAULT
val importance = NotificationManager.IMPORTANCE_LOW
val channel = NotificationChannel(notifChannel.id, notifChannel.name, importance)
channel.description = notifChannel.desc
val notificationManager = NotificationManagerCompat.from(context)
@ -24,8 +24,8 @@ fun createNotif(context: Service, channelId: String, notif: Notif) {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
val pendingIntent = PendingIntent.getActivity(
context, notif.id, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val builder = NotificationCompat.Builder(context, channelId).setSmallIcon(notif.icon)

View File

@ -12,6 +12,7 @@ val AccessibilityService.safeActiveWindow: AccessibilityNodeInfo?
get() = try {
// java.lang.SecurityException: Call from user 0 as user -2 without permission INTERACT_ACROSS_USERS or INTERACT_ACROSS_USERS_FULL not allowed.
rootInActiveWindow
// 在主线程调用会阻塞界面导致卡顿
} catch (e: Exception) {
e.printStackTrace()
null
@ -132,9 +133,10 @@ private val getAttr: (AccessibilityNodeInfo, String) -> Any? = { node, name ->
"desc.length" -> node.contentDescription?.length
"clickable" -> node.isClickable
"focusable" -> node.isFocusable
"checkable" -> node.isCheckable
"checked" -> node.isChecked
"focusable" -> node.isFocusable
"editable" -> node.isEditable
"longClickable" -> node.isLongClickable
"visibleToUser" -> node.isVisibleToUser

View File

@ -3,38 +3,43 @@ package li.songe.gkd.service
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import li.songe.gkd.appScope
import li.songe.gkd.data.AppRule
import li.songe.gkd.data.ClickLog
import li.songe.gkd.data.Rule
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.appRuleFlow
import li.songe.gkd.util.AllRules
import li.songe.gkd.util.allRulesFlow
import li.songe.gkd.util.increaseClickCount
import li.songe.gkd.util.launchTry
import li.songe.gkd.util.recordStoreFlow
import li.songe.gkd.util.storeFlow
data class TopActivity(
val appId: String,
val appId: String = "",
val activityId: String? = null,
val number: Int = 0
)
val topActivityFlow by lazy {
MutableStateFlow<TopActivity?>(null)
}
val topActivityFlow = MutableStateFlow(TopActivity())
data class ActivityRule(
val rules: List<Rule> = emptyList(),
val topActivity: TopActivity? = null,
val appIdToRules: Map<String, List<Rule>> = emptyMap(),
)
private val appRules: List<AppRule> = emptyList(),
private val globalRules: List<GlobalRule> = emptyList(),
val topActivity: TopActivity = TopActivity(),
val allRules: AllRules = AllRules(),
) {
val currentRules = appRules + globalRules
}
val activityRuleFlow by lazy { MutableStateFlow(ActivityRule()) }
private var lastTopActivity: TopActivity? = null
private var lastTopActivity: TopActivity = topActivityFlow.value
private fun getFixTopActivity(): TopActivity? {
val top = topActivityFlow.value ?: return null
private fun getFixTopActivity(): TopActivity {
val top = topActivityFlow.value
if (top.activityId == null) {
if (lastTopActivity?.appId == top.appId) {
if (lastTopActivity.appId == top.appId) {
// 当从通知栏上拉返回应用等时, activityId 的无障碍事件不会触发, 此时复用上一次获得的 activityId 填充
topActivityFlow.value = lastTopActivity
}
@ -47,121 +52,71 @@ private fun getFixTopActivity(): TopActivity? {
fun getCurrentRules(): ActivityRule {
val topActivity = getFixTopActivity()
val activityRule = activityRuleFlow.value
val appIdToRules = if (storeFlow.value.matchUnknownApp) {
appRuleFlow.value.allMap
} else {
appRuleFlow.value.visibleMap
}
val idChanged = topActivity?.appId != activityRule.topActivity?.appId
val topChanged = activityRule.topActivity != topActivity
if (topChanged || activityRule.appIdToRules !== appIdToRules) {
activityRuleFlow.value =
ActivityRule(rules = (appIdToRules[topActivity?.appId] ?: emptyList()).filter { rule ->
rule.matchActivityId(topActivity?.activityId)
}, topActivity = topActivity, appIdToRules = appIdToRules)
}
if (topChanged) {
val t = System.currentTimeMillis()
if (idChanged) {
appChangeTime = t
openAdOptimized = null
}
activityChangeTime = t
if (openAdOptimized == null && t - appChangeTime < openAdOptimizedTime) {
openAdOptimized = activityRuleFlow.value.rules.any { r -> r.isOpenAd }
val oldActivityRule = activityRuleFlow.value
val allRules = allRulesFlow.value
val idChanged = topActivity.appId != oldActivityRule.topActivity.appId
val topChanged = idChanged || oldActivityRule.topActivity != topActivity
if (topChanged || oldActivityRule.allRules !== allRules) {
val newActivityRule = ActivityRule(
allRules = allRules,
topActivity = topActivity,
appRules = (allRules.appIdToRules[topActivity.appId]
?: emptyList()).filter { rule ->
rule.matchActivity(topActivity)
},
globalRules = allRulesFlow.value.globalRules.filter { r ->
r.matchActivity(
topActivity
)
},
)
activityRuleFlow.value = newActivityRule
if (newActivityRule.currentRules.isNotEmpty()) {
val newRules =
newActivityRule.currentRules.filter { r -> !oldActivityRule.currentRules.any { r2 -> r2 == r } }
val t = System.currentTimeMillis()
if (newRules.isNotEmpty()) {
newRules.forEach { r ->
r.matchChangedTime = t
}
}
newActivityRule.currentRules.forEach { r ->
if (r.resetMatchTypeWhenActivity || idChanged) {
r.actionDelayTriggerTime = 0
r.actionCount.value = 0
}
if (idChanged) {
// 重置全局规则的匹配时间
r.matchChangedTime = t
}
}
}
}
if (idChanged) {
appChangeTime = System.currentTimeMillis()
}
return activityRuleFlow.value
}
var lastTriggerRule: Rule? = null
var lastTriggerAppRule: ResolvedRule? = null
var appChangeTime = 0L
var activityChangeTime = 0L
// null: app 切换过
// true: 需要执行优化(此界面组需要存在开屏广告)
// false: 执行优化过了/已经过了优化时间
@Volatile
var openAdOptimized: Boolean? = null
const val openAdOptimizedTime = 5000L
fun isAvailableRule(rule: Rule): Boolean {
val t = System.currentTimeMillis()
if (openAdOptimized == true) {
if (t - appChangeTime > openAdOptimizedTime) {
openAdOptimized = false
}
}
if (rule.resetMatchTypeWhenActivity) {
if (activityChangeTime != rule.matchChangeTime) {
// 当 界面 更新时, 重置操作延迟点, 重置点击次数
rule.actionDelayTriggerTime = 0
rule.actionCount.value = 0
rule.matchChangeTime = activityChangeTime
}
} else {
if (appChangeTime != rule.matchChangeTime) {
// 当 切换APP 时, 重置点击次数
rule.actionDelayTriggerTime = 0
rule.actionCount.value = 0
rule.matchChangeTime = appChangeTime
}
}
if (rule.actionMaximum != null) {
if (rule.actionCount.value >= rule.actionMaximum) {
return false // 达到最大执行次数
}
}
if (rule.matchDelay != null) {
// 处于匹配延迟中
if (rule.resetMatchTypeWhenActivity) {
if (t - activityChangeTime < rule.matchDelay) {
return false
}
} else {
if (t - appChangeTime < rule.matchDelay) {
return false
}
}
}
if (rule.matchTime != null) {
// 超出了匹配时间
if (rule.resetMatchTypeWhenActivity) {
if (t - activityChangeTime > rule.matchAllTime) {
return false
}
} else {
if (t - appChangeTime > rule.matchAllTime) {
return false
}
}
}
if (rule.actionTriggerTime.value + rule.actionCd > t) return false // 处于冷却时间
if (rule.preRules.isNotEmpty()) { // 需要提前点击某个规则
lastTriggerRule ?: return false
// 上一个点击的规则不在当前需要点击的列表
return rule.preRules.any { it === lastTriggerRule }
}
if (rule.actionDelayTriggerTime > 0) {
if (rule.actionDelayTriggerTime + rule.actionDelay > t) {
return false // 没有延迟完毕
}
}
return true
}
fun insertClickLog(rule: Rule) {
rule.trigger()
fun insertClickLog(appRule: ResolvedRule) {
appRule.trigger()
toastClickTip()
appScope.launchTry(Dispatchers.IO) {
val clickLog = ClickLog(
appId = topActivityFlow.value?.appId,
activityId = topActivityFlow.value?.activityId,
subsId = rule.subsItem.id,
groupKey = rule.group.key,
ruleIndex = rule.index,
ruleKey = rule.key
appId = topActivityFlow.value.appId,
activityId = topActivityFlow.value.activityId,
subsId = appRule.subsItem.id,
subsVersion = appRule.rawSubs.version,
groupKey = appRule.group.key,
groupType = when (appRule) {
is AppRule -> SubsConfig.AppGroupType
is GlobalRule -> SubsConfig.GlobalGroupType
},
ruleIndex = appRule.index,
ruleKey = appRule.key,
)
DbSet.clickLogDao.insert(clickLog)
increaseClickCount()

View File

@ -1,17 +1,20 @@
package li.songe.gkd.service
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.os.Build
import android.util.Log
import android.view.Display
import android.view.View
import android.view.WindowManager
import android.view.accessibility.AccessibilityEvent
import com.blankj.utilcode.util.LogUtils
import com.blankj.utilcode.util.ServiceUtils
import com.blankj.utilcode.util.ScreenUtils
import com.blankj.utilcode.util.ToastUtils
import io.ktor.client.call.body
import io.ktor.client.request.get
@ -19,12 +22,10 @@ import io.ktor.client.statement.bodyAsText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import li.songe.gkd.composition.CompositionAbService
@ -33,43 +34,35 @@ import li.songe.gkd.composition.CompositionExt.useScope
import li.songe.gkd.data.ActionResult
import li.songe.gkd.data.GkdAction
import li.songe.gkd.data.NodeInfo
import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.RpcError
import li.songe.gkd.data.SubsVersion
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.data.getActionFc
import li.songe.gkd.db.DbSet
import li.songe.gkd.debug.SnapshotExt
import li.songe.gkd.shizuku.useSafeGetTasksFc
import li.songe.gkd.util.VOLUME_CHANGED_ACTION
import li.songe.gkd.util.client
import li.songe.gkd.util.launchTry
import li.songe.gkd.util.map
import li.songe.gkd.util.storeFlow
import li.songe.gkd.util.subsIdToRawFlow
import li.songe.gkd.util.subsItemsFlow
import li.songe.gkd.util.updateSubscription
import li.songe.selector.Selector
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
class GkdAbService : CompositionAbService({
useLifeCycleLog()
val context = this as GkdAbService
context.resources
val scope = useScope()
service = context
onDestroy {
service = null
topActivityFlow.value = null
}
ManageService.start(context)
onDestroy {
ManageService.stop(context)
}
val safeGetTasksFc = useSafeGetTasksFc(scope)
@ -85,7 +78,7 @@ class GkdAbService : CompositionAbService({
appId: String,
activityId: String,
): Boolean {
if (appId == topActivityFlow.value?.appId && activityId == topActivityFlow.value?.activityId) return true
if (appId == topActivityFlow.value.appId && activityId == topActivityFlow.value.activityId) return true
val r = (try {
packageManager.getActivityInfo(
ComponentName(
@ -95,42 +88,40 @@ class GkdAbService : CompositionAbService({
} catch (e: PackageManager.NameNotFoundException) {
null
} != null)
Log.d("isActivity", "$appId, $activityId, $r")
return r
}
var lastTriggerShizukuTime = 0L
var lastContentEventTime = 0L
var job: Job? = null
val singleThread = Dispatchers.IO.limitedParallelism(1)
val eventThread = Dispatchers.IO.limitedParallelism(1)
onDestroy {
singleThread.cancel()
}
val lastQueryTimeFlow = MutableStateFlow(0L)
val debounceTime = 500L
fun newQueryTask() {
job = scope.launchTry(singleThread) {
scope.launchTry(singleThread) {
val activityRule = getCurrentRules()
for (rule in (activityRule.rules)) {
if (!isActive && !rule.isOpenAd) break
if (!isAvailableRule(rule)) continue
for (rule in (activityRule.currentRules)) {
val statusCode = rule.statusCode
if (statusCode == 4 && rule.matchDelayJob == null) {
rule.matchDelayJob = scope.launch {
delay(rule.matchDelay)
rule.matchDelayJob = null
newQueryTask()
}
}
if (statusCode != 0) continue
val nodeVal = safeActiveWindow ?: continue
val target = rule.query(nodeVal) ?: continue
if (activityRule !== getCurrentRules()) break
// 开始 action 延迟
if (rule.actionDelay > 0 && rule.actionDelayTriggerTime == 0L) {
rule.triggerDelay()
// 防止在 actionDelay 时间内没有事件发送导致无法触发
scope.launch(singleThread) {
delay(rule.actionDelay - debounceTime)
lastQueryTimeFlow.value = System.currentTimeMillis()
if (rule.checkDelay() && rule.actionDelayJob == null) {
rule.actionDelayJob = scope.launch {
delay(rule.actionDelay)
rule.actionDelayJob = null
newQueryTask()
}
continue
}
// 如果节点在屏幕外部, click 的结果为 null
val actionResult = rule.performAction(context, target)
if (actionResult.result) {
LogUtils.d(
@ -144,27 +135,24 @@ class GkdAbService : CompositionAbService({
}
}
scope.launch(singleThread) {
lastQueryTimeFlow.debounce(debounceTime).collect {
newQueryTask()
}
}
val skipAppIds = setOf("com.android.systemui")
val skipAppIds = listOf("com.android.systemui")
onAccessibilityEvent { event ->
if (event == null || event.packageName == null) return@onAccessibilityEvent
if (!(event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED || event.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED)) return@onAccessibilityEvent
if (skipAppIds.any { id -> id.contentEquals(event.packageName) }) return@onAccessibilityEvent
if (event.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED &&
skipAppIds.any { id ->
id.contentEquals(
event.packageName
)
}
) {
return@onAccessibilityEvent
}
if (event.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) {
if (event.eventTime - appChangeTime > 5_000) { // app 启动 5s 内关闭限制
if (event.eventTime - lastContentEventTime < 100) {
if (event.eventTime - (lastTriggerRule?.actionTriggerTime?.value
?: 0) > 1000
) { // 规则刚刚触发后 1s 内关闭限制
return@onAccessibilityEvent
}
}
if (event.eventTime - lastContentEventTime < 100 && event.eventTime - appChangeTime > 5000) {
return@onAccessibilityEvent
}
lastContentEventTime = event.eventTime
}
@ -182,16 +170,17 @@ class GkdAbService : CompositionAbService({
// tv.danmaku.bili, com.miui.home, com.miui.home.launcher.Launcher
if (isActivity(evAppId, evActivityId)) {
topActivityFlow.value = TopActivity(
evAppId, evActivityId
evAppId, evActivityId, topActivityFlow.value.number + 1
)
activityChangeTime = System.currentTimeMillis()
}
} else {
if (fixedEvent.eventTime - lastTriggerShizukuTime > 300) {
val shizukuTop = getShizukuTopActivity()
if (shizukuTop != null && shizukuTop.appId == rightAppId) {
if (shizukuTop.activityId == evActivityId) {
activityChangeTime = System.currentTimeMillis()
topActivityFlow.value = TopActivity(
evAppId, evActivityId, topActivityFlow.value.number + 1
)
}
topActivityFlow.value = shizukuTop
}
@ -199,7 +188,7 @@ class GkdAbService : CompositionAbService({
}
}
}
if (rightAppId != topActivityFlow.value?.appId) {
if (rightAppId != topActivityFlow.value.appId) {
// 从 锁屏,下拉通知栏 返回等情况, 应用不会发送事件, 但是系统组件会发送事件
val shizukuTop = getShizukuTopActivity()
if (shizukuTop?.appId == rightAppId) {
@ -209,7 +198,7 @@ class GkdAbService : CompositionAbService({
}
}
if (getCurrentRules().rules.isEmpty()) {
if (getCurrentRules().currentRules.isEmpty()) {
// 放在 evAppId != rightAppId 的前面使得 TopActivity 能借助 lastTopActivity 恢复
return@launch
}
@ -218,18 +207,7 @@ class GkdAbService : CompositionAbService({
return@launch
}
if (!storeFlow.value.enableService) return@launch
val jobVal = job
if (jobVal?.isActive == true) {
if (openAdOptimized == true) {
jobVal.cancel()
} else {
return@launch
}
}
lastQueryTimeFlow.value = fixedEvent.eventTime
newQueryTask()
}
@ -254,7 +232,7 @@ class GkdAbService : CompositionAbService({
LogUtils.d("快速检测更新失败", subsItem, e)
}
}
val newSubsRaw = SubscriptionRaw.parse(
val newSubsRaw = RawSubscription.parse(
client.get(subsItem.updateUrl).bodyAsText()
)
if (newSubsRaw.id != subsItem.id) {
@ -263,11 +241,7 @@ class GkdAbService : CompositionAbService({
if (oldSubsRaw != null && newSubsRaw.version <= oldSubsRaw.version) {
return@forEach
}
subsItem.subsFile.writeText(
SubscriptionRaw.stringify(
newSubsRaw
)
)
updateSubscription(newSubsRaw)
val newItem = subsItem.copy(
updateUrl = newSubsRaw.updateUrl ?: subsItem.updateUrl,
mtime = System.currentTimeMillis()
@ -298,9 +272,9 @@ class GkdAbService : CompositionAbService({
scope.launch(Dispatchers.IO) {
activityRuleFlow.debounce(300).collect {
if (storeFlow.value.enableService) {
LogUtils.d(it.topActivity, *it.rules.map { r ->
"subsId:${r.subsItem.id}, gKey=${r.group.key}, gName:${r.group.name}, ruleIndex:${r.index}, rKey:${r.key}, active:${
isAvailableRule(r)
LogUtils.d(it.topActivity, *it.currentRules.map { r ->
"id:${r.subsItem.id}, v:${r.rawSubs.version}, gKey=${r.group.key}, gName:${r.group.name}, rIndex:${r.index}, rKey:${r.key}, rCode:${
r.statusCode
}"
}.toTypedArray())
} else {
@ -351,6 +325,52 @@ class GkdAbService : CompositionAbService({
}
}
fun createReceiver(): BroadcastReceiver {
return object : BroadcastReceiver() {
var lastTriggerTime = -1L
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == VOLUME_CHANGED_ACTION) {
val t = System.currentTimeMillis()
if (t - lastTriggerTime > 3000 && !ScreenUtils.isScreenLock()) {
lastTriggerTime = t
scope.launchTry(Dispatchers.IO) {
SnapshotExt.captureSnapshot()
ToastUtils.showShort("快照成功")
}
}
}
}
}
}
var captureVolumeReceiver: BroadcastReceiver? = null
scope.launch {
storeFlow.map(scope) { s -> s.captureVolumeChange }.collect {
if (captureVolumeReceiver != null) {
context.unregisterReceiver(captureVolumeReceiver)
}
captureVolumeReceiver = if (it) {
createReceiver().apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(
this, IntentFilter(VOLUME_CHANGED_ACTION), Context.RECEIVER_EXPORTED
)
} else {
context.registerReceiver(this, IntentFilter(VOLUME_CHANGED_ACTION))
}
}
} else {
null
}
}
}
onDestroy {
if (captureVolumeReceiver != null) {
context.unregisterReceiver(captureVolumeReceiver)
}
}
onAccessibilityEvent { e ->
if (!storeFlow.value.captureScreenshot) return@onAccessibilityEvent
e ?: return@onAccessibilityEvent
@ -368,11 +388,18 @@ class GkdAbService : CompositionAbService({
}
}
}
isRunning.value = true
onDestroy {
isRunning.value = false
}
}) {
companion object {
var service: GkdAbService? = null
fun isRunning() = ServiceUtils.isServiceRunning(GkdAbService::class.java)
val isRunning = MutableStateFlow(false)
fun execAction(gkdAction: GkdAction): ActionResult {
val serviceVal = service ?: throw RpcError("无障碍没有运行")

View File

@ -1,13 +1,8 @@
package li.songe.gkd.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import com.blankj.utilcode.util.ScreenUtils
import com.blankj.utilcode.util.ToastUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
@ -16,14 +11,11 @@ import li.songe.gkd.app
import li.songe.gkd.composition.CompositionExt.useLifeCycleLog
import li.songe.gkd.composition.CompositionExt.useScope
import li.songe.gkd.composition.CompositionService
import li.songe.gkd.debug.SnapshotExt.captureSnapshot
import li.songe.gkd.notif.abNotif
import li.songe.gkd.notif.createNotif
import li.songe.gkd.notif.defaultChannel
import li.songe.gkd.util.VOLUME_CHANGED_ACTION
import li.songe.gkd.util.appRuleFlow
import li.songe.gkd.util.allRulesFlow
import li.songe.gkd.util.clickCountFlow
import li.songe.gkd.util.launchTry
import li.songe.gkd.util.map
import li.songe.gkd.util.storeFlow
@ -32,61 +24,21 @@ class ManageService : CompositionService({
val context = this
createNotif(context, defaultChannel.id, abNotif)
val scope = useScope()
fun createReceiver(): BroadcastReceiver {
return object : BroadcastReceiver() {
var lastTriggerTime = -1L
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == VOLUME_CHANGED_ACTION) {
val t = System.currentTimeMillis()
if (t - lastTriggerTime > 3000 && !ScreenUtils.isScreenLock()) {
lastTriggerTime = t
scope.launchTry(Dispatchers.IO) {
captureSnapshot()
ToastUtils.showShort("快照成功")
}
}
}
}
}
}
var receiver: BroadcastReceiver? = null
scope.launchTry(Dispatchers.IO) {
storeFlow.map(scope) { s -> s.captureVolumeChange }.collect {
if (receiver != null) {
context.unregisterReceiver(receiver)
}
receiver = if (it) {
createReceiver().apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(
this, IntentFilter(VOLUME_CHANGED_ACTION), Context.RECEIVER_EXPORTED
)
} else {
context.registerReceiver(this, IntentFilter(VOLUME_CHANGED_ACTION))
}
}
} else {
null
}
}
}
onDestroy {
if (receiver != null) {
context.unregisterReceiver(receiver)
}
}
scope.launch {
combine(appRuleFlow, clickCountFlow, storeFlow) { appRule, clickCount, store ->
if (!store.enableService) return@combine "服务已暂停"
val appSize = appRule.visibleMap.keys.size
val groupSize = appRule.visibleMap.values.sumOf { rules ->
rules.map { r -> r.group.key }.toSet().size
}
(if (groupSize > 0) {
"${appSize}应用/${groupSize}规则组"
combine(
allRulesFlow,
clickCountFlow,
storeFlow.map(scope) { it.enableService },
GkdAbService.isRunning
) { allRules, clickCount, enableService, abRunning ->
if (!abRunning) return@combine "无障碍未授权"
if (!enableService) return@combine "服务已暂停"
(if (allRules.allGroupSize > 0) {
if (allRules.appSize > 0) {
"${allRules.appSize}应用/${allRules.allGroupSize}规则组"
} else {
"${allRules.allGroupSize}规则组"
}
} else {
"暂无规则"
}) + if (clickCount > 0) "/${clickCount}点击" else ""
@ -98,12 +50,18 @@ class ManageService : CompositionService({
)
}
}
isRunning.value = true
onDestroy {
isRunning.value = false
}
}) {
companion object {
fun start(context: Context = app) {
context.startForegroundService(Intent(context, ManageService::class.java))
}
val isRunning = MutableStateFlow(false)
fun stop(context: Context = app) {
context.stopService(Intent(context, ManageService::class.java))
}

View File

@ -54,9 +54,8 @@ import com.blankj.utilcode.util.ToastUtils
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import kotlinx.coroutines.Dispatchers
import kotlinx.serialization.encodeToString
import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.SubsConfig
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.db.DbSet
import li.songe.gkd.ui.destinations.GroupItemPageDestination
import li.songe.gkd.util.LocalNavController
@ -68,6 +67,7 @@ import li.songe.gkd.util.json
import li.songe.gkd.util.launchAsFn
import li.songe.gkd.util.launchTry
import li.songe.gkd.util.navigate
import li.songe.gkd.util.updateSubscription
@RootNavGraph
@Destination(style = ProfileTransitions::class)
@ -92,7 +92,7 @@ fun AppItemPage(
val groupToCategoryMap = subsRaw?.groupToCategoryMap ?: emptyMap()
val (showGroupItem, setShowGroupItem) = remember {
mutableStateOf<SubscriptionRaw.GroupRaw?>(
mutableStateOf<RawSubscription.RawAppGroup?>(
null
)
}
@ -102,10 +102,10 @@ fun AppItemPage(
var showAddDlg by remember { mutableStateOf(false) }
val (menuGroupRaw, setMenuGroupRaw) = remember {
mutableStateOf<SubscriptionRaw.GroupRaw?>(null)
mutableStateOf<RawSubscription.RawAppGroup?>(null)
}
val (editGroupRaw, setEditGroupRaw) = remember {
mutableStateOf<SubscriptionRaw.GroupRaw?>(null)
mutableStateOf<RawSubscription.RawAppGroup?>(null)
}
Scaffold(topBar = {
@ -173,7 +173,7 @@ fun AppItemPage(
)
if (group.valid) {
Text(
text = group.desc ?: "-",
text = group.desc ?: "",
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
@ -213,7 +213,7 @@ fun AppItemPage(
Switch(checked = groupEnable, modifier = Modifier,
onCheckedChange = scope.launchAsFn { enable ->
val newItem = (subsConfig?.copy(enable = enable) ?: SubsConfig(
type = SubsConfig.GroupType,
type = SubsConfig.AppGroupType,
subsItemId = subsItemId,
appId = appId,
groupKey = group.key,
@ -244,7 +244,7 @@ fun AppItemPage(
Text(text = "该规则组默认不启用")
Spacer(modifier = Modifier.height(10.dp))
}
Text(text = showGroupItemVal.desc ?: "-")
Text(text = showGroupItemVal.desc ?: "")
}
},
confirmButton = {
@ -255,8 +255,8 @@ fun AppItemPage(
navController.navigate(
GroupItemPageDestination(
subsInt = subsItemId,
groupKey = showGroupItemVal.key,
appId = appId,
groupKey = showGroupItemVal.key
)
)
}) {
@ -307,11 +307,7 @@ fun AppItemPage(
appRawVal.copy(groups = appRawVal.groups.filter { g -> g.key != menuGroupRaw.key })
)
})
subsItemVal.subsFile.writeText(
json.encodeToString(
newSubsRaw
)
)
updateSubscription(newSubsRaw)
DbSet.subsItemDao.update(subsItemVal.copy(mtime = System.currentTimeMillis()))
DbSet.subsConfigDao.delete(
subsItemVal.id, appRawVal.id, menuGroupRaw.key
@ -356,7 +352,7 @@ fun AppItemPage(
return@TextButton
}
val newGroupRaw = try {
SubscriptionRaw.parseGroupRaw(source)
RawSubscription.parseRawGroup(source)
} catch (e: Exception) {
LogUtils.d(e)
ToastUtils.showShort("非法规则:${e.message}")
@ -383,11 +379,7 @@ fun AppItemPage(
)
})
vm.viewModelScope.launchTry(Dispatchers.IO) {
subsItemVal.subsFile.writeText(
json.encodeToString(
newSubsRaw
)
)
updateSubscription(newSubsRaw)
DbSet.subsItemDao.update(subsItemVal.copy(mtime = System.currentTimeMillis()))
ToastUtils.showShort("更新成功")
}
@ -413,13 +405,13 @@ fun AppItemPage(
}, onDismissRequest = { showAddDlg = false }, confirmButton = {
TextButton(onClick = {
val newAppRaw = try {
SubscriptionRaw.parseAppRaw(source)
RawSubscription.parseRawApp(source)
} catch (_: Exception) {
null
}
val tempGroups = if (newAppRaw == null) {
val newGroupRaw = try {
SubscriptionRaw.parseGroupRaw(source)
RawSubscription.parseRawGroup(source)
} catch (e: Exception) {
LogUtils.d(e)
ToastUtils.showShort("非法规则:${e.message}")
@ -460,8 +452,8 @@ fun AppItemPage(
)
})
vm.viewModelScope.launchTry(Dispatchers.IO) {
subsItemVal.subsFile.writeText(json.encodeToString(newSubsRaw))
DbSet.subsItemDao.update(subsItemVal.copy(mtime = System.currentTimeMillis()))
updateSubscription(newSubsRaw)
showAddDlg = false
ToastUtils.showShort("添加成功")
}

View File

@ -24,7 +24,7 @@ class AppItemVm @Inject constructor(stateHandle: SavedStateHandle) : ViewModel()
val subsRawFlow = subsIdToRawFlow.map(viewModelScope) { s -> s[args.subsItemId] }
val subsConfigsFlow = DbSet.subsConfigDao.queryGroupTypeConfig(args.subsItemId, args.appId)
val subsConfigsFlow = DbSet.subsConfigDao.queryAppGroupTypeConfig(args.subsItemId, args.appId)
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val categoryConfigsFlow = DbSet.categoryConfigDao.queryConfig(args.subsItemId)

View File

@ -47,14 +47,13 @@ import com.blankj.utilcode.util.ToastUtils
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import kotlinx.coroutines.Dispatchers
import kotlinx.serialization.encodeToString
import li.songe.gkd.data.CategoryConfig
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.data.RawSubscription
import li.songe.gkd.db.DbSet
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.ProfileTransitions
import li.songe.gkd.util.json
import li.songe.gkd.util.launchTry
import li.songe.gkd.util.updateSubscription
@RootNavGraph
@Destination(style = ProfileTransitions::class)
@ -71,17 +70,17 @@ fun CategoryPage(subsItemId: Long) {
mutableStateOf(false)
}
val (menuCategory, setMenuCategory) = remember {
mutableStateOf<SubscriptionRaw.Category?>(null)
mutableStateOf<RawSubscription.RawCategory?>(null)
}
var editEnableCategory by remember {
mutableStateOf<SubscriptionRaw.Category?>(null)
mutableStateOf<RawSubscription.RawCategory?>(null)
}
val (editNameCategory, setEditNameCategory) = remember {
mutableStateOf<SubscriptionRaw.Category?>(null)
mutableStateOf<RawSubscription.RawCategory?>(null)
}
val categories = subsRaw?.categories ?: emptyList()
val categoriesGroups = subsRaw?.categoriesGroups ?: emptyMap()
val categoriesGroups = subsRaw?.categoryToGroupsMap ?: emptyMap()
Scaffold(topBar = {
TopAppBar(navigationIcon = {
@ -93,7 +92,7 @@ fun CategoryPage(subsItemId: Long) {
contentDescription = null,
)
}
}, title = { Text(text = subsRaw?.name ?: subsItemId.toString()) }, actions = {})
}, title = { Text(text = "${subsRaw?.name ?: subsItemId}/规则类别") }, actions = {})
}, floatingActionButton = {
if (editable) {
FloatingActionButton(onClick = { showAddDlg = true }) {
@ -158,7 +157,7 @@ fun CategoryPage(subsItemId: Long) {
if (categories.isEmpty()) {
Spacer(modifier = Modifier.height(40.dp))
Text(
text = "此订阅暂无类别",
text = "暂无类别",
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
@ -250,7 +249,7 @@ fun CategoryPage(subsItemId: Long) {
}
vm.viewModelScope.launchTry(Dispatchers.IO) {
subsItem?.apply {
subsFile.writeText(json.encodeToString(subsRawVal.copy(
updateSubscription(subsRawVal.copy(
categories = categories.toMutableList().apply {
val i =
categories.indexOfFirst { c -> c.key == editNameCategory.key }
@ -258,7 +257,7 @@ fun CategoryPage(subsItemId: Long) {
set(i, editNameCategory.copy(name = source))
}
}
)))
))
DbSet.subsItemDao.update(copy(mtime = System.currentTimeMillis()))
}
ToastUtils.showShort("修改成功")
@ -300,15 +299,15 @@ fun CategoryPage(subsItemId: Long) {
showAddDlg = false
vm.viewModelScope.launchTry(Dispatchers.IO) {
subsItem?.apply {
subsFile.writeText(json.encodeToString(subsRawVal.copy(
updateSubscription(subsRawVal.copy(
categories = categories.toMutableList().apply {
add(SubscriptionRaw.Category(
add(RawSubscription.RawCategory(
key = (categories.maxOfOrNull { c -> c.key } ?: -1) + 1,
name = source,
enable = null
))
}
)))
))
DbSet.subsItemDao.update(copy(mtime = System.currentTimeMillis()))
ToastUtils.showShort("添加成功")
}
@ -340,9 +339,9 @@ fun CategoryPage(subsItemId: Long) {
.clickable {
vm.viewModelScope.launchTry(Dispatchers.IO) {
subsItem?.apply {
subsFile.writeText(json.encodeToString(subsRawVal.copy(
updateSubscription(subsRawVal.copy(
categories = subsRawVal.categories.filter { c -> c.key != menuCategory.key }
)))
))
DbSet.subsItemDao.update(copy(mtime = System.currentTimeMillis()))
}
DbSet.categoryConfigDao.deleteByCategoryKey(

View File

@ -44,8 +44,10 @@ import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import kotlinx.coroutines.Dispatchers
import li.songe.gkd.data.ClickLog
import li.songe.gkd.data.SubsConfig
import li.songe.gkd.db.DbSet
import li.songe.gkd.ui.destinations.AppItemPageDestination
import li.songe.gkd.ui.destinations.GlobalRulePageDestination
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.ProfileTransitions
import li.songe.gkd.util.appInfoCacheFlow
@ -142,7 +144,7 @@ fun ClickLogPage() {
}
if (rule?.name != null) {
Spacer(modifier = Modifier.width(10.dp))
Text(text = rule.name)
Text(text = rule.name ?: "")
} else if ((group?.rules?.size ?: 0) > 1) {
Spacer(modifier = Modifier.width(10.dp))
Text(text = (if (clickLog.ruleKey != null) "key=${clickLog.ruleKey}, " else "") + "index=${clickLog.ruleIndex}")
@ -179,13 +181,22 @@ fun ClickLogPage() {
Text(text = "查看规则组", modifier = Modifier
.clickable {
previewTriggerLogVal.appId ?: return@clickable
navController.navigate(
AppItemPageDestination(
previewTriggerLogVal.subsId,
previewTriggerLogVal.appId,
previewTriggerLogVal.groupKey
if (previewTriggerLogVal.groupType == SubsConfig.AppGroupType) {
navController.navigate(
AppItemPageDestination(
previewTriggerLogVal.subsId,
previewTriggerLogVal.appId,
previewTriggerLogVal.groupKey
)
)
)
} else if (previewTriggerLogVal.groupType == SubsConfig.GlobalGroupType) {
navController.navigate(
GlobalRulePageDestination(
previewTriggerLogVal.subsId,
previewTriggerLogVal.groupKey
)
)
}
previewClickLog = null
}
.fillMaxWidth()

View File

@ -6,6 +6,7 @@ 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.data.SubsConfig
import li.songe.gkd.data.Tuple3
import li.songe.gkd.db.DbSet
import li.songe.gkd.util.subsIdToRawFlow
@ -17,8 +18,12 @@ class ClickLogVm @Inject constructor() : ViewModel() {
val clickDataListFlow =
combine(DbSet.clickLogDao.query(), subsIdToRawFlow) { clickLogs, subsIdToRaw ->
clickLogs.map { c ->
val app = subsIdToRaw[c.subsId]?.apps?.find { a -> a.id == c.appId }
val group = app?.groups?.find { g -> g.key == c.groupKey }
val group = if (c.groupType == SubsConfig.AppGroupType) {
val app = subsIdToRaw[c.subsId]?.apps?.find { a -> a.id == c.appId }
app?.groups?.find { g -> g.key == c.groupKey }
} else {
subsIdToRaw[c.subsId]?.globalGroups?.find { g -> g.key == c.groupKey }
}
val rule = group?.rules?.run {
if (c.ruleKey != null) {
find { r -> r.key == c.ruleKey }

View File

@ -25,18 +25,17 @@ 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 com.blankj.utilcode.util.ToastUtils
import kotlinx.coroutines.Dispatchers
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.LocalNavController
import li.songe.gkd.util.SafeR
import li.songe.gkd.util.checkOrRequestNotifPermission
import li.songe.gkd.util.launchTry
import li.songe.gkd.util.navigate
import li.songe.gkd.util.storeFlow
@ -54,63 +53,29 @@ fun ControlPage() {
val subsStatus by vm.subsStatusFlow.collectAsState()
val store by storeFlow.collectAsState()
val gkdAccessRunning by usePollState { GkdAbService.isRunning() }
val notifEnabled by usePollState {
NotificationManagerCompat.from(context).areNotificationsEnabled()
}
val gkdAccessRunning by GkdAbService.isRunning.collectAsState()
val manageRunning by ManageService.isRunning.collectAsState()
val canDrawOverlays by usePollState { Settings.canDrawOverlays(context) }
Column(
modifier = Modifier.verticalScroll(
state = rememberScrollState()
)
) {
if (!notifEnabled) {
AuthCard(title = "通知权限", desc = "用于启动后台服务,展示服务运行状态", onAuthClick = {
val intent = Intent()
intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS
intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
intent.putExtra(Settings.EXTRA_CHANNEL_ID, context.applicationInfo.uid)
context.startActivity(intent)
})
Divider()
}
if (!gkdAccessRunning) {
AuthCard(title = "无障碍权限",
desc = "用于获取屏幕信息,点击屏幕上的控件",
onAuthClick = {
if (notifEnabled) {
appScope.launchTry(Dispatchers.IO) {
val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
// android.content.ActivityNotFoundException
// https://bugly.qq.com/v2/crash-reporting/crashes/d0ce46b353/113010?pid=1
context.startActivity(intent)
}
} else {
ToastUtils.showShort("必须先开启[通知权限]")
appScope.launchTry {
val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
// android.content.ActivityNotFoundException
context.startActivity(intent)
}
})
Divider()
}
if (!canDrawOverlays) {
AuthCard(title = "悬浮窗权限",
desc = "用于后台提示,显示保存快照按钮等功能",
onAuthClick = {
val intent = Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)
})
Divider()
}
if (gkdAccessRunning) {
TextSwitch(name = "服务开启",
} else {
TextSwitch(
name = "服务开启",
desc = "保持服务开启,根据订阅规则匹配屏幕目标节点",
checked = store.enableService,
onCheckedChange = {
@ -120,6 +85,36 @@ fun ControlPage() {
)
)
})
}
Divider()
TextSwitch(
name = "常驻通知栏",
desc = "在通知栏显示服务运行状态,避免被某些系统回收",
checked = manageRunning,
onCheckedChange = {
if (it) {
if (!checkOrRequestNotifPermission(context)) {
return@TextSwitch
}
ManageService.start(context)
} else {
ManageService.stop(context)
}
})
Divider()
if (!canDrawOverlays) {
AuthCard(
title = "悬浮窗权限",
desc = "用于后台提示,显示保存快照按钮等功能",
onAuthClick = {
val intent = Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)
})
Divider()
}

View File

@ -7,8 +7,8 @@ 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.appRuleFlow
import li.songe.gkd.util.clickCountFlow
import li.songe.gkd.util.subsIdToRawFlow
import javax.inject.Inject
@ -37,12 +37,13 @@ class ControlVm @Inject constructor() : ViewModel() {
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
val subsStatusFlow = combine(appRuleFlow, clickCountFlow) { appRule, clickCount ->
val appSize = appRule.visibleMap.keys.size
val groupSize =
appRule.visibleMap.values.sumOf { rules -> rules.map { r -> r.group.key }.toSet().size }
(if (groupSize > 0) {
"${appSize}应用/${groupSize}规则组"
val subsStatusFlow = combine(allRulesFlow, clickCountFlow) { allRules, clickCount ->
(if (allRules.allGroupSize > 0) {
if (allRules.appSize > 0) {
"${allRules.appSize}应用/${allRules.allGroupSize}规则组"
} else {
"${allRules.allGroupSize}规则组"
}
} else {
"暂无规则"
}) + if (clickCount > 0) "/${clickCount}点击" else ""

View File

@ -7,8 +7,10 @@ import android.media.projection.MediaProjectionManager
import android.provider.Settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.text.KeyboardOptions
@ -37,7 +39,6 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.blankj.utilcode.util.LogUtils
import com.blankj.utilcode.util.ToastUtils
@ -61,6 +62,9 @@ import li.songe.gkd.util.Ext
import li.songe.gkd.util.LocalLauncher
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.ProfileTransitions
import li.songe.gkd.util.authActionFlow
import li.songe.gkd.util.canDrawOverlaysAuthAction
import li.songe.gkd.util.checkOrRequestNotifPermission
import li.songe.gkd.util.launchAsFn
import li.songe.gkd.util.launchTry
import li.songe.gkd.util.navigate
@ -146,20 +150,7 @@ fun DebugPage() {
Divider()
}
TextSwitch(
name = "匹配未知应用",
desc = "匹配不在安装列表中(其它用户空间)的应用",
checked = store.matchUnknownApp
) {
updateStorage(
storeFlow, store.copy(
matchUnknownApp = it
)
)
}
Divider()
val httpServerRunning by usePollState { HttpService.isRunning() }
val httpServerRunning by HttpService.isRunning.collectAsState()
TextSwitch(
name = "HTTP服务",
desc = "开启HTTP服务, 以便在同一局域网下传递数据" + if (httpServerRunning) "\n${
@ -168,8 +159,7 @@ fun DebugPage() {
}" else "",
checked = httpServerRunning
) {
if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) {
ToastUtils.showShort("需要通知权限")
if (!checkOrRequestNotifPermission(context)) {
return@TextSwitch
}
if (it) {
@ -200,15 +190,18 @@ fun DebugPage() {
}
Divider()
// android 11 以上可以使用无障碍服务获取屏幕截图
// Build.VERSION.SDK_INT < Build.VERSION_CODES.R
val screenshotRunning by usePollState { ScreenshotService.isRunning() }
TextSwitch(name = "截屏服务",
SettingItem(title = "快照记录", onClick = {
navController.navigate(SnapshotPageDestination)
})
Divider()
val screenshotRunning by ScreenshotService.isRunning.collectAsState()
TextSwitch(
name = "截屏服务",
desc = "生成快照需要获取屏幕截图,Android11无需开启",
checked = screenshotRunning,
onCheckedChange = appScope.launchAsFn<Boolean> {
if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) {
ToastUtils.showShort("需要通知权限")
if (!checkOrRequestNotifPermission(context)) {
return@launchAsFn
}
if (it) {
@ -225,26 +218,21 @@ fun DebugPage() {
})
Divider()
val floatingRunning by usePollState {
FloatingService.isRunning()
}
val floatingRunning by FloatingService.isRunning.collectAsState()
TextSwitch(
name = "悬浮窗服务",
desc = "显示截屏按钮,便于用户主动保存快照",
checked = floatingRunning
) {
if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) {
ToastUtils.showShort("需要通知权限")
if (!checkOrRequestNotifPermission(context)) {
return@TextSwitch
}
if (it) {
if (Settings.canDrawOverlays(context)) {
val intent = Intent(context, FloatingService::class.java)
ContextCompat.startForegroundService(context, intent)
} else {
ToastUtils.showShort("需要悬浮窗权限")
authActionFlow.value = canDrawOverlaysAuthAction
}
} else {
FloatingService.stop(context)
@ -266,7 +254,7 @@ fun DebugPage() {
Divider()
TextSwitch(
name = "截屏快照",
desc = "当用户截屏时保存快照(需手动替换快照图片),仅支持MIUI14",
desc = "当用户截屏时保存快照(需手动替换快照图片),仅支持部分MIUI14",
checked = store.captureScreenshot
) {
updateStorage(
@ -288,11 +276,8 @@ fun DebugPage() {
)
)
}
Divider()
SettingItem(title = "快照记录", onClick = {
navController.navigate(SnapshotPageDestination)
})
Spacer(modifier = Modifier.height(40.dp))
}
})

View File

@ -0,0 +1,398 @@
package li.songe.gkd.ui
import androidx.compose.foundation.background
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.defaultMinSize
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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
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.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.viewModelScope
import com.blankj.utilcode.util.ClipboardUtils
import com.blankj.utilcode.util.LogUtils
import com.blankj.utilcode.util.ToastUtils
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import kotlinx.coroutines.Dispatchers
import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.SubsConfig
import li.songe.gkd.db.DbSet
import li.songe.gkd.ui.component.getDialogResult
import li.songe.gkd.ui.destinations.GroupItemPageDestination
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.ProfileTransitions
import li.songe.gkd.util.encodeToJson5String
import li.songe.gkd.util.json
import li.songe.gkd.util.launchAsFn
import li.songe.gkd.util.launchTry
import li.songe.gkd.util.navigate
import li.songe.gkd.util.updateSubscription
@RootNavGraph
@Destination(style = ProfileTransitions::class)
@Composable
fun GlobalRulePage(subsItemId: Long, focusGroupKey: Int? = null) {
val navController = LocalNavController.current
val vm = hiltViewModel<GlobalRuleVm>()
val subsItem by vm.subsItemFlow.collectAsState()
val rawSubs = vm.subsRawFlow.collectAsState().value
val subsConfigs by vm.subsConfigsFlow.collectAsState()
val editable = subsItemId < 0 && rawSubs != null && subsItem != null
val globalGroups = rawSubs?.globalGroups ?: emptyList()
var showAddDlg by remember { mutableStateOf(false) }
val (menuGroupRaw, setMenuGroupRaw) = remember {
mutableStateOf<RawSubscription.RawGlobalGroup?>(null)
}
val (editGroupRaw, setEditGroupRaw) = remember {
mutableStateOf<RawSubscription.RawGlobalGroup?>(null)
}
val (showGroupItem, setShowGroupItem) = remember {
mutableStateOf<RawSubscription.RawGlobalGroup?>(
null
)
}
Scaffold(
topBar = {
TopAppBar(navigationIcon = {
IconButton(onClick = {
navController.popBackStack()
}) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = null,
)
}
}, title = {
Text(text = "${rawSubs?.name ?: subsItemId}/全局规则")
})
}, floatingActionButton = {
if (editable) {
FloatingActionButton(onClick = { showAddDlg = true }) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "add",
)
}
}
},
content = { paddingValues ->
LazyColumn(modifier = Modifier.padding(paddingValues)) {
items(globalGroups, { g -> g.key }) { group ->
Row(
modifier = Modifier
.background(
if (group.key == focusGroupKey) MaterialTheme.colorScheme.inversePrimary else Color.Transparent
)
.clickable { setShowGroupItem(group) }
.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))
if (editable) {
IconButton(onClick = {
setMenuGroupRaw(group)
}) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = null,
)
}
Spacer(modifier = Modifier.width(10.dp))
}
val groupEnable = subsConfigs.find { c -> c.groupKey == group.key }?.enable
?: group.enable ?: true
val subsConfig = subsConfigs.find { it.groupKey == group.key }
Switch(checked = groupEnable, modifier = Modifier,
onCheckedChange = vm.viewModelScope.launchAsFn { enable ->
val newItem = (subsConfig?.copy(enable = enable) ?: SubsConfig(
type = SubsConfig.GlobalGroupType,
subsItemId = subsItemId,
groupKey = group.key,
enable = enable
))
DbSet.subsConfigDao.insert(newItem)
}
)
}
}
item {
Spacer(modifier = Modifier.height(40.dp))
if (globalGroups.isEmpty()) {
Text(
text = "暂无规则",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
}
}
}
}
)
if (showAddDlg && rawSubs != null) {
var source by remember {
mutableStateOf("")
}
AlertDialog(title = { Text(text = "添加全局规则组") }, text = {
OutlinedTextField(
value = source,
onValueChange = { source = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text(text = "请输入规则组") },
maxLines = 8,
)
}, onDismissRequest = { showAddDlg = false }, confirmButton = {
TextButton(onClick = {
val newGroup = try {
RawSubscription.parseRawGlobalGroup(source)
} catch (e: Exception) {
ToastUtils.showShort("非法规则\n${e.message ?: e}")
return@TextButton
}
if (!newGroup.valid) {
ToastUtils.showShort("非法规则:存在非法选择器")
return@TextButton
}
if (rawSubs.globalGroups.any { g -> g.name == newGroup.name }) {
ToastUtils.showShort("存在同名规则[${newGroup.name}]")
return@TextButton
}
val newKey = (rawSubs.globalGroups.maxByOrNull { g -> g.key }?.key ?: -1) + 1
val newRawSubs = rawSubs.copy(
globalGroups = rawSubs.globalGroups.toMutableList()
.apply { add(newGroup.copy(key = newKey)) }
)
updateSubscription(newRawSubs)
vm.viewModelScope.launchTry(Dispatchers.IO) {
showAddDlg = false
ToastUtils.showShort("添加成功")
}
}, enabled = source.isNotEmpty()) {
Text(text = "添加")
}
}, dismissButton = {
TextButton(onClick = { showAddDlg = false }) {
Text(text = "取消")
}
})
}
if (menuGroupRaw != null && rawSubs != null) {
Dialog(onDismissRequest = { setMenuGroupRaw(null) }) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(16.dp),
) {
Column {
Text(text = "编辑", modifier = Modifier
.clickable {
setEditGroupRaw(menuGroupRaw)
setMenuGroupRaw(null)
}
.padding(16.dp)
.fillMaxWidth())
Text(text = "删除", modifier = Modifier
.clickable {
setMenuGroupRaw(null)
vm.viewModelScope.launchTry {
if (!getDialogResult("是否删除${menuGroupRaw.name}")) return@launchTry
updateSubscription(
rawSubs.copy(
globalGroups = rawSubs.globalGroups.filter { g -> g.key != menuGroupRaw.key }
)
)
DbSet.subsItemDao.updateMtime(rawSubs.id)
}
}
.padding(16.dp)
.fillMaxWidth(),
color = MaterialTheme.colorScheme.error
)
}
}
}
}
if (editGroupRaw != null && rawSubs != null) {
var source by remember {
mutableStateOf(json.encodeToJson5String(editGroupRaw))
}
val oldSource = remember { source }
AlertDialog(
title = { Text(text = "编辑规则组") },
text = {
OutlinedTextField(
value = source,
onValueChange = { source = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text(text = "请输入规则组") },
maxLines = 8,
)
},
onDismissRequest = { setEditGroupRaw(null) },
dismissButton = {
TextButton(onClick = { setEditGroupRaw(null) }) {
Text(text = "取消")
}
},
confirmButton = {
TextButton(onClick = {
if (oldSource == source) {
ToastUtils.showShort("规则无变动")
return@TextButton
}
val newGroupRaw = try {
RawSubscription.parseRawGlobalGroup(source)
} catch (e: Exception) {
LogUtils.d(e)
ToastUtils.showShort("非法规则:${e.message}")
return@TextButton
}
if (newGroupRaw.key != editGroupRaw.key) {
ToastUtils.showShort("不能更改规则组的key")
return@TextButton
}
if (!newGroupRaw.valid) {
ToastUtils.showShort("非法规则:存在非法选择器")
return@TextButton
}
setEditGroupRaw(null)
val newGlobalGroups = rawSubs.globalGroups.toMutableList().apply {
val i = rawSubs.globalGroups.indexOfFirst { g -> g.key == newGroupRaw.key }
if (i >= 0) {
set(i, newGroupRaw)
}
}
updateSubscription(rawSubs.copy(globalGroups = newGlobalGroups))
vm.viewModelScope.launchTry(Dispatchers.IO) {
DbSet.subsItemDao.updateMtime(rawSubs.id)
ToastUtils.showShort("更新成功")
}
}, enabled = source.isNotEmpty()) {
Text(text = "更新")
}
},
)
}
if (showGroupItem != null) {
AlertDialog(modifier = Modifier.defaultMinSize(300.dp),
onDismissRequest = { setShowGroupItem(null) },
title = {
Text(text = showGroupItem.name)
},
text = {
Column {
if (showGroupItem.enable == false) {
Text(text = "该规则组默认不启用")
Spacer(modifier = Modifier.height(10.dp))
}
Text(text = showGroupItem.desc ?: "")
}
},
confirmButton = {
Row {
if (showGroupItem.allExampleUrls.isNotEmpty()) {
TextButton(onClick = {
setShowGroupItem(null)
navController.navigate(
GroupItemPageDestination(
subsInt = subsItemId,
groupKey = showGroupItem.key
)
)
}) {
Text(text = "查看图片")
}
}
TextButton(onClick = {
val groupAppText = json.encodeToJson5String(showGroupItem)
ClipboardUtils.copyText(groupAppText)
ToastUtils.showShort("复制成功")
}) {
Text(text = "复制规则组")
}
}
})
}
}

View File

@ -0,0 +1,26 @@
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.SharingStarted
import kotlinx.coroutines.flow.stateIn
import li.songe.gkd.db.DbSet
import li.songe.gkd.ui.destinations.GlobalRulePageDestination
import li.songe.gkd.util.map
import li.songe.gkd.util.subsIdToRawFlow
import li.songe.gkd.util.subsItemsFlow
import javax.inject.Inject
@HiltViewModel
class GlobalRuleVm @Inject constructor(stateHandle: SavedStateHandle) : ViewModel() {
private val args = GlobalRulePageDestination.argsFrom(stateHandle)
val subsItemFlow =
subsItemsFlow.map(viewModelScope) { s -> s.find { v -> v.id == args.subsItemId } }
val subsRawFlow = subsIdToRawFlow.map(viewModelScope) { s -> s[args.subsItemId] }
val subsConfigsFlow = DbSet.subsConfigDao.queryGlobalGroupTypeConfig(args.subsItemId)
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
}

View File

@ -20,7 +20,6 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@ -30,6 +29,7 @@ import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import li.songe.gkd.data.RawSubscription
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.appInfoCacheFlow
import li.songe.gkd.util.imageLoader
@ -39,14 +39,20 @@ import li.songe.gkd.util.subsIdToRawFlow
@RootNavGraph
@Destination
@Composable
fun GroupItemPage(subsInt: Long, appId: String, groupKey: Int) {
fun GroupItemPage(subsInt: Long, groupKey: Int, appId: String? = null) {
val navController = LocalNavController.current
val subsIdToRawState = subsIdToRawFlow.collectAsState()
val appRaw = remember {
subsIdToRawState.value[subsInt]?.apps?.first { a -> a.id == appId }
val subsIdToRaw by subsIdToRawFlow.collectAsState()
val rawSubs = subsIdToRaw[subsInt]
val rawApp = rawSubs?.apps?.first { a -> a.id == appId }
val group = if (appId == null) {
rawSubs?.globalGroups?.find { g -> g.key == groupKey }
} else {
rawApp?.groups?.find { g -> g.key == groupKey }
}
val group = remember {
appRaw?.groups?.find { g -> g.key == groupKey }
val allExampleUrls = when (group) {
is RawSubscription.RawAppGroup -> group.allExampleUrls
is RawSubscription.RawGlobalGroup -> group.allExampleUrls
else -> emptyList()
}
val appInfoCache by appInfoCacheFlow.collectAsState()
Box(modifier = Modifier.fillMaxSize()) {
@ -62,10 +68,26 @@ fun GroupItemPage(subsInt: Long, appId: String, groupKey: Int) {
}
},
title = {
Text(
text = ((appInfoCache[appId]?.name ?: appRaw?.name
?: appId) + "/" + (group?.name ?: "未知规则"))
)
when (group) {
is RawSubscription.RawAppGroup -> {
Text(
text = ((rawSubs?.name
?: subsInt.toString()) + (appInfoCache[appId]?.name ?: rawApp?.name
?: appId) + "/" + (group.name))
)
}
is RawSubscription.RawGlobalGroup -> {
Text(
text = "${rawSubs?.name ?: subsInt}/${group.name}"
)
}
else -> {
Text(text = "未知规则")
}
}
},
actions = {},
modifier = Modifier.zIndex(1f),
@ -74,11 +96,11 @@ fun GroupItemPage(subsInt: Long, appId: String, groupKey: Int) {
)
)
if (group != null) {
val state = rememberPagerState { group.allExampleUrls.size }
val state = rememberPagerState { allExampleUrls.size }
HorizontalPager(
modifier = Modifier.fillMaxSize(), state = state
) { p ->
val url = group.allExampleUrls.getOrNull(p)
val url = allExampleUrls.getOrNull(p)
if (url != null) {
SubcomposeAsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(url)

View File

@ -2,7 +2,6 @@ package li.songe.gkd.ui
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
@ -16,22 +15,21 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
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.data.SubscriptionRaw
import li.songe.gkd.db.DbSet
import li.songe.gkd.debug.SnapshotExt
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.dbFolder
import li.songe.gkd.util.initFolder
import li.songe.gkd.util.json
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
@ -48,34 +46,6 @@ class HomePageVm @Inject constructor() : ViewModel() {
if (!DbSet.subsItemDao.query().first().any { s -> s.id == localSubsItem.id }) {
DbSet.subsItemDao.insert(localSubsItem)
}
if (!localSubsItem.subsFile.exists()) {
localSubsItem.subsFile.writeText(
json.encodeToString(
SubscriptionRaw(
id = localSubsItem.id,
name = "本地订阅",
version = 0,
author = "gkd",
)
)
)
}
}
appScope.launchTry(Dispatchers.IO) {
// 迁移快照记录
val oldDbFile = File(dbFolder, "snapshot.db")
if (oldDbFile.exists()) {
SnapshotExt.snapshotDir.walk().maxDepth(1).filter { f -> f.isDirectory }
.mapNotNull { f -> f.name.toLongOrNull() }.forEach { snapshotId ->
DbSet.snapshotDao.insertOrIgnore(
json.decodeFromString(
File(SnapshotExt.getSnapshotPath(snapshotId)).readText()
)
)
}
oldDbFile.delete()
LogUtils.d("执行快照迁移")
}
}
if (storeFlow.value.autoCheckAppUpdate) {
@ -88,6 +58,19 @@ class HomePageVm @Inject constructor() : ViewModel() {
}
}
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都能重新检测创建
@ -129,4 +112,9 @@ class HomePageVm @Inject constructor() : ViewModel() {
}
}
}
override fun onCleared() {
super.onCleared()
authActionFlow.value = null
}
}

View File

@ -31,7 +31,6 @@ fun ImagePreviewPage(
title: String? = null,
) {
val navController = LocalNavController.current
Box(modifier = Modifier.fillMaxSize()) {
TopAppBar(
navigationIcon = {

View File

@ -55,6 +55,8 @@ import li.songe.gkd.ui.destinations.DebugPageDestination
import li.songe.gkd.util.LoadStatus
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.SafeR
import li.songe.gkd.util.authActionFlow
import li.songe.gkd.util.canDrawOverlaysAuthAction
import li.songe.gkd.util.checkUpdate
import li.songe.gkd.util.checkUpdatingFlow
import li.songe.gkd.util.launchTry
@ -110,8 +112,8 @@ fun SettingsPage() {
})
Divider()
TextSwitch(name = "无障碍前台",
desc = "添加前台透明悬浮窗,关闭可能导致不工作",
TextSwitch(name = "前台悬浮窗",
desc = "添加前台透明悬浮窗,关闭可能导致不点击/点击缓慢",
checked = store.enableAbFloatWindow,
onCheckedChange = {
updateStorage(
@ -129,14 +131,15 @@ fun SettingsPage() {
showToastInputDlg = true
},
onCheckedChange = {
if (it && !Settings.canDrawOverlays(context)) {
authActionFlow.value = canDrawOverlaysAuthAction
return@TextSwitch
}
updateStorage(
storeFlow, store.copy(
toastWhenClick = it
)
)
if (it && !Settings.canDrawOverlays(context)) {
ToastUtils.showShort("需要悬浮窗权限")
}
})
Divider()

View File

@ -53,19 +53,22 @@ import com.blankj.utilcode.util.ClipboardUtils
import com.blankj.utilcode.util.ToastUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.SubsItem
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.db.DbSet
import li.songe.gkd.ui.component.SubsItemCard
import li.songe.gkd.ui.destinations.CategoryPageDestination
import li.songe.gkd.ui.destinations.GlobalRulePageDestination
import li.songe.gkd.ui.destinations.SubsPageDestination
import li.songe.gkd.util.DEFAULT_SUBS_UPDATE_URL
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.SafeR
import li.songe.gkd.util.formatTimeAgo
import li.songe.gkd.util.launchAsFn
import li.songe.gkd.util.launchTry
import li.songe.gkd.util.navigate
import li.songe.gkd.util.shareFile
import li.songe.gkd.util.subsFolder
import li.songe.gkd.util.subsIdToRawFlow
import li.songe.gkd.util.subsItemsFlow
import org.burnoutcrew.reorderable.ReorderableItem
@ -104,7 +107,7 @@ fun SubsManagePage() {
var link by remember { mutableStateOf("") }
val (showSubsRaw, setShowSubsRaw) = remember {
mutableStateOf<SubscriptionRaw?>(null)
mutableStateOf<RawSubscription?>(null)
}
@ -171,17 +174,14 @@ fun SubsManagePage() {
.animateItemPlacement()
.padding(vertical = 3.dp, horizontal = 8.dp)
.clickable {
navController.navigate(SubsPageDestination(subItem.id))
menuSubItem = subItem
},
shape = RoundedCornerShape(8.dp),
) {
SubsItemCard(
subsItem = subItem,
subscriptionRaw = subsIdToRaw[subItem.id],
rawSubscription = subsIdToRaw[subItem.id],
index = index + 1,
onMenuClick = {
menuSubItem = subItem
},
onCheckedChange = vm.viewModelScope.launchAsFn<Boolean> {
DbSet.subsItemDao.update(subItem.copy(enable = it))
},
@ -210,7 +210,7 @@ fun SubsManagePage() {
Column {
val subsRawVal = subsIdToRaw[menuSubItemVal.id]
if (subsRawVal != null) {
Text(text = "查看规则", modifier = Modifier
Text(text = "应用规则", modifier = Modifier
.clickable {
menuSubItem = null
navController.navigate(SubsPageDestination(subsRawVal.id))
@ -226,12 +226,23 @@ fun SubsManagePage() {
.fillMaxWidth()
.padding(16.dp))
Divider()
Text(text = "全局规则", modifier = Modifier
.clickable {
menuSubItem = null
navController.navigate(GlobalRulePageDestination(subsRawVal.id))
}
.fillMaxWidth()
.padding(16.dp))
Divider()
}
if (menuSubItemVal.id < 0 && subsRawVal != null && menuSubItemVal.subsFile.exists()) {
if (menuSubItemVal.id < 0 && subsRawVal != null) {
Text(text = "分享文件", modifier = Modifier
.clickable {
menuSubItem = null
context.shareFile(menuSubItemVal.subsFile, "分享订阅文件")
vm.viewModelScope.launchTry {
val subsFile = subsFolder.resolve("${menuSubItemVal.id}.json")
context.shareFile(subsFile, "分享订阅文件")
}
}
.fillMaxWidth()
.padding(16.dp))

View File

@ -13,16 +13,15 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.SubsItem
import li.songe.gkd.data.SubsVersion
import li.songe.gkd.data.SubscriptionRaw
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 java.io.File
import li.songe.gkd.util.updateSubscription
import javax.inject.Inject
@ -51,7 +50,7 @@ class SubsManageVm @Inject constructor() : ViewModel() {
return@launchTry
}
val newSubsRaw = try {
SubscriptionRaw.parse(text)
RawSubscription.parse(text)
} catch (e: Exception) {
e.printStackTrace()
ToastUtils.showShort("解析订阅文件失败")
@ -70,18 +69,7 @@ class SubsManageVm @Inject constructor() : ViewModel() {
updateUrl = newSubsRaw.updateUrl ?: url,
order = if (subItems.isEmpty()) 1 else (subItems.maxBy { it.order }.order + 1)
)
withContext(Dispatchers.IO) {
val parentPath = newItem.subsFile.parent
if (parentPath != null) {
// https://bugly.qq.com/v2/crash-reporting/crashes/d0ce46b353/109028?pid=1
File(parentPath).apply {
if (!exists()) {
mkdirs()
}
}
}
newItem.subsFile.writeText(text)
}
updateSubscription(newSubsRaw)
DbSet.subsItemDao.insert(newItem)
ToastUtils.showShort("成功添加订阅")
} finally {
@ -112,7 +100,7 @@ class SubsManageVm @Inject constructor() : ViewModel() {
LogUtils.d("快速检测更新失败", oldItem, e)
}
}
val newSubsRaw = SubscriptionRaw.parse(
val newSubsRaw = RawSubscription.parse(
client.get(oldItem.updateUrl).bodyAsText()
)
if (oldSubsRaw != null && newSubsRaw.version <= oldSubsRaw.version) {
@ -122,13 +110,7 @@ class SubsManageVm @Inject constructor() : ViewModel() {
updateUrl = newSubsRaw.updateUrl ?: oldItem.updateUrl,
mtime = System.currentTimeMillis(),
)
withContext(Dispatchers.IO) {
newItem.subsFile.writeText(
SubscriptionRaw.stringify(
newSubsRaw
)
)
}
updateSubscription(newSubsRaw)
newItem
} catch (e: Exception) {
e.printStackTrace()

View File

@ -51,9 +51,8 @@ import com.blankj.utilcode.util.ToastUtils
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import kotlinx.coroutines.Dispatchers
import kotlinx.serialization.encodeToString
import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.SubsConfig
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.db.DbSet
import li.songe.gkd.ui.component.AppBarTextField
import li.songe.gkd.ui.component.SubsAppCard
@ -66,6 +65,7 @@ import li.songe.gkd.util.json
import li.songe.gkd.util.launchAsFn
import li.songe.gkd.util.launchTry
import li.songe.gkd.util.navigate
import li.songe.gkd.util.updateSubscription
@RootNavGraph
@ -92,11 +92,11 @@ fun SubsPage(
mutableStateOf(false)
}
var menuAppRaw by remember {
mutableStateOf<SubscriptionRaw.AppRaw?>(null)
var menuRawApp by remember {
mutableStateOf<RawSubscription.RawApp?>(null)
}
var editAppRaw by remember {
mutableStateOf<SubscriptionRaw.AppRaw?>(null)
var editRawApp by remember {
mutableStateOf<RawSubscription.RawApp?>(null)
}
var showSearchBar by rememberSaveable {
@ -130,7 +130,7 @@ fun SubsPage(
modifier = Modifier.focusRequester(focusRequester)
)
} else {
Text(text = subsRaw?.name ?: subsItem?.id.toString())
Text(text = "${subsRaw?.name ?: subsItemId}/应用规则")
}
}, actions = {
if (showSearchBar) {
@ -168,7 +168,8 @@ fun SubsPage(
) {
itemsIndexed(appAndConfigs, { i, a -> i.toString() + a.t0.id }) { _, a ->
val (appRaw, subsConfig, enableSize) = a
SubsAppCard(appRaw = appRaw,
SubsAppCard(
rawApp = appRaw,
appInfo = appInfoCache[appRaw.id],
subsConfig = subsConfig,
enableSize = enableSize,
@ -188,7 +189,7 @@ fun SubsPage(
},
showMenu = editable,
onMenuClick = {
menuAppRaw = appRaw
menuRawApp = appRaw
})
}
item {
@ -201,7 +202,7 @@ fun SubsPage(
if (searchStr.isNotEmpty()) {
Text(text = "暂无搜索结果")
} else {
Text(text = "此订阅暂无规则")
Text(text = "暂无规则")
}
}
}
@ -230,7 +231,7 @@ fun SubsPage(
}, onDismissRequest = { showAddDlg = false }, confirmButton = {
TextButton(onClick = {
val newAppRaw = try {
SubscriptionRaw.parseAppRaw(source)
RawSubscription.parseRawApp(source)
} catch (e: Exception) {
LogUtils.d(e)
ToastUtils.showShort("非法规则${e.message}")
@ -281,11 +282,9 @@ fun SubsPage(
}
}
vm.viewModelScope.launchTry {
subsItemVal.subsFile.writeText(
SubscriptionRaw.stringify(
subsRaw.copy(
apps = newApps, version = subsRaw.version + 1
)
updateSubscription(
subsRaw.copy(
apps = newApps, version = subsRaw.version + 1
)
)
DbSet.subsItemDao.update(subsItemVal.copy(mtime = System.currentTimeMillis()))
@ -302,7 +301,7 @@ fun SubsPage(
})
}
val editAppRawVal = editAppRaw
val editAppRawVal = editRawApp
if (editAppRawVal != null && subsItemVal != null && subsRaw != null) {
var source by remember {
mutableStateOf(json.encodeToJson5String(editAppRawVal))
@ -315,27 +314,25 @@ fun SubsPage(
modifier = Modifier.fillMaxWidth(),
placeholder = { Text(text = "请输入规则") },
)
}, onDismissRequest = { editAppRaw = null }, confirmButton = {
}, onDismissRequest = { editRawApp = null }, confirmButton = {
TextButton(onClick = {
try {
val newAppRaw = SubscriptionRaw.parseAppRaw(source)
val newAppRaw = RawSubscription.parseRawApp(source)
if (newAppRaw.id != editAppRawVal.id) {
ToastUtils.showShort("不允许修改规则id")
return@TextButton
}
val oldAppRawIndex = subsRaw.apps.indexOfFirst { a -> a.id == editAppRawVal.id }
vm.viewModelScope.launchTry {
subsItemVal.subsFile.writeText(
SubscriptionRaw.stringify(
subsRaw.copy(
apps = subsRaw.apps.toMutableList().apply {
set(oldAppRawIndex, newAppRaw)
}, version = subsRaw.version + 1
)
updateSubscription(
subsRaw.copy(
apps = subsRaw.apps.toMutableList().apply {
set(oldAppRawIndex, newAppRaw)
}, version = subsRaw.version + 1
)
)
DbSet.subsItemDao.update(subsItemVal.copy(mtime = System.currentTimeMillis()))
editAppRaw = null
editRawApp = null
ToastUtils.showShort("更新成功")
}
} catch (e: Exception) {
@ -346,16 +343,16 @@ fun SubsPage(
Text(text = "添加")
}
}, dismissButton = {
TextButton(onClick = { editAppRaw = null }) {
TextButton(onClick = { editRawApp = null }) {
Text(text = "取消")
}
})
}
val menuAppRawVal = menuAppRaw
val menuAppRawVal = menuRawApp
if (menuAppRawVal != null && subsItemVal != null && subsRaw != null) {
Dialog(onDismissRequest = { menuAppRaw = null }) {
Dialog(onDismissRequest = { menuRawApp = null }) {
Card(
modifier = Modifier
.fillMaxWidth()
@ -363,33 +360,31 @@ fun SubsPage(
shape = RoundedCornerShape(16.dp),
) {
Column {
Text(text = "复制", modifier = Modifier
.clickable {
ClipboardUtils.copyText(
json.encodeToJson5String(menuAppRawVal)
)
ToastUtils.showShort("复制成功")
menuAppRaw = null
}
.fillMaxWidth()
.padding(16.dp))
Text(text = "删除", modifier = Modifier
.clickable {
// 也许需要二次确认
vm.viewModelScope.launchTry(Dispatchers.IO) {
subsItemVal.subsFile.writeText(
json.encodeToString(
subsRaw.copy(apps = subsRaw.apps.filter { a -> a.id != menuAppRawVal.id })
)
Text(
text = "复制", modifier = Modifier
.clickable {
ClipboardUtils.copyText(
json.encodeToJson5String(menuAppRawVal)
)
DbSet.subsItemDao.update(subsItemVal.copy(mtime = System.currentTimeMillis()))
DbSet.subsConfigDao.delete(subsItemVal.id, menuAppRawVal.id)
ToastUtils.showShort("删除成功")
ToastUtils.showShort("复制成功")
menuRawApp = null
}
menuAppRaw = null
}
.fillMaxWidth()
.padding(16.dp), color = MaterialTheme.colorScheme.error)
.fillMaxWidth()
.padding(16.dp))
Text(
text = "删除", modifier = Modifier
.clickable {
// 也许需要二次确认
vm.viewModelScope.launchTry(Dispatchers.IO) {
updateSubscription(subsRaw.copy(apps = subsRaw.apps.filter { a -> a.id != menuAppRawVal.id }))
DbSet.subsItemDao.update(subsItemVal.copy(mtime = System.currentTimeMillis()))
DbSet.subsConfigDao.delete(subsItemVal.id, menuAppRawVal.id)
ToastUtils.showShort("删除成功")
}
menuRawApp = null
}
.fillMaxWidth()
.padding(16.dp), color = MaterialTheme.colorScheme.error)
}
}
}

View File

@ -9,8 +9,8 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.stateIn
import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.SubsConfig
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.data.Tuple3
import li.songe.gkd.db.DbSet
import li.songe.gkd.ui.destinations.SubsPageDestination
@ -82,7 +82,7 @@ class SubsVm @Inject constructor(stateHandle: SavedStateHandle) : ViewModel() {
if (searchStr.isBlank()) {
appAndConfigs
} else {
val results = mutableListOf<Tuple3<SubscriptionRaw.AppRaw, SubsConfig?, Int>>()
val results = mutableListOf<Tuple3<RawSubscription.RawApp, SubsConfig?, Int>>()
val remnantList = appAndConfigs.toMutableList()
//1. 搜索已安装应用名称
remnantList.toList().apply { remnantList.clear() }.forEach { a ->

View File

@ -12,14 +12,14 @@ import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
data class DialogParams(
private data class DialogParams(
val title: String,
val text: String? = null,
val resolve: () -> Unit,
val reject: () -> Unit
)
val dialogParamsFlow = MutableStateFlow<DialogParams?>(null)
private val dialogParamsFlow = MutableStateFlow<DialogParams?>(null)
@Composable
fun ConfirmDialog() {

View File

@ -27,17 +27,17 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.google.accompanist.drawablepainter.rememberDrawablePainter
import li.songe.gkd.data.AppInfo
import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.SubsConfig
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.util.SafeR
@Composable
fun SubsAppCard(
appRaw: SubscriptionRaw.AppRaw,
rawApp: RawSubscription.RawApp,
appInfo: AppInfo? = null,
subsConfig: SubsConfig? = null,
enableSize: Int = appRaw.groups.count { g -> g.enable ?: true },
enableSize: Int = rawApp.groups.count { g -> g.enable ?: true },
onClick: (() -> Unit)? = null,
showMenu: Boolean = false,
onMenuClick: (() -> Unit)? = null,
@ -72,7 +72,7 @@ fun SubsAppCard(
verticalArrangement = Arrangement.SpaceBetween
) {
Text(
text = appInfo?.name ?: appRaw.name ?: appRaw.id,
text = appInfo?.name ?: rawApp.name ?: rawApp.id,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
@ -80,9 +80,9 @@ fun SubsAppCard(
)
val enableDesc = when (enableSize) {
0 -> "${appRaw.groups.size}组规则/${appRaw.groups.size}关闭"
appRaw.groups.size -> "${appRaw.groups.size}组规则"
else -> "${appRaw.groups.size}组规则/${enableSize}启用/${appRaw.groups.size - enableSize}关闭"
0 -> "${rawApp.groups.size}组规则/${rawApp.groups.size}关闭"
rawApp.groups.size -> "${rawApp.groups.size}组规则"
else -> "${rawApp.groups.size}组规则/${enableSize}启用/${rawApp.groups.size - enableSize}关闭"
}
Text(
text = enableDesc,
@ -107,7 +107,7 @@ fun SubsAppCard(
}
Switch(
subsConfig?.enable ?: true,
subsConfig?.enable ?: (appInfo != null),
onValueChange,
)
}

View File

@ -1,18 +1,11 @@
package li.songe.gkd.ui.component
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.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -21,8 +14,8 @@ 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 li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.SubsItem
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.util.formatTimeAgo
import li.songe.gkd.util.safeRemoteBaseUrls
@ -30,94 +23,88 @@ import li.songe.gkd.util.safeRemoteBaseUrls
@Composable
fun SubsItemCard(
subsItem: SubsItem,
subscriptionRaw: SubscriptionRaw?,
rawSubscription: RawSubscription?,
index: Int,
onMenuClick: (() -> Unit)? = null,
onCheckedChange: ((Boolean) -> Unit)? = null,
) {
Box {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
if (subsItem.id < 0) {
Text(text = "本地来源", fontSize = 12.sp)
} else if (subsItem.updateUrl != null && safeRemoteBaseUrls.any { s ->
subsItem.updateUrl.startsWith(
s
Row(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(8.dp)
) {
Column(modifier = Modifier.weight(1f)) {
if (rawSubscription != null) {
Row {
Text(
text = index.toString() + ". " + (rawSubscription.name),
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis
)
}) {
Text(text = "可信来源", fontSize = 12.sp)
} else {
Text(text = "未知来源", fontSize = 12.sp)
}
Spacer(modifier = Modifier.width(10.dp))
}
Row(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(8.dp)
) {
Column(modifier = Modifier.weight(1f)) {
if (subscriptionRaw != null) {
Row {
}
Spacer(modifier = Modifier.height(5.dp))
Row {
Text(
text = formatTimeAgo(subsItem.mtime),
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
fontSize = 14.sp
)
Spacer(modifier = Modifier.width(10.dp))
val sourceText =
if (subsItem.id < 0) {
"本地来源"
} else if (subsItem.updateUrl != null && safeRemoteBaseUrls.any { s ->
subsItem.updateUrl.startsWith(
s
)
}) {
"可信来源"
} else {
"未知来源"
}
Text(text = sourceText, fontSize = 14.sp)
}
Spacer(modifier = Modifier.height(5.dp))
Row {
if (subsItem.id >= 0) {
Text(
text = index.toString() + ". " + (subscriptionRaw.name),
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.height(5.dp))
Row {
Text(
text = formatTimeAgo(subsItem.mtime),
text = "v" + (rawSubscription.version.toString()),
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
fontSize = 14.sp
)
Spacer(modifier = Modifier.width(10.dp))
if (subsItem.id >= 0) {
Text(
text = "v" + (subscriptionRaw.version.toString()),
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
fontSize = 14.sp
)
Spacer(modifier = Modifier.width(10.dp))
}
val apps = subscriptionRaw.apps
val groupsSize = apps.sumOf { it.groups.size }
if (groupsSize > 0) {
Text(
text = "${apps.size}应用/${groupsSize}规则组", fontSize = 14.sp
)
} else {
Text(
text = "暂无规则", fontSize = 14.sp
)
}
}
} else {
val apps = rawSubscription.apps
val groupsSize = rawSubscription.allGroupSize
val ruleNumText = if (groupsSize > 0) {
if (apps.isNotEmpty()) {
"${apps.size}应用/${groupsSize}规则组"
} else {
"${groupsSize}规则组"
}
} else {
"暂无规则"
}
Text(
text = "本地无订阅文件,请下拉刷新",
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis
text = ruleNumText,
fontSize = 14.sp
)
}
}
Spacer(modifier = Modifier.width(5.dp))
IconButton(onClick = { onMenuClick?.invoke() }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "more",
} else {
Text(
text = "本地无订阅文件,请下拉刷新",
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.width(10.dp))
Switch(
checked = subsItem.enable,
onCheckedChange = onCheckedChange,
)
}
Spacer(modifier = Modifier.width(10.dp))
Switch(
checked = subsItem.enable,
onCheckedChange = onCheckedChange,
)
}
}

View File

@ -9,7 +9,6 @@ import android.os.Build
import com.blankj.utilcode.util.AppUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import li.songe.gkd.app
@ -17,11 +16,7 @@ import li.songe.gkd.appScope
import li.songe.gkd.data.AppInfo
import li.songe.gkd.util.Ext.getApplicationInfoExt
private val _appInfoCacheFlow = MutableStateFlow(mapOf<String, AppInfo>())
val appInfoCacheFlow: StateFlow<Map<String, AppInfo>>
get() = _appInfoCacheFlow
val appInfoCacheFlow = MutableStateFlow(mapOf<String, AppInfo>())
private val packageReceiver by lazy {
object : BroadcastReceiver() {
@ -33,7 +28,7 @@ private val packageReceiver by lazy {
val appId = intent?.data?.schemeSpecificPart ?: return
if (intent.action == Intent.ACTION_PACKAGE_ADDED || intent.action == Intent.ACTION_PACKAGE_REPLACED || intent.action == Intent.ACTION_PACKAGE_REMOVED) {
// update
updateAppInfo(appId)
updateAppInfo(listOf(appId))
}
}
}.apply {
@ -79,13 +74,13 @@ private fun getAppInfo(id: String): AppInfo? {
return info
}
val mutex by lazy { Mutex() }
private val updateAppMutex by lazy { Mutex() }
fun updateAppInfo(vararg appIds: String) {
fun updateAppInfo(appIds: List<String>) {
if (appIds.isEmpty()) return
appScope.launchTry(Dispatchers.IO) {
mutex.withLock {
val newMap = _appInfoCacheFlow.value.toMutableMap()
updateAppMutex.withLock {
val newMap = appInfoCacheFlow.value.toMutableMap()
appIds.forEach { appId ->
val newAppInfo = getAppInfo(appId)
if (newAppInfo != null) {
@ -94,7 +89,7 @@ fun updateAppInfo(vararg appIds: String) {
newMap.remove(appId)
}
}
_appInfoCacheFlow.value = newMap
appInfoCacheFlow.value = newMap
}
}
}

View File

@ -0,0 +1,110 @@
package li.songe.gkd.util
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.provider.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationManagerCompat
import kotlinx.coroutines.flow.MutableStateFlow
import li.songe.gkd.app
data class AuthAction(
val title: String,
val text: String,
val confirm: () -> Unit
)
private val notifAuthAction by lazy {
AuthAction(
title = "权限请求",
text = "当前操作需要通知权限\n您需要前往[通知管理]打开此权限",
confirm = {
val intent = Intent()
intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS
intent.putExtra(Settings.EXTRA_APP_PACKAGE, app.packageName)
intent.putExtra(Settings.EXTRA_CHANNEL_ID, app.applicationInfo.uid)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
app.startActivity(intent)
}
)
}
val canDrawOverlaysAuthAction by lazy {
AuthAction(
title = "权限请求",
text = "当前操作需要悬浮窗权限\n您需要前往[显示在其它应用的上层]打开此权限",
confirm = {
val intent = Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
app.startActivity(intent)
}
)
}
val authActionFlow = MutableStateFlow<AuthAction?>(null)
@Composable
fun AuthDialog() {
val authAction = authActionFlow.collectAsState().value
if (authAction != null) {
AlertDialog(
title = {
Text(text = authAction.title)
},
text = {
Text(text = authAction.text)
},
onDismissRequest = { authActionFlow.value = null },
confirmButton = {
TextButton(onClick = {
authActionFlow.value = null
authAction.confirm()
}) {
Text(text = "确认")
}
},
dismissButton = {
TextButton(onClick = { authActionFlow.value = null }) {
Text(text = "取消")
}
}
)
}
}
fun checkOrRequestNotifPermission(context: Activity): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ActivityCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_DENIED
) {
if (ActivityCompat.shouldShowRequestPermissionRationale(
context,
Manifest.permission.POST_NOTIFICATIONS
)
) {
ActivityCompat.requestPermissions(
context,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
0 // TODO 如何感知 (adb shell pm grant)/appops 这类引起的授权变化?
)
} else {
authActionFlow.value = notifAuthAction
}
return false
} else if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) {
authActionFlow.value = notifAuthAction
return false
}
return true
}

View File

@ -28,6 +28,7 @@ fun initFolder() {
imageCacheDir
).forEach { f ->
if (!f.exists()) {
// TODO 在某些机型上无法创建目录 用户反馈重启手机后解决 是否存在其它解决方式?
f.mkdirs()
}
}

View File

@ -1,5 +1,6 @@
package li.songe.gkd.util
import blue.endless.jankson.Jankson
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
@ -48,7 +49,8 @@ private fun escapeString(value: String): String {
return sb.toString()
}
fun convertJsonElementToJson5(element: JsonElement): String {
fun convertJsonElementToJson5(element: JsonElement, indent: Int = 2): String {
val spaces = "\u0020".repeat(indent)
return when (element) {
is JsonPrimitive -> {
val content = element.content
@ -61,20 +63,22 @@ fun convertJsonElementToJson5(element: JsonElement): String {
is JsonObject -> {
// Handle JSON objects
val entries = element.entries.joinToString(",") { (key, value) ->
val entries = element.entries.joinToString(",\n") { (key, value) ->
// If key is a valid identifier, no quotes are needed
if (key.matches(json5IdentifierReg)) {
"$key:${convertJsonElementToJson5(value)}"
"$key: ${convertJsonElementToJson5(value, indent)}"
} else {
"${escapeString(key)}:${convertJsonElementToJson5(value)}"
"${escapeString(key)}: ${convertJsonElementToJson5(value, indent)}"
}
}
"{$entries}"
}.lineSequence().map { l -> spaces + l }.joinToString("\n")
"{\n$entries\n}"
}
is JsonArray -> {
val elements = element.joinToString(",") { convertJsonElementToJson5(it) }
"[$elements]"
val elements =
element.joinToString(",\n") { convertJsonElementToJson5(it, indent) }
.lineSequence().map { l -> spaces + l }.joinToString("\n")
"[\n$elements\n]"
}
}
}
@ -82,3 +86,7 @@ fun convertJsonElementToJson5(element: JsonElement): String {
inline fun <reified T> Json.encodeToJson5String(value: T): String {
return convertJsonElementToJson5(encodeToJsonElement(serializersModule.serializer(), value))
}
fun json5ToJson(source: String): String {
return Jankson.builder().build().load(source).toJson()
}

View File

@ -59,7 +59,6 @@ data class Store(
val log2FileSwitch: Boolean = true,
val enableDarkTheme: Boolean? = null,
val enableAbFloatWindow: Boolean = true,
val matchUnknownApp: Boolean = false,
)
val storeFlow by lazy {

View File

@ -1,18 +1,22 @@
package li.songe.gkd.util
import com.blankj.utilcode.util.LogUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import li.songe.gkd.appScope
import li.songe.gkd.data.AppRule
import li.songe.gkd.data.CategoryConfig
import li.songe.gkd.data.Rule
import li.songe.gkd.data.GlobalApp
import li.songe.gkd.data.GlobalRule
import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.SubsConfig
import li.songe.gkd.data.SubsItem
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.db.DbSet
import li.songe.selector.Selector
@ -20,23 +24,39 @@ val subsItemsFlow by lazy {
DbSet.subsItemDao.query().stateIn(appScope, SharingStarted.Eagerly, emptyList())
}
private val subsIdToMtimeFlow by lazy {
subsItemsFlow.map { it.sortedBy { s -> s.id }.associate { s -> s.id to s.mtime } }
.stateIn(appScope, SharingStarted.Eagerly, emptyMap())
val subsIdToRawFlow by lazy {
MutableStateFlow<Map<Long, RawSubscription>>(emptyMap())
}
val subsIdToRawFlow by lazy {
MutableStateFlow<Map<Long, SubscriptionRaw?>>(emptyMap())
private val updateSubsFileMutex by lazy { Mutex() }
fun updateSubscription(subscription: RawSubscription) {
appScope.launchTry {
updateSubsFileMutex.withLock {
val newMap = subsIdToRawFlow.value.toMutableMap()
newMap[subscription.id] = subscription
subsIdToRawFlow.value = newMap
withContext(Dispatchers.IO) {
subsFolder.resolve("${subscription.id}.json")
.writeText(json.encodeToString(subscription))
}
}
}
}
fun deleteSubscription(subsId: Long) {
val newMap = subsIdToRawFlow.value.toMutableMap()
newMap.remove(subsId)
subsIdToRawFlow.value = newMap
}
fun getGroupRawEnable(
groupRaw: SubscriptionRaw.GroupRaw,
rawGroup: RawSubscription.RawGroupProps,
subsConfigs: List<SubsConfig>,
category: SubscriptionRaw.Category?,
category: RawSubscription.RawCategory?,
categoryConfigs: List<CategoryConfig>
): Boolean {
// 优先级: 规则用户配置 > 批量配置 > 批量默认 > 规则默认
val groupConfig = subsConfigs.find { c -> c.groupKey == groupRaw.key }
val groupConfig = subsConfigs.find { c -> c.groupKey == rawGroup.key }
// 1.规则用户配置
return groupConfig?.enable ?: if (category != null) {// 这个规则被批量配置捕获
val categoryConfig = categoryConfigs.find { c -> c.categoryKey == category.key }
@ -50,183 +70,279 @@ fun getGroupRawEnable(
enable
} else {
null
} ?: groupRaw.enable ?: true
} ?: rawGroup.enable ?: true
}
private val appIdToRulesFlow by lazy {
private fun getFixActivityIds(
appId: String,
activityIds: List<String>?,
): List<String> {
activityIds ?: return emptyList()
return activityIds.map { activityId ->
if (activityId.startsWith('.')) { // .a.b.c -> com.x.y.x.a.b.c
appId + activityId
} else {
activityId
}
}
}
data class AllRules(
val globalRules: List<GlobalRule> = emptyList(),
val globalGroups: List<RawSubscription.RawGlobalGroup> = emptyList(),
val appIdToRules: Map<String, List<AppRule>> = emptyMap(),
val appIdToGroups: Map<String, List<RawSubscription.RawAppGroup>> = emptyMap(),
) {
val appSize = appIdToRules.keys.size
val allGroupSize = globalGroups.size + appIdToGroups.values.sumOf { s -> s.size }
}
val allRulesFlow by lazy {
combine(
subsItemsFlow,
subsIdToRawFlow,
appInfoCacheFlow,
DbSet.subsConfigDao.query(),
DbSet.categoryConfigDao.query(),
) { subsItems, subsIdToRaw, subsConfigs, categoryConfigs ->
) { subsItems, subsIdToRaw, appInfoCache, subsConfigs, categoryConfigs ->
val globalSubsConfigs = subsConfigs.filter { c -> c.type == SubsConfig.GlobalGroupType }
val appSubsConfigs = subsConfigs.filter { c -> c.type == SubsConfig.AppType }
val groupSubsConfigs = subsConfigs.filter { c -> c.type == SubsConfig.GroupType }
val appIdToRules = mutableMapOf<String, MutableList<Rule>>()
val groupSubsConfigs = subsConfigs.filter { c -> c.type == SubsConfig.AppGroupType }
val appRules = mutableMapOf<String, MutableList<AppRule>>()
val globalRules = mutableListOf<GlobalRule>()
val globalGroups = mutableListOf<RawSubscription.RawGlobalGroup>()
val appGroups = mutableMapOf<String, List<RawSubscription.RawAppGroup>>()
subsItems.filter { it.enable }.forEach { subsItem ->
val subsRaw = subsIdToRaw[subsItem.id] ?: return@forEach
val rawSubs = subsIdToRaw[subsItem.id] ?: return@forEach
val subGlobalSubsConfigs = globalSubsConfigs.filter { c -> c.subsItemId == subsItem.id }
rawSubs.globalGroups.filter { g ->
g.valid && (subGlobalSubsConfigs.find { c -> c.groupKey == g.key }?.enable
?: g.enable ?: true)
}.forEach { groupRaw ->
globalGroups.add(groupRaw)
val subRules = groupRaw.rules.mapIndexed { ruleIndex, ruleRaw ->
val apps = mutableMapOf<String, GlobalApp>()
(ruleRaw.apps ?: groupRaw.apps ?: emptyList()).forEach { a ->
apps[a.id] = GlobalApp(
id = a.id,
enable = a.enable ?: true,
activityIds = a.activityIds ?: emptyList(),
excludeActivityIds = a.excludeActivityIds ?: emptyList()
)
}
val matchAnyApp = ruleRaw.matchAnyApp ?: groupRaw.matchAnyApp ?: false
val quickFind =
ruleRaw.quickFind ?: groupRaw.quickFind ?: false
val matchDelay =
ruleRaw.matchDelay ?: groupRaw.matchDelay ?: 0
val matchTime = ruleRaw.matchTime ?: groupRaw.matchTime
val resetMatch =
ruleRaw.resetMatch ?: groupRaw.resetMatch
val actionDelay =
ruleRaw.actionDelay ?: groupRaw.actionDelay ?: 0
GlobalRule(
quickFind = quickFind,
actionDelay = actionDelay,
index = ruleIndex,
matches = ruleRaw.matches.map { Selector.parse(it) },
excludeMatches = (ruleRaw.excludeMatches ?: emptyList()).map {
Selector.parse(
it
)
},
matchDelay = matchDelay,
matchTime = matchTime,
key = ruleRaw.key,
preKeys = (ruleRaw.preKeys ?: emptyList()).toSet(),
rule = ruleRaw,
group = groupRaw,
subsItem = subsItem,
resetMatch = resetMatch,
matchAnyApp = matchAnyApp,
apps = apps,
rawSubs = rawSubs,
)
}
subRules.forEach { ruleConfig ->
// 保留原始对象引用, 方便判断 lastTriggerRule 时直接使用 ===
ruleConfig.preAppRules = subRules.filter { otherRule ->
(otherRule.key != null) && ruleConfig.preKeys.contains(
otherRule.key
)
}.toSet()
// 共用次数
val maxKey =
ruleConfig.rule.actionMaximumKey ?: ruleConfig.group.actionMaximumKey
if (maxKey != null) {
val otherRule = subRules.find { r -> r.key == maxKey }
if (otherRule != null) {
ruleConfig.actionCount = otherRule.actionCount
}
}
// 共用 cd
val cdKey = ruleConfig.rule.actionCdKey ?: ruleConfig.group.actionCdKey
if (cdKey != null) {
val otherRule = subRules.find { r -> r.key == cdKey }
if (otherRule != null) {
ruleConfig.actionTriggerTime = otherRule.actionTriggerTime
}
}
}
globalRules.addAll(subRules)
}
val subAppSubsConfigs = appSubsConfigs.filter { c -> c.subsItemId == subsItem.id }
val subGroupSubsConfigs = groupSubsConfigs.filter { c -> c.subsItemId == subsItem.id }
val subCategoryConfigs = categoryConfigs.filter { c -> c.subsItemId == subsItem.id }
subsRaw.apps.filter { appRaw ->
rawSubs.apps.filter { appRaw ->
// 筛选 当前启用的 app 订阅规则
(subAppSubsConfigs.find { c -> c.appId == appRaw.id }?.enable ?: true)
(subAppSubsConfigs.find { c -> c.appId == appRaw.id }?.enable
?: (appInfoCache[appRaw.id] != null))
}.forEach { appRaw ->
val subAppGroups = mutableListOf<RawSubscription.RawAppGroup>()
val appGroupConfigs = subGroupSubsConfigs.filter { c -> c.appId == appRaw.id }
val rules = appIdToRules[appRaw.id] ?: mutableListOf()
appIdToRules[appRaw.id] = rules
appRaw.groups.filter { groupRaw ->
getGroupRawEnable(
groupRaw.valid && getGroupRawEnable(
groupRaw,
appGroupConfigs,
subsRaw.groupToCategoryMap[groupRaw],
rawSubs.groupToCategoryMap[groupRaw],
subCategoryConfigs
)
}.filter { groupRaw ->
// 筛选合法选择器的规则组, 如果一个规则组内某个选择器语法错误, 则禁用/丢弃此规则组
groupRaw.valid
}.forEach { groupRaw ->
val groupRuleList = mutableListOf<Rule>()
groupRaw.rules.forEachIndexed { ruleIndex, ruleRaw ->
subAppGroups.add(groupRaw)
val subRules = groupRaw.rules.mapIndexed { ruleIndex, ruleRaw ->
val activityIds =
(ruleRaw.activityIds ?: groupRaw.activityIds ?: appRaw.activityIds
?: emptyList()).map { activityId ->
if (activityId.startsWith('.')) { // .a.b.c -> com.x.y.x.a.b.c
appRaw.id + activityId
} else {
activityId
}
}.toSet()
getFixActivityIds(
appRaw.id,
ruleRaw.activityIds ?: groupRaw.activityIds
)
val excludeActivityIds =
(ruleRaw.excludeActivityIds ?: groupRaw.excludeActivityIds
?: appRaw.excludeActivityIds ?: emptyList()).map { activityId ->
if (activityId.startsWith('.')) { // .a.b.c -> com.x.y.x.a.b.c
appRaw.id + activityId
} else {
activityId
}
}.toSet()
val excludeActivityIds = getFixActivityIds(
appRaw.id,
ruleRaw.excludeActivityIds ?: groupRaw.excludeActivityIds,
)
val quickFind =
ruleRaw.quickFind ?: groupRaw.quickFind ?: appRaw.quickFind ?: false
ruleRaw.quickFind ?: groupRaw.quickFind ?: false
val matchDelay =
ruleRaw.matchDelay ?: groupRaw.matchDelay ?: appRaw.matchDelay
val matchTime = ruleRaw.matchTime ?: groupRaw.matchTime ?: appRaw.matchTime
ruleRaw.matchDelay ?: groupRaw.matchDelay ?: 0
val matchTime = ruleRaw.matchTime ?: groupRaw.matchTime
val resetMatch =
ruleRaw.resetMatch ?: groupRaw.resetMatch ?: appRaw.resetMatch
ruleRaw.resetMatch ?: groupRaw.resetMatch
val actionDelay =
ruleRaw.actionDelay ?: groupRaw.actionDelay ?: appRaw.actionDelay ?: 0
ruleRaw.actionDelay ?: groupRaw.actionDelay ?: 0
groupRuleList.add(
Rule(
quickFind = quickFind,
actionDelay = actionDelay,
index = ruleIndex,
matches = ruleRaw.matches.map { Selector.parse(it) },
excludeMatches = (ruleRaw.excludeMatches ?: emptyList()).map {
Selector.parse(
it
)
},
matchDelay = matchDelay,
matchTime = matchTime,
appId = appRaw.id,
activityIds = activityIds,
excludeActivityIds = excludeActivityIds,
key = ruleRaw.key,
preKeys = (ruleRaw.preKeys ?: emptyList()).toSet(),
rule = ruleRaw,
group = groupRaw,
app = appRaw,
subsItem = subsItem,
resetMatch = resetMatch,
)
AppRule(
quickFind = quickFind,
actionDelay = actionDelay,
index = ruleIndex,
matches = ruleRaw.matches.map { Selector.parse(it) },
excludeMatches = (ruleRaw.excludeMatches ?: emptyList()).map {
Selector.parse(
it
)
},
matchDelay = matchDelay,
matchTime = matchTime,
appId = appRaw.id,
activityIds = activityIds,
excludeActivityIds = excludeActivityIds,
key = ruleRaw.key,
preKeys = (ruleRaw.preKeys ?: emptyList()).toSet(),
rule = ruleRaw,
group = groupRaw,
app = appRaw,
subsItem = subsItem,
resetMatch = resetMatch,
rawSubs = rawSubs,
)
}
groupRuleList.forEach { ruleConfig ->
subRules.forEach { ruleConfig ->
// 保留原始对象引用, 方便判断 lastTriggerRule 时直接使用 ===
ruleConfig.preRules = groupRuleList.filter { otherRule ->
ruleConfig.preAppRules = subRules.filter { otherRule ->
(otherRule.key != null) && ruleConfig.preKeys.contains(
otherRule.key
)
}.toSet()
// 共用次数
val maxKey = ruleConfig.rule.actionMaximumKey
val maxKey =
ruleConfig.rule.actionMaximumKey ?: ruleConfig.group.actionMaximumKey
if (maxKey != null) {
val otherRule = groupRuleList.find { r -> r.key == maxKey }
val otherRule = subRules.find { r -> r.key == maxKey }
if (otherRule != null) {
ruleConfig.actionCount = otherRule.actionCount
}
}
// 共用 cd
val cdKey = ruleConfig.rule.actionCdKey
val cdKey = ruleConfig.rule.actionCdKey ?: ruleConfig.group.actionCdKey
if (cdKey != null) {
val otherRule = groupRuleList.find { r -> r.key == cdKey }
val otherRule = subRules.find { r -> r.key == cdKey }
if (otherRule != null) {
ruleConfig.actionTriggerTime = otherRule.actionTriggerTime
}
}
}
rules.addAll(groupRuleList)
if (subRules.isNotEmpty()) {
val rules = appRules[appRaw.id] ?: mutableListOf()
appRules[appRaw.id] = rules
rules.addAll(subRules)
}
}
if (subAppGroups.isNotEmpty()) {
appGroups[appRaw.id] = subAppGroups
}
}
}
appIdToRules.values.forEach { rules ->
// 让开屏广告类规则全排在最前面
rules.sortBy { r -> if (r.isOpenAd) 0 else 1 }
}
appIdToRules.filter { it.value.isNotEmpty() }
}.stateIn(appScope, SharingStarted.Eagerly, emptyMap<String, List<Rule>>())
}
data class AppRule(
val visibleMap: Map<String, List<Rule>> = emptyMap(),
val unVisibleMap: Map<String, List<Rule>> = emptyMap(),
val allMap: Map<String, List<Rule>> = emptyMap(),
)
val appRuleFlow by lazy {
combine(appIdToRulesFlow, appInfoCacheFlow) { appIdToRules, appInfoCache ->
val visibleMap = mutableMapOf<String, List<Rule>>()
val unVisibleMap = mutableMapOf<String, List<Rule>>()
appIdToRules.forEach { (appId, rules) ->
if (appInfoCache.containsKey(appId)) {
visibleMap[appId] = rules
} else {
unVisibleMap[appId] = rules
}
}
AppRule(
visibleMap = visibleMap, unVisibleMap = unVisibleMap, allMap = appIdToRules
AllRules(
appIdToRules = appRules,
globalRules = globalRules,
globalGroups = globalGroups,
appIdToGroups = appGroups,
)
}.stateIn(appScope, SharingStarted.Eagerly, AppRule())
}.stateIn(appScope, SharingStarted.Eagerly, AllRules())
}
fun initSubsState() {
subsItemsFlow.value
subsIdToRawFlow.value
appScope.launchTry {
// reload subsRaw file by diff
var oldSubsIdToMtime = emptyMap<Long, Long>()
subsIdToMtimeFlow.collect { subsIdToMtime ->
val commonMap = subsIdToMtime.filter { e -> oldSubsIdToMtime[e.key] == e.value }
oldSubsIdToMtime = subsIdToMtime
val oldSubsIdToRaw = subsIdToRawFlow.value
subsIdToRawFlow.value = subsIdToMtime.map { entry ->
if (commonMap.containsKey(entry.key)) {
entry.key to oldSubsIdToRaw[entry.key]
} else {
val newSubsRaw =
withContext(Dispatchers.IO) { SubsItem.getSubscriptionRaw(entry.key) }
newSubsRaw?.apps?.apply {
updateAppInfo(*map { a -> a.id }.toTypedArray())
}
entry.key to newSubsRaw
appScope.launchTry(Dispatchers.IO) {
if (subsFolder.exists() && subsFolder.isDirectory) {
val fileRegex = Regex("^-?\\d+\\.json$")
val files =
subsFolder.listFiles { f -> f.isFile && f.name.matches(fileRegex) } ?: emptyArray()
val subscriptions = files.mapNotNull { f ->
try {
RawSubscription.parse(f.readText())
} catch (e: Exception) {
LogUtils.d("加载订阅文件失败", e)
null
}
}.toMap()
}
val newMap = subsIdToRawFlow.value.toMutableMap()
subscriptions.forEach { s ->
newMap[s.id] = s
}
if (newMap[-2] == null) {
newMap[-2] = RawSubscription(
id = -2,
name = "本地订阅",
version = 0,
author = "gkd",
)
}
subsIdToRawFlow.value = newMap
}
var oldAppIds = emptySet<String>()
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
}
}
}

View File

@ -90,9 +90,9 @@ fun startDownload(newVersion: NewVersion) {
} else if (downloadStatus is LoadStatus.Failure) {
// 提前终止下载
job?.cancel()
}
}
}.bodyAsChannel()
}
}.bodyAsChannel()
if (downloadStatusFlow.value is LoadStatus.Loading) {
channel.copyAndClose(newApkFile.writeChannel())
downloadStatusFlow.value = LoadStatus.Success(newApkFile.absolutePath)
@ -109,7 +109,6 @@ fun startDownload(newVersion: NewVersion) {
fun UpgradeDialog() {
val newVersion by newVersionFlow.collectAsState()
newVersion?.let { newVersionVal ->
AlertDialog(title = {
Text(text = "检测到新版本")
}, text = {