feat: 基本可用

This commit is contained in:
lisonge 2023-08-31 22:22:03 +08:00
parent b087a250aa
commit bff13cbac0
84 changed files with 2567 additions and 1506 deletions

View File

@ -1,30 +1,10 @@
# gkd
搞快点顾名思义做快速点击一款基于无障碍和自定义匹配规则的快速点击app
基于 *无障碍* + *高级选择器* + *订阅规则* 的自定义屏幕点击 APP
## feature
- 扩展自css选择器语法的高级选择语法
- 跳过启动页面
- 关闭app内部广告
- 任意点击操作, 组合操作
- 支持导入订阅链接, 订阅支持 json/json5/yaml/toml 格式
- 支持浏览器直接跳转导入规则至app
- 无障碍模式, shizuku 模式, root 模式, 设备管理员 模式
根据 [高级选择器](https://github.com/gkd-kit/selector) + [订阅规则](https://github.com/gkd-kit/subscription), 它可以实现
# Fix Bug
## Android Studio kts lint error
replace default "Embedded JDK" with download jdk by `File->Project Structrue...->SDK Location->Gradle Settings->Gradle JDK`
## Layout Inspector not working
开发者选项 - 启用视图属性检查功能
## 开发辅助文档
- <https://foso.github.io/Jetpack-Compose-Playground/>
- <https://google.github.io/accompanist/>
-
- 点击跳过任意开屏广告/点击关闭应用内部任意弹窗广告, 如关闭百度贴吧帖子广告卡片/知乎回答底部推荐广告卡片
- 一些快捷操作, 如微信电脑登录自动同意/微信扫描登录自动同意/微信抢红包

File diff suppressed because one or more lines are too long

View File

@ -3,18 +3,16 @@ import java.text.SimpleDateFormat
import java.util.Locale
plugins {
id("com.android.application")
id("kotlin-parcelize")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization")
id("org.jetbrains.kotlin.kapt")
id("com.google.devtools.ksp")
id("dev.rikka.tools.refine")
id("com.google.dagger.hilt.android")
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.google.ksp)
alias(libs.plugins.google.hilt)
alias(libs.plugins.rikka.refine)
}
@Suppress("UnstableApiUsage")
android {
namespace = "li.songe.gkd"
compileSdk = libs.versions.android.compileSdk.get().toInt()
@ -155,7 +153,7 @@ dependencies {
implementation(libs.ktor.server.content.negotiation)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.android)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
@ -173,10 +171,14 @@ dependencies {
implementation(libs.others.floating.bubble.view)
implementation(libs.destinations.core)
implementation(libs.destinations.animations)
// implementation(libs.destinations.animations)
ksp(libs.destinations.ksp)
implementation(libs.google.hilt.android)
kapt(libs.google.hilt.android.compiler)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.others.reorderable)
implementation(libs.androidx.splashscreen)
}

View File

@ -17,11 +17,13 @@
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- update self -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!--
APP 有两个进程, 主进程 + :remote 进程
主进程: 主要是 activity 的前端界面
remote进程: 主要是 service
优点: 在最近任务界面删除当前APP的窗口记录时,不会让 remote进程里的 service 停止
-->
<application
@ -41,7 +43,8 @@
<activity
android:name="li.songe.gkd.MainActivity"
android:configChanges="uiMode|screenSize|orientation|keyboardHidden|touchscreen|smallestScreenSize|screenLayout|navigation|mnc|mcc|locale|layoutDirection|keyboard|fontWeightAdjustment|fontScale|density|colorMode"
android:exported="true">
android:exported="true"
android:theme="@style/SplashScreenTheme">
<!--
about android:configChanges
@ -50,11 +53,15 @@
-->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="import-subs"
android:path="/"
android:host="import"
android:scheme="gkd" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@ -4,30 +4,29 @@ import android.app.Application
import android.content.Context
import android.os.Build
import com.blankj.utilcode.util.LogUtils
import com.blankj.utilcode.util.ProcessUtils
import com.tencent.bugly.crashreport.CrashReport
import com.tencent.mmkv.MMKV
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import li.songe.gkd.data.getAppInfo
import li.songe.gkd.db.DbSet
import li.songe.gkd.notif.initChannel
import li.songe.gkd.util.initAppState
import li.songe.gkd.util.initStore
import li.songe.gkd.util.initSubsState
import li.songe.gkd.util.initUpgrade
import li.songe.gkd.util.isMainProcess
import org.lsposed.hiddenapibypass.HiddenApiBypass
import rikka.shizuku.ShizukuProvider
lateinit var app: Application
var appScope = MainScope()
val appScope by lazy { MainScope() }
private lateinit var _app: Application
val app: Application
get() = _app
@HiltAndroidApp
class App : Application() {
override fun onLowMemory() {
super.onLowMemory()
appScope.cancel("onLowMemory() called by system")
appScope = MainScope()
}
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
@ -37,7 +36,8 @@ class App : Application() {
override fun onCreate() {
super.onCreate()
app = this
_app = this
LogUtils.d("App onCreate", ProcessUtils.getCurrentProcessName())
MMKV.initialize(this)
LogUtils.getConfig().apply {
isLog2FileSwitch = true
@ -47,12 +47,11 @@ class App : Application() {
CrashReport.initCrashReport(applicationContext, "d0ce46b353", false)
if (isMainProcess) {
appScope.launch(Dispatchers.IO) {
// 提前获取 appInfo 缓存
DbSet.subsItemDao.query().collect {
it.forEach { s -> s.subscriptionRaw?.apps?.forEach { app -> getAppInfo(app.id) } }
}
}
initChannel()
initStore()
initAppState()
initSubsState()
initUpgrade()
}
}
}

View File

@ -2,10 +2,13 @@ package li.songe.gkd
import android.os.Build
import android.view.WindowManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.navigation.compose.rememberNavController
import com.blankj.utilcode.util.LogUtils
import com.dylanc.activityresult.launcher.StartActivityLauncher
import com.ramcosta.composedestinations.DestinationsNavHost
import dagger.hilt.android.AndroidEntryPoint
@ -15,16 +18,19 @@ import li.songe.gkd.ui.NavGraphs
import li.songe.gkd.ui.theme.AppTheme
import li.songe.gkd.util.LocalLauncher
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.UpgradeDialog
import li.songe.gkd.util.storeFlow
@AndroidEntryPoint
class MainActivity : CompositionActivity({
installSplashScreen()
useLifeCycleLog()
val launcher = StartActivityLauncher(this)
onFinish { fs ->
if (storeFlow.value.excludeFromRecents) {
finishAndRemoveTask() // 会让miui桌面回退动画失效
LogUtils.d("finishAndRemoveTask")
} else {
fs()
}
@ -36,47 +42,16 @@ class MainActivity : CompositionActivity({
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
// lifecycleScope.launchTry {
// delay(1000)
// WindowCompat.setDecorFitsSystemWindows(window, false)
// val insetsController = WindowCompat.getInsetsController(window, window.decorView)
// insetsController.hide(WindowInsetsCompat.Type.statusBars())
// }
// var shizukuIsOK = false
// val receivedListener: () -> Unit = {
// shizukuIsOK = true
// }
// Shizuku.addBinderReceivedListenerSticky(receivedListener)
// onDestroy {
// Shizuku.removeBinderReceivedListener(receivedListener)
// }
// lifecycleScope.launchWhile {
// if (shizukuIsOK) {
// val top = activityTaskManager.getTasks(1, false, true)?.firstOrNull()
// if (top!=null) {
// LogUtils.d(top.topActivity?.packageName, top.topActivity?.className, top.topActivity?.shortClassName)
// }
// }
// delay(5000)
// }
// lifecycleScope.launchTry(IO) {
// File("/sdcard/Android/data/${packageName}/files/snapshot").walk().maxDepth(1)
// .filter { it.isDirectory && !it.name.endsWith("snapshot") }.forEach { folder ->
// val snapshot = Singleton.json.decodeFromString<Snapshot>(File(folder.absolutePath + "/${folder.name}.json").readText())
// try {
// DbSet.snapshotDao.insert(snapshot)
// }catch (e:Exception){
// e.printStackTrace()
// LogUtils.d("insert failed, ${snapshot.id}")
// return@launchTry
// }
// }
// }
setContent {
UpgradeDialog()
val navController = rememberNavController()
BackHandler {
if (navController.currentBackStack.value.size <= 2) {
finish()
} else {
navController.popBackStack()
}
}
AppTheme(false) {
CompositionLocalProvider(
LocalLauncher provides launcher, LocalNavController provides navController

View File

@ -0,0 +1,7 @@
package li.songe.gkd.composition
import android.view.KeyEvent
interface CanOnKeyEvent {
fun onKeyEvent(f: (KeyEvent?) -> Unit): Unit
}

View File

@ -2,16 +2,13 @@ package li.songe.gkd.composition
import android.accessibilityservice.AccessibilityService
import android.content.Intent
import android.view.KeyEvent
import android.view.accessibility.AccessibilityEvent
open class CompositionAbService(
private val block: CompositionAbService.() -> Unit,
) : AccessibilityService(),
CanOnDestroy,
CanOnStartCommand,
CanOnAccessibilityEvent,
CanOnServiceConnected,
CanOnInterrupt {
) : AccessibilityService(), CanOnDestroy, CanOnStartCommand, CanOnAccessibilityEvent,
CanOnServiceConnected, CanOnInterrupt, CanOnKeyEvent {
override fun onCreate() {
super.onCreate()
block(this)
@ -52,4 +49,15 @@ open class CompositionAbService(
super.onServiceConnected()
onServiceConnectedHooks.forEach { f -> f() }
}
private val onKeyEventHooks by lazy { linkedSetOf<(KeyEvent?) -> Unit>() }
override fun onKeyEvent(event: KeyEvent?): Boolean {
onKeyEventHooks.forEach { f -> f(event) }
return super.onKeyEvent(event)
}
override fun onKeyEvent(f: (KeyEvent?) -> Unit) {
onKeyEventHooks.add(f)
}
}

View File

@ -1,41 +1,11 @@
package li.songe.gkd.data
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import li.songe.gkd.app
import li.songe.gkd.util.Ext.getApplicationInfoExt
data class AppInfo(
val id: String,
val name: String? = null,
val icon: Drawable? = null,
val installed: Boolean = true,
) {
val realName = if (name.isNullOrBlank()) id else name
}
private val appInfoCache = mutableMapOf<String, AppInfo>()
fun getAppInfo(id: String): AppInfo {
appInfoCache[id]?.let { return it }
val packageManager = app.packageManager
val info = try { // 需要权限
val rawInfo = app.packageManager.getApplicationInfoExt(
id, PackageManager.GET_META_DATA
)
AppInfo(
id = id,
name = packageManager.getApplicationLabel(rawInfo).toString(),
icon = packageManager.getApplicationIcon(rawInfo),
)
} catch (e: Exception) {
return AppInfo(id = id, installed = false)
}
appInfoCache[id] = info
return info
}
fun getAppName(id: String?): String? {
id ?: return null
return getAppInfo(id).name
}
val name: String,
val icon: Drawable,
val versionCode: Int,
val versionName: String,
)

View File

@ -11,36 +11,11 @@ data class AttrInfo(
val id: String?,
val name: String?,
val text: String?,
val textLen: Int? = text?.length,
val desc: String?,
val descLen: Int? = desc?.length,
val hint: String?,
val hintLen: Int? = hint?.length,
val error: String?,
val errorLen: Int? = error?.length,
val inputType: Int?,
val liveRegion: Int?,
val enabled: Boolean,
val clickable: Boolean,
val checked: Boolean,
val checkable: Boolean,
val focused: Boolean,
val focusable: Boolean,
val visibleToUser: Boolean,
val selected: Boolean,
val longClickable: Boolean,
val password: Boolean,
val scrollable: Boolean,
val accessibilityFocused: Boolean,
val editable: Boolean,
val canOpenPopup: Boolean,
val dismissable: Boolean,
val multiLine: Boolean,
val contentInvalid: Boolean,
val contextClickable: Boolean,
val importance: Boolean,
val showingHintText: Boolean,
val left: Int,
val top: Int,
@ -68,31 +43,10 @@ data class AttrInfo(
name = node.className?.toString(),
text = node.text?.toString(),
desc = node.contentDescription?.toString(),
hint = node.hintText?.toString(),
error = node.error?.toString(),
inputType = node.inputType,
liveRegion = node.liveRegion,
enabled = node.isEnabled,
clickable = node.isClickable,
checked = node.isChecked,
checkable = node.isCheckable,
focused = node.isFocused,
focusable = node.isFocusable,
visibleToUser = node.isVisibleToUser,
selected = node.isSelected,
longClickable = node.isLongClickable,
password = node.isPassword,
scrollable = node.isScrollable,
accessibilityFocused = node.isAccessibilityFocused,
editable = node.isEditable,
canOpenPopup = node.canOpenPopup(),
dismissable = node.isDismissable,
multiLine = node.isMultiLine,
contentInvalid = node.isContentInvalid,
contextClickable = node.isContextClickable,
importance = node.isImportantForAccessibility,
showingHintText = node.isShowingHintText,
left = rect.left,
top = rect.top,

View File

@ -13,10 +13,10 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.parcelize.Parcelize
@Entity(
tableName = "trigger_log",
tableName = "click_log",
)
@Parcelize
data class TriggerLog(
data class ClickLog(
@PrimaryKey @ColumnInfo(name = "id") val id: Long = System.currentTimeMillis(),
@ColumnInfo(name = "app_id") val appId: String? = null,
@ColumnInfo(name = "activity_id") val activityId: String? = null,
@ -29,18 +29,22 @@ data class TriggerLog(
interface TriggerLogDao {
@Update
suspend fun update(vararg objects: TriggerLog): Int
suspend fun update(vararg objects: ClickLog): Int
@Insert
suspend fun insert(vararg objects: TriggerLog): List<Long>
suspend fun insert(vararg objects: ClickLog): List<Long>
@Delete
suspend fun delete(vararg objects: TriggerLog): Int
suspend fun delete(vararg objects: ClickLog): Int
@Query("SELECT * FROM trigger_log ORDER BY id DESC")
fun query(): Flow<List<TriggerLog>>
@Query("SELECT * FROM click_log ORDER BY id DESC LIMIT 1000")
fun query(): Flow<List<ClickLog>>
@Query("SELECT COUNT(*) FROM trigger_log")
@Query("SELECT COUNT(*) FROM click_log")
fun count(): Flow<Int>
@Query("SELECT * FROM click_log ORDER BY id DESC LIMIT 1")
fun queryLatest(): Flow<ClickLog?>
}
}

View File

@ -0,0 +1,7 @@
package li.songe.gkd.data
sealed class FileStatus<T> {
object NotFound : FileStatus<Nothing>()
data class Failure(val e: Exception) : FileStatus<Nothing>()
data class Ok<T>(val data: T) : FileStatus<T>()
}

View File

@ -1,6 +1,7 @@
package li.songe.gkd.data
import android.view.accessibility.AccessibilityNodeInfo
import li.songe.gkd.service.lastTriggerRuleFlow
import li.songe.gkd.service.querySelector
import li.songe.selector.Selector
@ -11,7 +12,7 @@ data class Rule(
val matches: List<Selector> = emptyList(),
val excludeMatches: List<Selector> = emptyList(),
/**
* 任意一个元素是上次触发过的
* 任意一个元素是上次点击过的
*/
val preRules: Set<Rule> = emptySet(),
val cd: Long = defaultMiniCd,
@ -21,18 +22,20 @@ data class Rule(
val excludeActivityIds: Set<String> = emptySet(),
val key: Int? = null,
val preKeys: Set<Int> = emptySet(),
val rule: SubscriptionRaw.RuleRaw,
val group: SubscriptionRaw.GroupRaw,
val app: SubscriptionRaw.AppRaw,
val subsItem: SubsItem,
) {
private var triggerTime = 0L
fun trigger() {
triggerTime = System.currentTimeMillis()
lastTriggerRuleFlow.value = this
}
val active: Boolean
get() = triggerTime + cd < System.currentTimeMillis()
val matchAnyActivity = activityIds.contains("*")
fun query(nodeInfo: AccessibilityNodeInfo?): AccessibilityNodeInfo? {
if (nodeInfo == null) return null
@ -46,6 +49,13 @@ data class Rule(
return target
}
fun matchActivityId(activityId: String?): Boolean {
if (activityId == null) return false
if (excludeActivityIds.any { activityId.startsWith(it) }) return false
if (activityIds.isEmpty()) return true
return activityIds.any { activityId.startsWith(it) }
}
companion object {
const val defaultMiniCd = 1000L
}

View File

@ -1,115 +0,0 @@
package li.songe.gkd.data
import li.songe.selector.Selector
class RuleManager(subsItems: List<SubsItem> = listOf()) {
private data class TriggerRecord(val ctime: Long = System.currentTimeMillis(), val rule: Rule)
private val appToRulesMap = mutableMapOf<String, MutableList<Rule>>()
private val triggerLogQueue = ArrayDeque<TriggerRecord>()
fun trigger(rule: Rule) {
rule.trigger()
triggerLogQueue.addLast(TriggerRecord(rule = rule))
while (triggerLogQueue.size >= 256) {
triggerLogQueue.removeFirst()
}
}
fun match(appId: String? = null, activityId: String? = null) = sequence {
if (appId == null) return@sequence
val rules = appToRulesMap[appId] ?: return@sequence
if (activityId == null) {
yieldAll(rules)
return@sequence
}
rules.forEach { rule ->
if (rule.excludeActivityIds.any { activityId.startsWith(it) }) return@forEach // 是被排除的 界面 id
if (rule.matchAnyActivity || rule.activityIds.any { activityId.startsWith(it) } // 在匹配列表
) {
yield(rule)
}
}
}
fun ruleIsAvailable(rule: Rule): Boolean {
if (!rule.active) return false // 处于冷却时间
if (rule.preKeys.isNotEmpty()) { // 需要提前触发某个规则
if (rule.preRules.isEmpty()) return false // 声明了 preKeys 但是没有在当前列表找到
val record = triggerLogQueue.lastOrNull() ?: return false
if (!rule.preRules.any { it == record.rule }) return false // 上一个触发的规则不在当前需要触发的列表
}
return true
}
init {
subsItems.filter { s -> s.enable }.forEach { subsItem ->
val subscriptionRaw = subsItem.subscriptionRaw ?: return@forEach
subscriptionRaw.apps.forEach { appRaw ->
val ruleConfigList = appToRulesMap[appRaw.id] ?: mutableListOf()
appToRulesMap[appRaw.id] = ruleConfigList
appRaw.groups.forEach { groupRaw ->
val ruleGroupList = mutableListOf<Rule>()
groupRaw.rules.forEachIndexed ruleEach@{ ruleIndex, ruleRaw ->
if (ruleRaw.matches.isEmpty()) return@ruleEach
val cd = Rule.defaultMiniCd.coerceAtLeast(
ruleRaw.cd ?: groupRaw.cd ?: appRaw.cd ?: Rule.defaultMiniCd
)
val activityIds =
(ruleRaw.activityIds ?: groupRaw.activityIds ?: appRaw.activityIds
?: listOf("*")).map { activityId ->
if (activityId.startsWith('.')) { // .a.b.c -> com.x.y.x.a.b.c
return@map appRaw.id + activityId
}
activityId
}.toSet()
val excludeActivityIds =
(ruleRaw.excludeActivityIds ?: groupRaw.excludeActivityIds
?: appRaw.excludeActivityIds ?: emptyList()).toSet()
ruleGroupList.add(
Rule(
cd = cd,
index = ruleIndex,
matches = ruleRaw.matches.map { Selector.parse(it) },
excludeMatches = ruleRaw.excludeMatches.map {
Selector.parse(
it
)
},
appId = appRaw.id,
activityIds = activityIds,
excludeActivityIds = excludeActivityIds,
key = ruleRaw.key,
preKeys = ruleRaw.preKeys.toSet(),
group = groupRaw,
subsItem = subsItem
)
)
}
ruleGroupList.forEachIndexed { index, ruleConfig ->
ruleGroupList[index] = ruleConfig.copy(
preRules = ruleGroupList.filter {
(it.key != null) && it.preKeys.contains(
it.key
)
}.toSet()
)
}
ruleConfigList.addAll(ruleGroupList)
}
}
}
}
}

View File

@ -16,6 +16,7 @@ import kotlinx.serialization.Serializable
import li.songe.gkd.service.GkdAbService
import li.songe.gkd.db.IgnoreConverters
import li.songe.gkd.debug.SnapshotExt
import li.songe.gkd.service.activityIdFlow
import java.io.File
@TypeConverters(IgnoreConverters::class)
@ -27,7 +28,7 @@ data class Snapshot(
@PrimaryKey @ColumnInfo(name = "id") val id: Long = System.currentTimeMillis(),
@ColumnInfo(name = "app_id") val appId: String? = null,
@ColumnInfo(name = "activity_id") val activityId: String? = null,
@ColumnInfo(name = "app_name") val appName: String? = getAppName(appId),
@ColumnInfo(name = "app_name") val appName: String? = AppUtils.getAppName(appId),
@ColumnInfo(name = "app_version_code") val appVersionCode: Int? = appId?.let {
AppUtils.getAppVersionCode(
appId
@ -68,7 +69,7 @@ data class Snapshot(
): Snapshot {
val currentAbNode = GkdAbService.currentAbNode
val appId = currentAbNode?.packageName?.toString()
val currentActivityId = GkdAbService.currentActivityId
val currentActivityId = activityIdFlow.value
return Snapshot(
appId = appId,
activityId = currentActivityId,

View File

@ -19,16 +19,15 @@ import kotlinx.parcelize.Parcelize
@Parcelize
data class SubsConfig(
@PrimaryKey @ColumnInfo(name = "id") val id: Long = System.currentTimeMillis(),
@ColumnInfo(name = "type") val type: Int = SubsType,
@ColumnInfo(name = "enable") val enable: Boolean = true,
@ColumnInfo(name = "type") val type: Int,
@ColumnInfo(name = "enable") val enable: Boolean,
@ColumnInfo(name = "subs_item_id") val subsItemId: Long ,
@ColumnInfo(name = "subs_item_id") val subsItemId: Long,
@ColumnInfo(name = "app_id") val appId: String = "",
@ColumnInfo(name = "group_key") val groupKey: Int = -1,
) : Parcelable {
companion object {
const val SubsType = 0
const val AppType = 1
const val GroupType = 2
}
@ -51,14 +50,12 @@ data class SubsConfig(
@Query("SELECT * FROM subs_config")
fun query(): Flow<List<SubsConfig>>
@Query("SELECT * FROM subs_config WHERE type=${SubsType}")
fun querySubsTypeConfig(): Flow<List<SubsConfig>>
@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 and app_id=:appId")
fun queryGroupTypeConfig(subsItemId: Long, appId: String): Flow<List<SubsConfig>>
fun queryGroupTypeConfig(subsItemId: Long, appId: String): Flow<List<SubsConfig>>
}
}

View File

@ -1,5 +1,6 @@
package li.songe.gkd.data
import android.content.ContentValues
import android.os.Parcelable
import androidx.room.ColumnInfo
import androidx.room.Dao
@ -23,18 +24,15 @@ import java.io.File
)
@Parcelize
data class SubsItem(
@PrimaryKey @ColumnInfo(name = "id") val id: Long = System.currentTimeMillis(),
@PrimaryKey @ColumnInfo(name = "id") val id: Long,
@ColumnInfo(name = "ctime") val ctime: Long = System.currentTimeMillis(),
@ColumnInfo(name = "mtime") val mtime: Long = System.currentTimeMillis(),
@ColumnInfo(name = "enable") val enable: Boolean = true,
@ColumnInfo(name = "enable_update") val enableUpdate: Boolean = true,
@ColumnInfo(name = "order") val order: Int = 1,
@ColumnInfo(name = "order") val order: Int,
// 订阅文件的根字段
@ColumnInfo(name = "name") val name: String = "",
@ColumnInfo(name = "author") val author: String = "",
@ColumnInfo(name = "version") val version: Int = 1,
@ColumnInfo(name = "update_url") val updateUrl: String = "",
@ColumnInfo(name = "support_url") val supportUrl: String = "",
@ColumnInfo(name = "update_url") val updateUrl: String,
) : Parcelable {
@ -43,14 +41,16 @@ data class SubsItem(
File(FolderExt.subsFolder.absolutePath.plus("/${id}.json"))
}
@IgnoredOnParcel
val subscriptionRaw by lazy {
try {
SubscriptionRaw.parse5(subsFile.readText())
} catch (e: Exception) {
e.printStackTrace()
null
}
fun toContentValues(): ContentValues {
val values = ContentValues()
values.put("id", id)
values.put("ctime", ctime)
values.put("mtime", mtime)
values.put("enable", enable)
values.put("enable_update", enableUpdate)
values.put("`order`", order)
values.put("update_url", updateUrl)
return values
}
suspend fun removeAssets() {
@ -62,9 +62,14 @@ data class SubsItem(
}
companion object {
fun getSubscriptionRaw(subsItemId: Long): SubscriptionRaw? {
return try {
SubscriptionRaw.parse5(File(FolderExt.subsFolder.absolutePath.plus("/${subsItemId}.json")).readText())
val file = File(FolderExt.subsFolder.absolutePath.plus("/${subsItemId}.json"))
if (!file.exists()) {
return null
}
return SubscriptionRaw.parse(file.readText())
} catch (e: Exception) {
e.printStackTrace()
null
@ -88,8 +93,7 @@ data class SubsItem(
fun query(): Flow<List<SubsItem>>
@Query("SELECT * FROM subs_item WHERE id=:id")
fun queryById(id: Long): SubsItem?
fun queryById(id: Long): Flow<SubsItem?>
}
}

View File

@ -1,6 +1,7 @@
package li.songe.gkd.data
import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@ -13,6 +14,7 @@ import li.songe.selector.Selector
@Parcelize
@Serializable
data class SubscriptionRaw(
@SerialName("id") val id: Long,
@SerialName("name") val name: String,
@SerialName("version") val version: Int,
@SerialName("author") val author: String? = null,
@ -21,27 +23,98 @@ data class SubscriptionRaw(
@SerialName("apps") val apps: List<AppRaw> = emptyList(),
) : Parcelable {
@Parcelize
@Serializable
data class NumberFilter(
@SerialName("enum") val enum: List<Int>? = null,
@SerialName("minimum") val minimum: Int? = null,
@SerialName("maximum") val maximum: Int? = null,
) : Parcelable
@Parcelize
@Serializable
data class StringFilter(
@SerialName("enum") val enum: List<String>? = null,
@SerialName("minLength") val minLength: Int? = null,
@SerialName("maxLength") val maxLength: Int? = null,
@SerialName("pattern") val pattern: String? = null,
) : Parcelable {
@IgnoredOnParcel
val patternRegex by lazy {
if (pattern != null) try {
Regex(pattern)
} catch (e: Exception) {
null
} else null
}
}
@Parcelize
@Serializable
data class AppFilter(
@SerialName("name") val name: StringFilter? = null,
@SerialName("versionName") val versionName: StringFilter? = null,
@SerialName("versionCode") val versionCode: NumberFilter? = null,
) : Parcelable
@Parcelize
@Serializable
data class DeviceFilter(
@SerialName("device") val device: StringFilter? = null,
@SerialName("model") val model: StringFilter? = null,
@SerialName("manufacturer") val manufacturer: StringFilter? = null,
@SerialName("brand") val brand: StringFilter? = null,
@SerialName("sdkInt") val sdkInt: NumberFilter? = null,
@SerialName("release") val release: StringFilter? = null,
) : Parcelable
interface CommonProps {
val activityIds: List<String>?
val excludeActivityIds: List<String>?
val cd: Long?
val appFilter: AppFilter?
val deviceFilter: DeviceFilter?
}
@Parcelize
@Serializable
data class AppRaw(
@SerialName("id") val id: String,
@SerialName("cd") val cd: Long? = null,
@SerialName("activityIds") val activityIds: List<String>? = null,
@SerialName("excludeActivityIds") val excludeActivityIds: List<String>? = null,
@SerialName("cd") override val cd: Long? = null,
@SerialName("activityIds") override val activityIds: List<String>? = null,
@SerialName("excludeActivityIds") override val excludeActivityIds: List<String>? = null,
@SerialName("groups") val groups: List<GroupRaw> = emptyList(),
) : Parcelable
@SerialName("appFilter") override val appFilter: AppFilter? = null,
@SerialName("deviceFilter") override val deviceFilter: DeviceFilter? = null,
) : Parcelable, CommonProps
@Parcelize
@Serializable
data class GroupRaw(
@SerialName("name") val name: String? = null,
@SerialName("desc") val desc: String? = null,
@SerialName("enable") val enable: Boolean? = null,
@SerialName("key") val key: Int,
@SerialName("cd") val cd: Long? = null,
@SerialName("activityIds") val activityIds: List<String>? = null,
@SerialName("excludeActivityIds") val excludeActivityIds: List<String>? = null,
@SerialName("cd") override val cd: Long? = null,
@SerialName("activityIds") override val activityIds: List<String>? = null,
@SerialName("excludeActivityIds") override val excludeActivityIds: List<String>? = null,
@SerialName("rules") val rules: List<RuleRaw> = emptyList(),
) : Parcelable
override val appFilter: AppFilter? = null,
override val deviceFilter: DeviceFilter? = null,
) : Parcelable, CommonProps {
@IgnoredOnParcel
val valid by lazy {
rules.all { r ->
r.matches.all { s -> Selector.check(s) } && r.excludeMatches.all { s ->
Selector.check(
s
)
}
}
}
}
@Parcelize
@Serializable
@ -49,16 +122,19 @@ data class SubscriptionRaw(
@SerialName("name") val name: String? = null,
@SerialName("key") val key: Int? = null,
@SerialName("preKeys") val preKeys: List<Int> = emptyList(),
@SerialName("cd") val cd: Long? = null,
@SerialName("activityIds") val activityIds: List<String>? = null,
@SerialName("excludeActivityIds") val excludeActivityIds: List<String>? = null,
@SerialName("cd") override val cd: Long? = null,
@SerialName("activityIds") override val activityIds: List<String>? = null,
@SerialName("excludeActivityIds") override val excludeActivityIds: List<String>? = null,
@SerialName("matches") val matches: List<String> = emptyList(),
@SerialName("excludeMatches") val excludeMatches: List<String> = emptyList(),
) : Parcelable
override val appFilter: AppFilter? = null,
override val deviceFilter: DeviceFilter? = null,
) : Parcelable, CommonProps
companion object {
private fun getStringIArray(json: JsonObject? = null, name: String = ""): List<String>? {
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")
@ -74,7 +150,7 @@ data class SubscriptionRaw(
}
@Suppress("SameParameterValue")
private fun getIntIArray(json: JsonObject? = null, name: String = ""): List<Int>? {
private fun getIntIArray(json: JsonObject? = null, name: String): List<Int>? {
return when (val element = json?.get(name)) {
JsonNull, null -> null
is JsonArray -> element.map {
@ -89,7 +165,7 @@ data class SubscriptionRaw(
}
}
private fun getString(json: JsonObject? = null, key: String = ""): String? =
private fun getString(json: JsonObject? = null, key: String): String? =
when (val p = json?.get(key)) {
JsonNull, null -> null
is JsonPrimitive -> {
@ -104,7 +180,7 @@ data class SubscriptionRaw(
}
@Suppress("SameParameterValue")
private fun getLong(json: JsonObject? = null, key: String = ""): Long? =
private fun getLong(json: JsonObject? = null, key: String): Long? =
when (val p = json?.get(key)) {
JsonNull, null -> null
is JsonPrimitive -> {
@ -114,7 +190,7 @@ data class SubscriptionRaw(
else -> error("Element $p is not a long")
}
private fun getInt(json: JsonObject? = null, key: String = ""): Int? =
private fun getInt(json: JsonObject? = null, key: String): Int? =
when (val p = json?.get(key)) {
JsonNull, null -> null
is JsonPrimitive -> {
@ -124,9 +200,19 @@ data class SubscriptionRaw(
else -> error("Element $p is not a int")
}
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("")
JsonNull -> error("miss current rule")
is JsonObject -> rulesRawJson
is JsonPrimitive, is JsonArray -> JsonObject(mapOf("matches" to rulesRawJson))
}
@ -136,13 +222,19 @@ data class SubscriptionRaw(
cd = getLong(rulesJson, "cd"),
matches = (getStringIArray(
rulesJson, "matches"
) ?: emptyList()).onEach { Selector.parse(it) },
) ?: emptyList()),
excludeMatches = (getStringIArray(
rulesJson, "excludeMatches"
) ?: emptyList()).onEach { Selector.parse(it) },
) ?: emptyList()),
key = getInt(rulesJson, "key"),
name = getString(rulesJson, "name"),
preKeys = getIntIArray(rulesJson, "preKeys") ?: emptyList(),
deviceFilter = rulesJson["deviceFilter"]?.let {
Singleton.json.decodeFromJsonElement(it)
},
appFilter = rulesJson["appFilter"]?.let {
Singleton.json.decodeFromJsonElement(it)
},
)
}
@ -153,11 +245,13 @@ data class SubscriptionRaw(
is JsonObject -> groupsRawJson
is JsonPrimitive, is JsonArray -> JsonObject(mapOf("rules" to groupsRawJson))
}
return GroupRaw(activityIds = getStringIArray(groupsJson, "activityIds"),
return GroupRaw(
activityIds = getStringIArray(groupsJson, "activityIds"),
excludeActivityIds = getStringIArray(groupsJson, "excludeActivityIds"),
cd = getLong(groupsJson, "cd"),
name = getString(groupsJson, "name"),
desc = getString(groupsJson, "desc"),
enable = getBoolean(groupsJson, "boolean"),
key = getInt(groupsJson, "key") ?: groupIndex,
rules = when (val rulesJson = groupsJson["rules"]) {
null, JsonNull -> emptyList()
@ -165,37 +259,77 @@ data class SubscriptionRaw(
is JsonArray -> rulesJson
}.map {
jsonToRuleRaw(it)
})
},
deviceFilter = groupsJson["deviceFilter"]?.let {
Singleton.json.decodeFromJsonElement(it)
},
appFilter = groupsJson["appFilter"]?.let {
Singleton.json.decodeFromJsonElement(it)
},
)
}
private fun jsonToAppRaw(appsJson: JsonObject): AppRaw {
return AppRaw(activityIds = getStringIArray(appsJson, "activityIds"),
private fun jsonToAppRaw(appsJson: JsonObject, appIndex: Int): AppRaw {
return AppRaw(
activityIds = getStringIArray(appsJson, "activityIds"),
excludeActivityIds = getStringIArray(appsJson, "excludeActivityIds"),
cd = getLong(appsJson, "cd"),
id = getString(appsJson, "id") ?: error(""),
id = getString(appsJson, "id") ?: error("miss subscription.apps[$appIndex].id"),
groups = (when (val groupsJson = appsJson["groups"]) {
null, JsonNull -> emptyList()
is JsonPrimitive, is JsonObject -> JsonArray(listOf(groupsJson))
is JsonArray -> groupsJson
}).mapIndexed { index, jsonElement ->
jsonToGroupRaw(index, jsonElement)
})
},
deviceFilter = appsJson["deviceFilter"]?.let {
Singleton.json.decodeFromJsonElement(it)
},
appFilter = appsJson["appFilter"]?.let {
Singleton.json.decodeFromJsonElement(it)
},
)
}
private fun jsonToSubscriptionRaw(rootJson: JsonObject): SubscriptionRaw {
return SubscriptionRaw(name = getString(rootJson, "name") ?: error(""),
version = getInt(rootJson, "version") ?: error(""),
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"),
supportUrl = getString(rootJson, "supportUrl"),
apps = rootJson["apps"]?.jsonArray?.map { jsonToAppRaw(it.jsonObject) }
?: emptyList())
apps = rootJson["apps"]?.jsonArray?.mapIndexed { index, jsonElement ->
jsonToAppRaw(
jsonElement.jsonObject, index
)
} ?: emptyList())
}
// 订阅文件状态: 文件不存在, 文件正常, 文件损坏(损坏原因)
fun stringify(source: SubscriptionRaw) = Singleton.json.encodeToString(source)
private fun parse(source: String): SubscriptionRaw {
fun parse(source: String): SubscriptionRaw {
return jsonToSubscriptionRaw(Singleton.json.parseToJsonElement(source).jsonObject)
// 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 ->
// 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 }}")
// }
// 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")
// }
// }
// }
}
fun parse5(source: String): SubscriptionRaw {

View File

@ -0,0 +1,13 @@
package li.songe.gkd.db
import androidx.room.Database
import androidx.room.RoomDatabase
import li.songe.gkd.data.ClickLog
@Database(
version = 1,
entities = [ClickLog::class],
)
abstract class ClickLogDb : RoomDatabase() {
abstract fun clickLogDao(): ClickLog.TriggerLogDao
}

View File

@ -1,27 +1,74 @@
package li.songe.gkd.db
import android.database.sqlite.SQLiteDatabase
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import com.blankj.utilcode.util.LogUtils
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import li.songe.gkd.app
import li.songe.gkd.appScope
import li.songe.gkd.data.SubsItem
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.util.FolderExt
import li.songe.gkd.util.Singleton
import li.songe.gkd.util.isMainProcess
object DbSet {
private fun <T : RoomDatabase> getDb(
klass: Class<T>, name: String,
): T {
): RoomDatabase.Builder<T> {
return Room.databaseBuilder(
app, klass, FolderExt.dbFolder.absolutePath.plus("/${name}.db")
).fallbackToDestructiveMigration().enableMultiInstanceInvalidation().build()
).fallbackToDestructiveMigration().enableMultiInstanceInvalidation()
}
private val snapshotDb by lazy { getDb(SnapshotDb::class.java, "snapshot") }
private val subsConfigDb by lazy { getDb(SubsConfigDb::class.java, "subsConfig") }
private val subsItemDb by lazy { getDb(SubsItemDb::class.java, "subsItem") }
val triggerLogDb by lazy { getDb(TriggerLogDb::class.java, "triggerLog-v2") }
private val snapshotDb by lazy { getDb(SnapshotDb::class.java, "snapshot").build() }
private val subsConfigDb by lazy { getDb(SubsConfigDb::class.java, "subsConfig").build() }
private val subsItemDb by lazy {
getDb(SubsItemDb::class.java, "subsItem").addCallback(createCallback()).build()
}
val clickLogDb by lazy { getDb(ClickLogDb::class.java, "clickLog").build() }
val subsItemDao by lazy { subsItemDb.subsItemDao() }
val subsConfigDao by lazy { subsConfigDb.subsConfigDao() }
val snapshotDao by lazy { snapshotDb.snapshotDao() }
private fun createCallback(): RoomDatabase.Callback {
return object : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
if (!isMainProcess) return
val subsItem = SubsItem(
id = 0,
order = 0,
updateUrl = "https://registry.npmmirror.com/@gkd-kit/subscription/latest/files",
mtime = 0
)
db.insert("subs_item", SQLiteDatabase.CONFLICT_IGNORE, subsItem.toContentValues())
appScope.launch(Dispatchers.IO) {
try {
val s = System.currentTimeMillis()
val newSubsRaw = withTimeout(3000) {
SubscriptionRaw.parse5(
Singleton.client.get(subsItem.updateUrl).bodyAsText()
)
}
delay(1500 - (System.currentTimeMillis() - s))
val newSubsItem = subsItem.copy(mtime = System.currentTimeMillis())
newSubsItem.subsFile.writeText(SubscriptionRaw.stringify(newSubsRaw))
subsItemDao.update(newSubsItem)
} catch (e: Exception) {
e.printStackTrace()
LogUtils.d("创建数据库时获取订阅失败")
}
}
}
}
}
}

View File

@ -1,14 +0,0 @@
package li.songe.gkd.db
import androidx.room.Database
import androidx.room.RoomDatabase
import li.songe.gkd.data.Snapshot
import li.songe.gkd.data.TriggerLog
@Database(
version = 1,
entities = [TriggerLog::class],
)
abstract class TriggerLogDb : RoomDatabase() {
abstract fun triggerLogDao(): TriggerLog.TriggerLogDao
}

View File

@ -3,57 +3,67 @@ package li.songe.gkd.debug
import android.app.Notification
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.size
import androidx.compose.material.Icon
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationCompat
import com.blankj.utilcode.util.ServiceUtils
import com.blankj.utilcode.util.ToastUtils
import com.torrydo.floatingbubbleview.FloatingBubble
import kotlinx.coroutines.Dispatchers
import li.songe.gkd.app
import li.songe.gkd.appScope
import li.songe.gkd.composition.CompositionExt.useLifeCycleLog
import li.songe.gkd.composition.CompositionFbService
import li.songe.gkd.composition.CompositionExt.useMessage
import li.songe.gkd.composition.InvokeMessage
import li.songe.gkd.notif.floatingChannel
import li.songe.gkd.notif.floatingNotif
import li.songe.gkd.util.SafeR
import li.songe.gkd.util.launchTry
class FloatingService : CompositionFbService({
useLifeCycleLog()
val context = this
val (onMessage, sendMessage) = useMessage(this::class.simpleName)
onMessage { message ->
when (message.method) {
"showBubbles" -> context.showBubbles()
"removeBubbles" -> context.removeBubbles()
}
}
setupBubble { _, resolve ->
val builder = FloatingBubble.Builder(this).bubble(SafeR.ic_capture, 40, 40)
.enableCloseBubble(false)
.addFloatingBubbleListener(object : FloatingBubble.Listener {
override fun onClick() {
sendMessage(InvokeMessage(HttpService::class.simpleName, "capture"))
}
})
val builder = FloatingBubble.Builder(this).bubble {
Icon(painter = painterResource(SafeR.ic_capture),
contentDescription = "capture",
modifier = Modifier
.clickable(indication = null,
interactionSource = remember { MutableInteractionSource() }) {
appScope.launchTry(Dispatchers.IO) {
SnapshotExt.captureSnapshot()
ToastUtils.showShort("快照成功")
}
}
.size(40.dp),
tint = Color.Red)
}.enableCloseBubble(false).enableAnimateToEdge(false)
resolve(builder)
}
}) {
override fun setupNotificationBuilder(channelId: String): Notification {
return NotificationCompat.Builder(this, channelId)
.setOngoing(true)
.setSmallIcon(SafeR.ic_launcher)
.setContentTitle("搞快点")
.setContentText("正在显示悬浮窗按钮")
.setPriority(NotificationCompat.PRIORITY_MIN)
.setCategory(Notification.CATEGORY_SERVICE)
.build()
override fun initialNotification(): Notification {
return NotificationCompat.Builder(this, floatingChannel.id).setOngoing(true)
.setSmallIcon(SafeR.ic_launcher).setContentTitle(floatingNotif.title)
.setContentText(floatingNotif.text).setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(Notification.CATEGORY_SERVICE).build()
}
override fun channelId() = "service-floating"
override fun channelName() = "悬浮窗按钮服务"
override fun notificationId() = 69
override fun notificationId() = floatingNotif.id
companion object{
override fun createNotificationChannel(channelId: String, channelName: String) {
// by app init
}
companion object {
fun isRunning() = ServiceUtils.isServiceRunning(FloatingService::class.java)
fun stop(context: Context =app) {
fun stop(context: Context = app) {
if (isRunning()) {
context.stopService(Intent(context, FloatingService::class.java))
}

View File

@ -4,7 +4,6 @@ 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.CacheControl
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.call
@ -13,24 +12,24 @@ import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.cors.routing.CORS
import io.ktor.server.request.receive
import io.ktor.server.response.cacheControl
import io.ktor.server.response.respond
import io.ktor.server.response.respondFile
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import li.songe.gkd.app
import li.songe.gkd.composition.CompositionExt.useMessage
import li.songe.gkd.composition.CompositionService
import li.songe.gkd.composition.InvokeMessage
import li.songe.gkd.data.DeviceInfo
import li.songe.gkd.data.RpcError
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.db.DbSet
import li.songe.gkd.debug.SnapshotExt.captureSnapshot
import li.songe.gkd.util.Ext.getIpAddressInLocalNetwork
@ -40,85 +39,66 @@ import java.io.File
class HttpService : CompositionService({
val scope = CoroutineScope(Dispatchers.IO)
val (onMessage, sendMessage) = useMessage(this::class.simpleName)
val removeBubbles = {
sendMessage(InvokeMessage(FloatingService::class.simpleName, "removeBubbles"))
}
val showBubbles = {
sendMessage(InvokeMessage(FloatingService::class.simpleName, "showBubbles"))
}
onMessage { message ->
when (message.method) {
"capture" -> {
scope.launch {
removeBubbles()
delay(200)
try {
captureSnapshot()
ToastUtils.showShort("保存快照成功")
} catch (e: Exception) {
ToastUtils.showShort("保存快照失败")
e.printStackTrace()
}
showBubbles()
}
}
}
}
val server = embeddedServer(
Netty,
storeFlow.value.httpServerPort,
configure = { tcpKeepAlive = true }
) {
install(CORS) { anyHost() }
install(RpcErrorHeaderPlugin)
install(ContentNegotiation) { json() }
subsFlow.value = null
val server =
embeddedServer(Netty, storeFlow.value.httpServerPort, configure = { tcpKeepAlive = true }) {
install(CORS) { anyHost() }
install(RpcErrorHeaderPlugin)
install(ContentNegotiation) { json() }
routing {
route("/api") {
get("/device") { call.respond(DeviceInfo.instance) }
get("/snapshot") {
val id = call.request.queryParameters["id"]?.toLongOrNull()
?: throw RpcError("miss id")
val fp = File(SnapshotExt.getSnapshotPath(id))
if (!fp.exists()) {
throw RpcError("对应快照不存在")
routing {
get("/") { call.respond("hello world") }
route("/api") {
get("/device") { call.respond(DeviceInfo.instance) }
get("/snapshot") {
val id = call.request.queryParameters["id"]?.toLongOrNull()
?: throw RpcError("miss id")
val fp = File(SnapshotExt.getSnapshotPath(id))
if (!fp.exists()) {
throw RpcError("对应快照不存在")
}
call.response.cacheControl(CacheControl.MaxAge(3600 * 24 * 7))
call.respondFile(fp)
}
call.response.cacheControl(CacheControl.MaxAge(3600 * 24 * 7))
call.respondFile(fp)
}
get("/screenshot") {
val id = call.request.queryParameters["id"]?.toLongOrNull()
?: throw RpcError("miss id")
val fp = File(SnapshotExt.getScreenshotPath(id))
if (!fp.exists()) {
throw RpcError("对应截图不存在")
get("/screenshot") {
val id = call.request.queryParameters["id"]?.toLongOrNull()
?: throw RpcError("miss id")
val fp = File(SnapshotExt.getScreenshotPath(id))
if (!fp.exists()) {
throw RpcError("对应截图不存在")
}
call.response.cacheControl(CacheControl.MaxAge(3600 * 24 * 7))
call.respondFile(fp)
}
call.response.cacheControl(CacheControl.MaxAge(3600 * 24 * 7))
call.respondFile(fp)
}
get("/captureSnapshot") {
removeBubbles()
delay(200)
val snapshot = try {
captureSnapshot()
} finally {
showBubbles()
get("/captureSnapshot") {
call.respond(captureSnapshot())
}
get("/snapshots") {
call.respond(DbSet.snapshotDao.query().first())
}
get("/subsApps") {
call.respond(subsFlow.value?.apps ?: emptyList())
}
post("/updateSubsApps") {
val subsStr =
"""{"name":"GKD-内存订阅","id":-1,"version":0,"author":"@gkd-kit/inspect","apps":${call.receive<String>()}}"""
try {
subsFlow.value = SubscriptionRaw.parse(subsStr)
} catch (e: Exception) {
throw RpcError(e.message ?: "未知")
}
call.respond("")
}
call.respond(snapshot)
}
get("/snapshots") {
call.respond(DbSet.snapshotDao.query().first())
}
}
}
}
scope.launchTry(Dispatchers.IO) {
LogUtils.d(*getIpAddressInLocalNetwork().map { host -> "http://${host}:${storeFlow.value.httpServerPort}" }
.toList().toTypedArray())
server.start(true)
}
onDestroy {
subsFlow.value = null
scope.launchTry(Dispatchers.IO) {
server.stop()
LogUtils.d("http server is stopped")
@ -127,6 +107,10 @@ class HttpService : CompositionService({
}
}) {
companion object {
val subsFlow by lazy { MutableStateFlow<SubscriptionRaw?>(null) }
fun isRunning() = ServiceUtils.isServiceRunning(HttpService::class.java)
fun stop(context: Context = app) {
if (isRunning()) {

View File

@ -1,5 +1,6 @@
package li.songe.gkd.debug
import android.annotation.SuppressLint
import android.content.ComponentName
import android.content.Context
import android.content.Intent
@ -8,12 +9,14 @@ import com.blankj.utilcode.util.ServiceUtils
import li.songe.gkd.app
import li.songe.gkd.composition.CompositionExt.useLifeCycleLog
import li.songe.gkd.composition.CompositionService
import li.songe.gkd.util.Ext
import li.songe.gkd.notif.createNotif
import li.songe.gkd.notif.screenshotChannel
import li.songe.gkd.notif.screenshotNotif
import li.songe.gkd.util.ScreenshotUtil
class ScreenshotService : CompositionService({
useLifeCycleLog()
Ext.createNotificationChannel(this, 110)
createNotif(this, screenshotChannel.id, screenshotNotif)
onStartCommand { intent, _, _ ->
if (intent == null) return@onStartCommand
@ -28,6 +31,8 @@ class ScreenshotService : CompositionService({
}) {
companion object {
suspend fun screenshot() = screenshotUtil?.execute()
@SuppressLint("StaticFieldLeak")
private var screenshotUtil: ScreenshotUtil? = null
fun start(context: Context = app, intent: Intent) {

View File

@ -7,14 +7,16 @@ import com.blankj.utilcode.util.ZipUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.encodeToString
import li.songe.gkd.service.GkdAbService
import li.songe.gkd.app
import li.songe.gkd.data.RpcError
import li.songe.gkd.data.Snapshot
import li.songe.gkd.db.DbSet
import li.songe.gkd.service.GkdAbService
import li.songe.gkd.util.Singleton
import java.io.File
@ -25,13 +27,11 @@ object SnapshotExt {
private val emptyBitmap by lazy {
Bitmap.createBitmap(
ScreenUtils.getScreenWidth(),
ScreenUtils.getScreenHeight(),
Bitmap.Config.ARGB_8888
ScreenUtils.getScreenWidth(), ScreenUtils.getScreenHeight(), Bitmap.Config.ARGB_8888
)
}
fun getSnapshotParentPath(snapshotId: Long) =
private fun getSnapshotParentPath(snapshotId: Long) =
"${snapshotDir.absolutePath}/${snapshotId}"
fun getSnapshotPath(snapshotId: Long) =
@ -40,19 +40,13 @@ object SnapshotExt {
fun getScreenshotPath(snapshotId: Long) =
"${getSnapshotParentPath(snapshotId)}/${snapshotId}.png"
fun getSnapshotIds(): List<Long> {
return snapshotDir.listFiles { f -> f.isDirectory }
?.mapNotNull { f -> f.name.toLongOrNull() } ?: emptyList()
}
suspend fun getSnapshotZipFile(snapshotId: Long): File {
val file = File(getSnapshotParentPath(snapshotId) + "/${snapshotId}.zip")
if (!file.exists()) {
withContext(Dispatchers.IO) {
ZipUtils.zipFiles(
listOf(
getSnapshotPath(snapshotId),
getScreenshotPath(snapshotId)
getSnapshotPath(snapshotId), getScreenshotPath(snapshotId)
), file.absolutePath
)
}
@ -68,38 +62,48 @@ object SnapshotExt {
}
}
suspend fun captureSnapshot(): Snapshot {
if (!GkdAbService.isRunning()) {
throw RpcError("无障碍不可用")
}
val captureLoading = MutableStateFlow(false)
val snapshotDef = coroutineScope { async(Dispatchers.IO) { Snapshot.current() } }
val bitmapDef = coroutineScope {
async(Dispatchers.IO) {
GkdAbService.currentScreenshot() ?: withTimeoutOrNull(3_000) {
if (!ScreenshotService.isRunning()) {
return@withTimeoutOrNull null
suspend fun captureSnapshot(): Snapshot {
if (captureLoading.value) {
throw RpcError("正在截屏,不可重复截屏")
}
captureLoading.value = true
try {
if (!GkdAbService.isRunning()) {
throw RpcError("无障碍不可用")
}
val snapshotDef = coroutineScope { async(Dispatchers.IO) { Snapshot.current() } }
val bitmapDef = coroutineScope {
async(Dispatchers.IO) {
GkdAbService.currentScreenshot() ?: withTimeoutOrNull(3_000) {
if (!ScreenshotService.isRunning()) {
return@withTimeoutOrNull null
}
ScreenshotService.screenshot()
} ?: emptyBitmap.apply {
LogUtils.d("截屏不可用,即将使用空白图片")
}
ScreenshotService.screenshot()
} ?: emptyBitmap.apply {
LogUtils.d("截屏不可用,即将使用空白图片")
}
}
}
val bitmap = bitmapDef.await()
val snapshot = snapshotDef.await()
val bitmap = bitmapDef.await()
val snapshot = snapshotDef.await()
withContext(Dispatchers.IO) {
File(getSnapshotParentPath(snapshot.id)).apply { if (!exists()) mkdirs() }
val stream =
File(getScreenshotPath(snapshot.id)).outputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.close()
val text = Singleton.json.encodeToString(snapshot)
File(getSnapshotPath(snapshot.id)).writeText(text)
DbSet.snapshotDao.insert(snapshot)
withContext(Dispatchers.IO) {
File(getSnapshotParentPath(snapshot.id)).apply { if (!exists()) mkdirs() }
val stream = File(getScreenshotPath(snapshot.id)).outputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.close()
val text = Singleton.json.encodeToString(snapshot)
File(getSnapshotPath(snapshot.id)).writeText(text)
DbSet.snapshotDao.insert(snapshot)
}
return snapshot
} finally {
captureLoading.value = false
}
return snapshot
}
}

View File

@ -12,11 +12,9 @@ data class Notif(
)
const val STATUS_NOTIF_ID = 100
val abNotif by lazy {
Notif(
id = STATUS_NOTIF_ID,
id = 100,
icon = SafeR.ic_launcher,
title = "搞快点",
text = "无障碍正在运行",
@ -24,3 +22,24 @@ val abNotif by lazy {
autoCancel = false
)
}
val screenshotNotif by lazy {
Notif(
id = 101,
icon = SafeR.ic_launcher,
title = "搞快点",
text = "截屏服务正在运行",
ongoing = true,
autoCancel = false
)
}
val floatingNotif by lazy {
Notif(
id = 102,
icon = SafeR.ic_launcher,
title = "搞快点",
text = "悬浮窗按钮正在显示",
ongoing = true,
autoCancel = false
)
}

View File

@ -1,5 +1,7 @@
package li.songe.gkd.notif
import li.songe.gkd.app
data class NotifChannel(
val id: String,
val name: String,
@ -10,4 +12,21 @@ val defaultChannel by lazy {
NotifChannel(
id = "default", name = "搞快点", desc = "显示服务运行状态"
)
}
val floatingChannel by lazy {
NotifChannel(
id = "floating", name = "悬浮窗按钮服务", desc = "用于主动捕获屏幕快照的悬浮窗按钮"
)
}
val screenshotChannel by lazy {
NotifChannel(
id = "screenshot", name = "截屏服务", desc = "用于捕获屏幕截屏生成快照"
)
}
fun initChannel() {
createChannel(app, defaultChannel)
createChannel(app, floatingChannel)
createChannel(app, screenshotChannel)
}

View File

@ -20,9 +20,7 @@ fun createChannel(context: Context, notifChannel: NotifChannel) {
notificationManager.createNotificationChannel(channel)
}
fun createNotif(context: Service, notifChannel: NotifChannel, notif: Notif) {
createChannel(context, notifChannel)
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
}
@ -30,14 +28,13 @@ fun createNotif(context: Service, notifChannel: NotifChannel, notif: Notif) {
context, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val builder = NotificationCompat.Builder(context, notifChannel.id).setSmallIcon(notif.icon)
val builder = NotificationCompat.Builder(context, channelId).setSmallIcon(notif.icon)
.setContentTitle(notif.title).setContentText(notif.text).setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_DEFAULT).setOngoing(notif.ongoing)
.setAutoCancel(notif.autoCancel)
val notification = builder.build()
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// manager.notify(notice.id, notification)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
context.startForeground(
notif.id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST

View File

@ -80,50 +80,39 @@ private fun AccessibilityNodeInfo.getTempRect(): Rect {
return tempRect
}
val abTransform = Transform<AccessibilityNodeInfo>(
getAttr = { node, name ->
when (name) {
"id" -> node.viewIdResourceName
"name" -> node.className
"text" -> node.text
"textLen" -> node.text?.length
"desc" -> node.contentDescription
"descLen" -> node.contentDescription?.length
"childCount" -> node.childCount
val abTransform = Transform<AccessibilityNodeInfo>(getAttr = { node, name ->
when (name) {
"id" -> node.viewIdResourceName
"name" -> node.className
"text" -> node.text
"text.length" -> node.text?.length
"desc" -> node.contentDescription
"desc.length" -> node.contentDescription?.length
"isEnabled" -> node.isEnabled
"isClickable" -> node.isClickable
"isChecked" -> node.isChecked
"isCheckable" -> node.isCheckable
"isFocused" -> node.isFocused
"isFocusable" -> node.isFocusable
"isVisibleToUser" -> node.isVisibleToUser
""->node.isAccessibilityFocused
"clickable" -> node.isClickable
"focusable" -> node.isFocusable
"visibleToUser" -> node.isVisibleToUser
"left" -> node.getTempRect().left
"top" -> node.getTempRect().top
"right" -> node.getTempRect().right
"bottom" -> node.getTempRect().bottom
"left" -> node.getTempRect().left
"top" -> node.getTempRect().top
"right" -> node.getTempRect().right
"bottom" -> node.getTempRect().bottom
"width" -> node.getTempRect().width()
"height" -> node.getTempRect().height()
"width" -> node.getTempRect().width()
"height" -> node.getTempRect().height()
"index" -> node.getIndex()
"depth" -> node.getDepth()
else -> null
"index" -> node.getIndex()
"depth" -> node.getDepth()
"childCount" -> node.childCount
else -> null
}
}, getName = { node -> node.className }, getChildren = { node ->
sequence {
repeat(node.childCount) { i ->
yield(node.getChild(i))
}
},
getName = { node -> node.className },
getChildren = { node ->
sequence {
repeat(node.childCount) { i ->
yield(node.getChild(i))
}
}
},
getChild = { node, index -> node.getChild(index) },
getParent = { node -> node.parent }
)
}
}, getChild = { node, index -> node.getChild(index) }, getParent = { node -> node.parent })

View File

@ -0,0 +1,40 @@
package li.songe.gkd.service
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import li.songe.gkd.appScope
import li.songe.gkd.data.Rule
import li.songe.gkd.util.appIdToRulesFlow
val activityIdFlow by lazy {
MutableStateFlow<String?>(null)
}
val appIdFlow by lazy {
MutableStateFlow<String?>(null)
}
val currentRulesFlow by lazy {
combine(appIdToRulesFlow, appIdFlow, activityIdFlow) { appIdToRules, appId, activityId ->
(appIdToRules[appId] ?: emptyList()).filter { rule ->
rule.matchActivityId(activityId)
}
}.stateIn(appScope, SharingStarted.Eagerly, emptyList())
}
val lastTriggerRuleFlow by lazy {
MutableStateFlow<Rule?>(null)
}
fun isAvailableRule(rule: Rule): Boolean {
if (!rule.active) return false // 处于冷却时间
if (rule.preKeys.isNotEmpty()) { // 需要提前点击某个规则
if (rule.preRules.isEmpty()) return false // 声明了 preKeys 但是没有在当前列表找到
lastTriggerRuleFlow.value ?: return false
if (!rule.preRules.any { it === lastTriggerRuleFlow.value }) return false // 上一个点击的规则不在当前需要点击的列表
}
return true
}

View File

@ -2,7 +2,9 @@ package li.songe.gkd.service
import android.graphics.Bitmap
import android.os.Build
import android.util.Log
import android.view.Display
import android.view.KeyEvent
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import com.blankj.utilcode.util.LogUtils
@ -14,25 +16,26 @@ import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import li.songe.gkd.composition.CompositionAbService
import li.songe.gkd.composition.CompositionExt.useLifeCycleLog
import li.songe.gkd.composition.CompositionExt.useScope
import li.songe.gkd.data.NodeInfo
import li.songe.gkd.data.Rule
import li.songe.gkd.data.RuleManager
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.data.TriggerLog
import li.songe.gkd.data.ClickLog
import li.songe.gkd.db.DbSet
import li.songe.gkd.debug.SnapshotExt
import li.songe.gkd.shizuku.activityTaskManager
import li.songe.gkd.shizuku.shizukuIsSafeOK
import li.songe.gkd.util.Singleton
import li.songe.gkd.util.increaseClickCount
import li.songe.gkd.util.launchTry
import li.songe.gkd.util.launchWhile
import li.songe.gkd.util.launchWhileTry
import li.songe.gkd.util.storeFlow
import li.songe.gkd.util.subsIdToRawFlow
import li.songe.gkd.util.subsItemsFlow
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@ -46,8 +49,8 @@ class GkdAbService : CompositionAbService({
service = context
onDestroy {
service = null
currentAppId = null
currentActivityId = null
appIdFlow.value = null
activityIdFlow.value = null
}
ManageService.start(context)
@ -55,15 +58,31 @@ class GkdAbService : CompositionAbService({
ManageService.stop(context)
}
var lastKeyEventTime = -1L
onKeyEvent { event -> // 当按下音量键时捕获快照
if (storeFlow.value.captureVolumeKey && (event?.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || event?.keyCode == KeyEvent.KEYCODE_VOLUME_UP)) {
val et = System.currentTimeMillis()
if (et - lastKeyEventTime > 3000) {
lastKeyEventTime = et
scope.launchTry(IO) {
val snapshot = SnapshotExt.captureSnapshot()
ToastUtils.showShort("保存快照成功")
LogUtils.d("截屏:保存快照", snapshot.id)
}
}
}
}
var serviceConnected = false
onServiceConnected { serviceConnected = true }
onInterrupt { serviceConnected = false }
onAccessibilityEvent { event -> // 根据事件获取 activityId, 概率不准确
val appId = rootInActiveWindow?.packageName?.toString() ?: return@onAccessibilityEvent
appIdFlow.value = appId
when (event?.eventType) {
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED, AccessibilityEvent.TYPE_WINDOWS_CHANGED -> {
val appId = rootInActiveWindow?.packageName?.toString() ?: return@onAccessibilityEvent
val activityId = event.className?.toString() ?: return@onAccessibilityEvent
if (activityId == "com.miui.home.launcher.Launcher") { // 小米桌面 bug
if (appId != "com.miui.home") {
@ -77,117 +96,105 @@ class GkdAbService : CompositionAbService({
) {
return@onAccessibilityEvent
}
currentAppId = appId
currentActivityId = activityId
activityIdFlow.value = activityId
}
else -> {}
}
}
onAccessibilityEvent { event -> // 小米手机监听截屏保存快照
if (!storeFlow.value.enableCaptureScreenshot) return@onAccessibilityEvent
if (event?.packageName == null || event.className == null) return@onAccessibilityEvent
if (event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED && event.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_UNDEFINED && event.packageName.contentEquals(
"com.miui.screenshot"
) && event.className!!.startsWith("android.") // android.widget.RelativeLayout
) {
scope.launchTry {
val snapshot = SnapshotExt.captureSnapshot()
ToastUtils.showShort("保存快照成功")
LogUtils.d("截屏:保存快照", snapshot.id)
scope.launchWhileTry(interval = 500) { // 根据 shizuku 获取 activityId, 准确
if (shizukuIsSafeOK()) {
val topActivity =
activityTaskManager.getTasks(1, false, true)?.firstOrNull()?.topActivity
if (topActivity != null) {
appIdFlow.value = topActivity.packageName
activityIdFlow.value = topActivity.className
}
}
}
var isScreenLock = false
scope.launchWhile(IO) {
isScreenLock = ScreenUtils.isScreenLock()
delay(1000)
}
scope.launchWhile { // 屏幕无障碍信息轮询
delay(200)
if (!serviceConnected) return@launchWhile
currentAppId = rootInActiveWindow?.packageName?.toString()
if (!storeFlow.value.enableService || ScreenUtils.isScreenLock()) return@launchWhile
if (!storeFlow.value.enableService || isScreenLock) return@launchWhile
var tempRules = rules
var i = 0
while (i < tempRules.size) {
val rule = tempRules[i]
i++
if (!ruleManager.ruleIsAvailable(rule)) continue
val frozenNode = rootInActiveWindow
val target = rule.query(frozenNode)
if (target != null) {
val clickResult = target.click(context)
ruleManager.trigger(rule)
val currentRules = currentRulesFlow.value
for (rule in currentRules) {
if (!isAvailableRule(rule)) continue
val target = rule.query(rootInActiveWindow)
val clickResult = target?.click(context)
if (clickResult != null) {
rule.trigger()
LogUtils.d(
*rule.matches.toTypedArray(), NodeInfo.abNodeToNode(target), clickResult
*rule.matches.toTypedArray(), NodeInfo.abNodeToNode(target).attr, clickResult
)
if (clickResult != null) {
scope.launchTry(IO) {
val triggerLog = TriggerLog(
appId = currentAppId,
activityId = currentActivityId,
subsId = rule.subsItem.id,
groupKey = rule.group.key,
ruleIndex = rule.index,
ruleKey = rule.key
)
DbSet.triggerLogDb.triggerLogDao().insert(triggerLog)
}
scope.launchTry(IO) {
val clickLog = ClickLog(
appId = appIdFlow.value,
activityId = activityIdFlow.value,
subsId = rule.subsItem.id,
groupKey = rule.group.key,
ruleIndex = rule.index,
ruleKey = rule.key
)
DbSet.clickLogDb.clickLogDao().insert(clickLog)
increaseClickCount()
}
}
delay(50)
currentAppId = rootInActiveWindow?.packageName?.toString()
if (tempRules != rules) {
tempRules = rules
i = 0
}
if (currentRules !== currentRulesFlow.value) break
}
}
scope.launchWhile { // 自动从网络更新订阅文件
delay(5000)
scope.launchWhile(IO) { // 自动从网络更新订阅文件
delay(storeFlow.value.autoUpdateSubsIntervalTimeMillis.coerceAtLeast(30 * 60_000))
if (!NetworkUtils.isAvailable()) return@launchWhile
DbSet.subsItemDao.query().first().forEach { subsItem ->
if (!storeFlow.value.autoUpdateSubs) return@launchWhile
subsItemsFlow.value.forEach { subsItem ->
try {
val text = Singleton.client.get(subsItem.updateUrl).bodyAsText()
val subscriptionRaw = SubscriptionRaw.parse5(text)
if (subscriptionRaw.version <= subsItem.version) {
val newSubsRaw = SubscriptionRaw.parse5(text)
if (newSubsRaw.id != subsItem.id) {
return@forEach
}
val newItem = subsItem.copy(
updateUrl = subscriptionRaw.updateUrl ?: subsItem.updateUrl,
name = subscriptionRaw.name,
mtime = System.currentTimeMillis()
)
newItem.subsFile.writeText(
val oldSubsRaw = subsIdToRawFlow.value[subsItem.id]
if (oldSubsRaw != null && newSubsRaw.version <= oldSubsRaw.version) {
return@forEach
}
subsItem.subsFile.writeText(
SubscriptionRaw.stringify(
subscriptionRaw
newSubsRaw
)
)
val newItem = subsItem.copy(
updateUrl = newSubsRaw.updateUrl ?: subsItem.updateUrl,
mtime = System.currentTimeMillis()
)
DbSet.subsItemDao.update(newItem)
LogUtils.d("更新磁盘订阅文件:${subsItem.name}")
LogUtils.d("更新磁盘订阅文件:${newSubsRaw.name}")
} catch (e: Exception) {
e.printStackTrace()
}
}
delay(30 * 60_000)
}
scope.launchTry {
delay(5000)
DbSet.subsItemDao.query().flowOn(IO).collect {
ruleManager = RuleManager(it)
}
}
scope.launchWhileTry(interval = 400) {
if (shizukuIsSafeOK()) {
val topActivity =
activityTaskManager.getTasks(1, false, true)?.firstOrNull()?.topActivity
if (topActivity != null) {
currentAppId = topActivity.packageName
currentActivityId = topActivity.className
scope.launch {
combine(appIdFlow, activityIdFlow, currentRulesFlow) { appId, activityId, currentRules ->
appId to activityId to currentRules
}.collect {
val (appId, activityId) = it.first
val currentRules = it.second
Log.d("GkdAbService", "appId:$appId, activityId:$activityId")
if (currentRules.isNotEmpty()) {
LogUtils.d(*currentRules.map { r -> r.rule.matches }.toTypedArray())
}
}
}
@ -198,37 +205,7 @@ class GkdAbService : CompositionAbService({
private var service: GkdAbService? = null
fun isRunning() = ServiceUtils.isServiceRunning(GkdAbService::class.java)
private var ruleManager = RuleManager()
set(value) {
field = value
rules = value.match(currentAppId, currentActivityId).toList()
}
private var rules = listOf<Rule>()
set(value) {
field = value
LogUtils.d(
"currentAppId: $currentAppId",
"currentActivityId: $currentActivityId",
value.size,
)
}
var currentActivityId: String? = null
set(value) {
val oldValue = field
field = value
if (value != oldValue) {
rules = ruleManager.match(currentAppId, value).toList()
}
}
private var currentAppId: String? = null
set(value) {
val oldValue = field
field = value
if (value != oldValue) {
rules = ruleManager.match(value, currentActivityId).toList()
}
}
val currentAbNode: AccessibilityNodeInfo?
get() {
return service?.rootInActiveWindow

View File

@ -2,17 +2,44 @@ package li.songe.gkd.service
import android.content.Context
import android.content.Intent
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
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.notif.abNotif
import li.songe.gkd.notif.createNotif
import li.songe.gkd.notif.defaultChannel
import li.songe.gkd.util.appIdToRulesFlow
import li.songe.gkd.util.clickCountFlow
import li.songe.gkd.util.storeFlow
class ManageService : CompositionService({
useLifeCycleLog()
val context = this
createNotif(context, defaultChannel, abNotif)
createNotif(context, defaultChannel.id, abNotif)
val scope = useScope()
scope.launch {
combine(appIdToRulesFlow, clickCountFlow, storeFlow) { appIdToRules, clickCount, store ->
if (!store.enableService) return@combine "服务已暂停"
val appSize = appIdToRules.keys.size
val groupSize =
appIdToRules.values.flatten().map { r -> r.group.hashCode() }.toSet().size
(if (groupSize > 0) {
"${appSize}应用/${groupSize}规则组"
} else {
"暂无规则"
}) + if (clickCount > 0) "/${clickCount}点击" else ""
}.collect { text ->
createNotif(
context, defaultChannel.id, abNotif.copy(
text = text
)
)
}
}
}) {
companion object {
fun start(context: Context = app) {

View File

@ -1,21 +1,26 @@
package li.songe.gkd.ui
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import li.songe.gkd.BuildConfig
import li.songe.gkd.ui.component.SimpleTopAppBar
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.ProfileTransitions
@RootNavGraph
@Destination
@Destination(style = ProfileTransitions::class)
@Composable
fun AboutPage() {
// val systemUiController = rememberSystemUiController()
@ -29,6 +34,7 @@ fun AboutPage() {
// WindowCompat.setDecorFitsSystemWindows(context.window, true)
// }
// }
val context = LocalContext.current
val navController = LocalNavController.current
Scaffold(topBar = {
SimpleTopAppBar(onClickIcon = { navController.popBackStack() }, title = "关于")

View File

@ -13,7 +13,7 @@ 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.lazy.itemsIndexed
import androidx.compose.material.Scaffold
import androidx.compose.material.Switch
import androidx.compose.material.Text
@ -36,15 +36,16 @@ import com.ramcosta.composedestinations.annotation.RootNavGraph
import kotlinx.serialization.encodeToString
import li.songe.gkd.data.SubsConfig
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.data.getAppName
import li.songe.gkd.db.DbSet
import li.songe.gkd.ui.component.SimpleTopAppBar
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.ProfileTransitions
import li.songe.gkd.util.Singleton
import li.songe.gkd.util.appInfoCacheFlow
import li.songe.gkd.util.launchAsFn
@RootNavGraph
@Destination
@Destination(style = ProfileTransitions::class)
@Composable
fun AppItemPage(
subsItemId: Long,
@ -54,15 +55,19 @@ fun AppItemPage(
val scope = rememberCoroutineScope()
val navController = LocalNavController.current
val vm = hiltViewModel<AppItemVm>()
val subsItem by vm.subsItemFlow.collectAsState()
val subsConfigs by vm.subsConfigsFlow.collectAsState()
val subsApp by vm.subsAppFlow.collectAsState()
val appInfoCache by appInfoCacheFlow.collectAsState()
var showGroupItem: SubscriptionRaw.GroupRaw? by remember { mutableStateOf(null) }
Scaffold(topBar = {
SimpleTopAppBar(
onClickIcon = { navController.popBackStack() },
title = getAppName(subsApp?.id) ?: subsApp?.id ?: ""
title = if (subsItem == null) "订阅文件缺失" else (appInfoCache[subsApp?.id]?.name
?: subsApp?.id ?: "")
)
}, content = { contentPadding ->
LazyColumn(
@ -74,7 +79,7 @@ fun AppItemPage(
Spacer(modifier = Modifier.height(10.dp))
}
subsApp?.groups?.let { groupsVal ->
items(groupsVal, { it.key }) { group ->
itemsIndexed(groupsVal, { i, g -> i.toString() + g.key }) { _, group ->
Row(
modifier = Modifier
.background(
@ -100,13 +105,21 @@ fun AppItemPage(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
Text(
text = group.desc ?: "-",
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()
)
} else {
Text(
text = "规则组损坏",
color = Color.Red,
modifier = Modifier.fillMaxWidth()
)
}
}
Spacer(modifier = Modifier.width(10.dp))
@ -115,11 +128,13 @@ fun AppItemPage(
Switch(checked = subsConfig?.enable != false,
modifier = Modifier,
onCheckedChange = scope.launchAsFn { enable ->
val newItem = (subsConfig ?: SubsConfig(
val newItem = (subsConfig?.copy(enable = enable) ?: SubsConfig(
type = SubsConfig.GroupType,
subsItemId = subsItemId,
appId = appId,
)).copy(enable = enable)
groupKey = group.key,
enable = enable
))
DbSet.subsConfigDao.insert(newItem)
})
}

View File

@ -4,32 +4,29 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import li.songe.gkd.data.SubsItem
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.db.DbSet
import li.songe.gkd.ui.destinations.AppItemPageDestination
import li.songe.gkd.util.subsIdToRawFlow
import li.songe.gkd.util.subsItemsFlow
import javax.inject.Inject
@HiltViewModel
class AppItemVm @Inject constructor(stateHandle: SavedStateHandle) : ViewModel() {
private val args = AppItemPageDestination.argsFrom(stateHandle)
val subsItemFlow =
subsItemsFlow.map { subsItems -> subsItems.find { s -> s.id == args.subsItemId } }
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
val subsConfigsFlow = DbSet.subsConfigDao.queryGroupTypeConfig(args.subsItemId, args.appId)
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val subsAppFlow = MutableStateFlow<SubscriptionRaw.AppRaw?>(null)
init {
viewModelScope.launch(Dispatchers.IO) {
val subscriptionRaw = SubsItem.getSubscriptionRaw(args.subsItemId) ?: return@launch
subsAppFlow.value = subscriptionRaw.apps.find { it.id == args.appId }
}
}
val subsAppFlow =
subsIdToRawFlow.map { subsIdToRaw -> subsIdToRaw[args.subsItemId]?.apps?.find { it.id == args.appId } }
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
}

View File

@ -13,7 +13,7 @@ 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.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
@ -31,61 +31,37 @@ import androidx.compose.ui.window.Dialog
import androidx.hilt.navigation.compose.hiltViewModel
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import li.songe.gkd.data.SubsItem
import li.songe.gkd.data.TriggerLog
import li.songe.gkd.data.getAppInfo
import li.songe.gkd.data.getAppName
import com.ramcosta.composedestinations.navigation.navigate
import li.songe.gkd.data.ClickLog
import li.songe.gkd.db.DbSet
import li.songe.gkd.ui.component.SimpleTopAppBar
import li.songe.gkd.ui.destinations.AppItemPageDestination
import li.songe.gkd.util.LaunchedEffectTry
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.Singleton
import li.songe.gkd.util.launchAsFn
import li.songe.gkd.util.rememberCache
import com.ramcosta.composedestinations.navigation.navigate
import li.songe.gkd.util.ProfileTransitions
import li.songe.gkd.util.appInfoCacheFlow
import li.songe.gkd.util.format
import li.songe.gkd.util.launchAsFn
@RootNavGraph
@Destination
@Destination(style = ProfileTransitions::class)
@Composable
fun RecordPage() {
fun ClickLogPage() {
val scope = rememberCoroutineScope()
val navController = LocalNavController.current
val vm = hiltViewModel<RecordVm>()
val triggerLogs by vm.triggerLogsFlow.collectAsState()
val subItems by vm.subItemsFlow.collectAsState(initial = emptyList())
val vm = hiltViewModel<ClickLogVm>()
val clickLogs by vm.clickLogsFlow.collectAsState()
val clickLogCount by vm.clickLogCountFlow.collectAsState()
val appInfoCache by appInfoCacheFlow.collectAsState()
val groups = remember(triggerLogs, subItems) {
triggerLogs.map { logWrapper ->
val sub = subItems.find { sub -> sub.id == logWrapper.subsId } ?: return@map null
val app = sub.subscriptionRaw?.apps?.find { app -> app.id == logWrapper.appId }
app?.groups?.find { group -> group.key == logWrapper.groupKey }
}
}
val rules = remember(groups) {
groups.mapIndexed { index, groupRaw ->
groupRaw ?: return@mapIndexed null
val log = triggerLogs.getOrNull(index)
log?.run {
if (ruleKey != null) {
groupRaw.rules.find { r -> r.key == ruleKey }?.let { return@mapIndexed it }
}
groupRaw.rules.getOrNull(ruleIndex)
}
}
}
var previewTriggerLog by remember {
mutableStateOf<TriggerLog?>(null)
var previewClickLog by remember {
mutableStateOf<ClickLog?>(null)
}
Scaffold(topBar = {
SimpleTopAppBar(
onClickIcon = { navController.popBackStack() },
title = "触发记录" + if (triggerLogs.isEmpty()) "" else ("-" + triggerLogs.size.toString())
title = "点击记录" + if (clickLogCount <= 0) "" else ("-$clickLogCount")
)
}, content = { contentPadding ->
LazyColumn(
@ -97,12 +73,12 @@ fun RecordPage() {
item {
Spacer(modifier = Modifier.height(5.dp))
}
itemsIndexed(triggerLogs) { index, triggerLog ->
items(clickLogs, { triggerLog -> triggerLog.id }) { triggerLog ->
Column(modifier = Modifier
.fillMaxWidth()
.border(BorderStroke(1.dp, Color.Black))
.clickable {
previewTriggerLog = triggerLog
previewClickLog = triggerLog
}) {
Row {
Text(
@ -111,22 +87,22 @@ fun RecordPage() {
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = getAppName(triggerLog.appId) ?: triggerLog.appId ?: ""
text = appInfoCache[triggerLog.appId]?.name ?: triggerLog.appId ?: ""
)
}
Spacer(modifier = Modifier.width(10.dp))
Text(text = triggerLog.activityId ?: "")
groups.getOrNull(index)?.name?.let { groupName ->
val group = vm.getGroup(triggerLog)
if (group?.name != null) {
Spacer(modifier = Modifier.width(10.dp))
Text(text = groupName)
Text(text = group.name)
}
rules.getOrNull(index)?.name?.let { ruleName ->
Spacer(modifier = Modifier.width(10.dp))
Text(text = ruleName)
val rule = group?.rules?.run {
find { r -> r.key == triggerLog.ruleKey } ?: getOrNull(triggerLog.ruleIndex)
}
rules.getOrNull(index)?.matches?.lastOrNull()?.let { matchText ->
if (rule?.name != null) {
Spacer(modifier = Modifier.width(10.dp))
Text(text = matchText)
Text(text = rule.name)
}
}
}
@ -136,8 +112,8 @@ fun RecordPage() {
}
})
previewTriggerLog?.let { previewTriggerLogVal ->
Dialog(onDismissRequest = { previewTriggerLog = null }) {
previewClickLog?.let { previewTriggerLogVal ->
Dialog(onDismissRequest = { previewClickLog = null }) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
@ -155,15 +131,15 @@ fun RecordPage() {
previewTriggerLogVal.groupKey
)
)
previewTriggerLog = null
previewClickLog = null
}
.fillMaxWidth()
.padding(10.dp))
Text(text = "删除", modifier = Modifier
.clickable(onClick = scope.launchAsFn {
previewTriggerLog = null
DbSet.triggerLogDb
.triggerLogDao()
previewClickLog = null
DbSet.clickLogDb
.clickLogDao()
.delete(previewTriggerLogVal)
})
.fillMaxWidth()

View File

@ -0,0 +1,27 @@
package li.songe.gkd.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.data.ClickLog
import li.songe.gkd.db.DbSet
import li.songe.gkd.util.subsIdToRawFlow
import li.songe.gkd.util.subsItemsFlow
import javax.inject.Inject
@HiltViewModel
class ClickLogVm @Inject constructor() : ViewModel() {
val clickLogsFlow = DbSet.clickLogDb.clickLogDao().query()
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val clickLogCountFlow = DbSet.clickLogDb.clickLogDao().count()
.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
fun getGroup(clickLog: ClickLog): SubscriptionRaw.GroupRaw? {
val subsItem = subsItemsFlow.value.find { s -> s.id == clickLog.subsId } ?: return null
return subsIdToRawFlow.value[subsItem.id]?.apps?.find { a -> a.id == clickLog.appId }?.groups?.find { g -> g.key == clickLog.groupKey }
}
}

View File

@ -2,41 +2,42 @@ package li.songe.gkd.ui
import android.content.Intent
import android.provider.Settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Icon
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.blankj.utilcode.util.ToastUtils
import com.ramcosta.composedestinations.navigation.navigate
import kotlinx.coroutines.delay
import li.songe.gkd.MainActivity
import li.songe.gkd.service.GkdAbService
import li.songe.gkd.ui.component.SettingItem
import li.songe.gkd.ui.component.AuthCard
import li.songe.gkd.ui.component.TextSwitch
import li.songe.gkd.ui.destinations.AboutPageDestination
import li.songe.gkd.ui.destinations.RecordPageDestination
import li.songe.gkd.ui.destinations.SnapshotPageDestination
import li.songe.gkd.ui.destinations.ClickLogPageDestination
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.SafeR
import li.songe.gkd.util.launchAsFn
import li.songe.gkd.util.storeFlow
import li.songe.gkd.util.updateStore
import li.songe.gkd.util.updateStorage
import li.songe.gkd.util.usePollState
import li.songe.gkd.util.useTask
val controlNav = BottomNavItem(label = "主页", icon = SafeR.ic_home, route = "settings")
@ -44,10 +45,9 @@ val controlNav = BottomNavItem(label = "主页", icon = SafeR.ic_home, route = "
fun ControlPage() {
val context = LocalContext.current as MainActivity
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
val vm = hiltViewModel<ControlVm>()
val recordCount by vm.recordCountFlow.collectAsState()
val latestRecordGroup by vm.latestRecordGroup.collectAsState()
val subsStatus by vm.subsStatusFlow.collectAsState()
val store by storeFlow.collectAsState()
Scaffold(
@ -68,17 +68,15 @@ fun ControlPage() {
.padding(padding)
) {
val gkdAccessRunning by usePollState { GkdAbService.isRunning() }
TextSwitch(name = "无障碍授权",
desc = "用于获取屏幕信息,点击屏幕上的控件",
gkdAccessRunning,
onCheckedChange = scope.launchAsFn<Boolean> {
if (!it) return@launchAsFn
ToastUtils.showShort("请先启动无障碍服务")
delay(500)
val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)
})
if (!gkdAccessRunning) {
AuthCard(title = "无障碍授权",
desc = "用于获取屏幕信息,点击屏幕上的控件",
onAuthClick = {
val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)
})
}
Spacer(modifier = Modifier.height(5.dp))
@ -86,29 +84,33 @@ fun ControlPage() {
desc = "保持服务开启,根据订阅规则匹配屏幕目标节点",
checked = store.enableService,
onCheckedChange = {
updateStore(
store.copy(
updateStorage(
storeFlow, store.copy(
enableService = it
)
)
})
Spacer(modifier = Modifier.height(5.dp))
Text(text = "规则已触发 $recordCount", modifier = Modifier.padding(10.dp, 0.dp))
Spacer(modifier = Modifier.height(5.dp))
Text(text = "最近触发规则组: 微信朋友圈广告", modifier = Modifier.padding(10.dp, 0.dp))
SettingItem(title = "快照记录", onClick = scope.useTask().launchAsFn {
navController.navigate(SnapshotPageDestination)
})
SettingItem(title = "触发记录", onClick = scope.useTask().launchAsFn {
navController.navigate(RecordPageDestination)
})
SettingItem(title = "关于", onClick = scope.useTask().launchAsFn {
navController.navigate(AboutPageDestination)
})
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable {
navController.navigate(ClickLogPageDestination)
}
.padding(10.dp, 5.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = subsStatus, fontSize = 18.sp
)
Spacer(modifier = Modifier.height(2.dp))
Text(text = latestRecordGroup?.name?.let { "最近点击: $it" } ?: "暂无记录",
fontSize = 14.sp)
}
Icon(imageVector = Icons.Default.KeyboardArrowRight, contentDescription = null)
}
}
}

View File

@ -4,11 +4,33 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import li.songe.gkd.db.DbSet
import li.songe.gkd.util.appIdToRulesFlow
import li.songe.gkd.util.clickCountFlow
import li.songe.gkd.util.storeFlow
import li.songe.gkd.util.subsIdToRawFlow
import javax.inject.Inject
@HiltViewModel
class ControlVm @Inject constructor() : ViewModel() {
val recordCountFlow = DbSet.triggerLogDb.triggerLogDao().count().stateIn(viewModelScope, SharingStarted.Eagerly, 0)
private val latestRecordFlow = DbSet.clickLogDb.clickLogDao().queryLatest()
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
val latestRecordGroup =
combine(latestRecordFlow, subsIdToRawFlow) { latestRecord, subsIdToRaw ->
subsIdToRaw[latestRecord?.subsId]?.apps?.find { a -> a.id == latestRecord?.appId }?.groups?.find { g -> g.key == latestRecord?.groupKey }
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
val subsStatusFlow = combine(appIdToRulesFlow, clickCountFlow) { appIdToRules, clickCount ->
val appSize = appIdToRules.keys.size
val groupSize = appIdToRules.values.flatten().map { r -> r.group.hashCode() }.toSet().size
(if (groupSize > 0) {
"${appSize}应用/${groupSize}规则组"
} else {
"暂无规则"
}) + if (clickCount > 0) "/${clickCount}点击" else ""
}.stateIn(viewModelScope, SharingStarted.Eagerly, "")
}

View File

@ -0,0 +1,245 @@
package li.songe.gkd.ui
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.provider.Settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
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.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.content.ContextCompat
import com.blankj.utilcode.util.RomUtils
import com.blankj.utilcode.util.ToastUtils
import com.dylanc.activityresult.launcher.launchForResult
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import com.ramcosta.composedestinations.navigation.navigate
import li.songe.gkd.MainActivity
import li.songe.gkd.appScope
import li.songe.gkd.debug.FloatingService
import li.songe.gkd.debug.HttpService
import li.songe.gkd.debug.ScreenshotService
import li.songe.gkd.shizuku.shizukuIsSafeOK
import li.songe.gkd.ui.component.AuthCard
import li.songe.gkd.ui.component.SettingItem
import li.songe.gkd.ui.component.SimpleTopAppBar
import li.songe.gkd.ui.component.TextSwitch
import li.songe.gkd.ui.destinations.SnapshotPageDestination
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.launchAsFn
import li.songe.gkd.util.storeFlow
import li.songe.gkd.util.updateStorage
import li.songe.gkd.util.usePollState
import rikka.shizuku.Shizuku
@RootNavGraph
@Destination(style = ProfileTransitions::class)
@Composable
fun DebugPage() {
val context = LocalContext.current as MainActivity
val launcher = LocalLauncher.current
val navController = LocalNavController.current
val store by storeFlow.collectAsState()
var showPortDlg by remember {
mutableStateOf(false)
}
Scaffold(topBar = {
SimpleTopAppBar(
onClickIcon = { navController.popBackStack() }, title = "高级模式"
)
}, content = { contentPadding ->
Column(
modifier = Modifier
.padding(0.dp, 10.dp)
.padding(contentPadding),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
val shizukuIsOk by usePollState { shizukuIsSafeOK() }
if (!shizukuIsOk) {
AuthCard(title = "Shizuku授权",
desc = "高级运行模式,能更准确识别界面活动ID",
onAuthClick = {
try {
Shizuku.requestPermission(Activity.RESULT_OK)
} catch (e: Exception) {
ToastUtils.showShort("Shizuku可能没有运行")
}
})
Divider()
}
val canDrawOverlays by usePollState { Settings.canDrawOverlays(context) }
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()
}
val httpServerRunning by usePollState { HttpService.isRunning() }
TextSwitch(
name = "HTTP服务",
desc = "开启HTTP服务, 以便在同一局域网下传递数据" + if (httpServerRunning) "\n${
Ext.getIpAddressInLocalNetwork()
.map { host -> "http://${host}:${store.httpServerPort}" }.joinToString(",")
}" else "",
httpServerRunning
) {
if (it) {
HttpService.start()
} else {
HttpService.stop()
}
}
Divider()
SettingItem(
title = "HTTP服务端口-${store.httpServerPort}", imageVector = Icons.Default.Edit
) {
showPortDlg = true
}
Divider()
// android 11 以上可以使用无障碍服务获取屏幕截图
// Build.VERSION.SDK_INT < Build.VERSION_CODES.R
val screenshotRunning by usePollState { ScreenshotService.isRunning() }
TextSwitch(name = "截屏服务",
desc = "生成快照需要获取屏幕截图",
screenshotRunning,
appScope.launchAsFn<Boolean> {
if (it) {
val mediaProjectionManager =
context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val activityResult =
launcher.launchForResult(mediaProjectionManager.createScreenCaptureIntent())
if (activityResult.resultCode == Activity.RESULT_OK && activityResult.data != null) {
ScreenshotService.start(intent = activityResult.data!!)
}
} else {
ScreenshotService.stop()
}
})
Divider()
val floatingRunning by usePollState {
FloatingService.isRunning()
}
TextSwitch(name = "悬浮窗服务", desc = "便于用户主动保存快照", floatingRunning) {
if (it) {
if (canDrawOverlays) {
val intent = Intent(context, FloatingService::class.java)
ContextCompat.startForegroundService(context, intent)
} else {
ToastUtils.showShort("请先完成悬浮窗授权再开启")
}
} else {
FloatingService.stop(context)
}
}
Divider()
TextSwitch(
"按键快照", "当用户按下音量键时,自动保存当前界面的快照", store.captureVolumeKey
) {
updateStorage(
storeFlow, store.copy(
captureVolumeKey = it
)
)
}
Divider()
SettingItem(title = "快照记录", onClick = {
navController.navigate(SnapshotPageDestination)
})
}
})
if (showPortDlg) {
Dialog(onDismissRequest = { showPortDlg = false }) {
var value by remember {
mutableStateOf(store.httpServerPort.toString())
}
Column(
modifier = Modifier.padding(10.dp)
) {
Text(text = "请输入新端口", style = MaterialTheme.typography.h6)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = value,
onValueChange = { value = it.trim() },
singleLine = true,
modifier = Modifier,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
Row(
horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()
) {
TextButton(onClick = { showPortDlg = false }) {
Text(
text = "取消", modifier = Modifier
)
}
Spacer(modifier = Modifier.width(5.dp))
TextButton(onClick = {
val newPort = value.toIntOrNull()
if (newPort == null || !(5000 <= newPort && newPort <= 65535)) {
ToastUtils.showShort("请输入在 5000~65535 的任意数字")
return@TextButton
}
updateStorage(
storeFlow, store.copy(
httpServerPort = newPort
)
)
showPortDlg = false
}) {
Text(
text = "确认", modifier = Modifier
)
}
}
}
}
}
}

View File

@ -1,5 +1,6 @@
package li.songe.gkd.ui
import android.app.Activity
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@ -10,19 +11,19 @@ import androidx.compose.material.Icon
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
import li.songe.gkd.util.ProfileTransitions
import li.songe.gkd.util.getImportUrl
val BottomNavItems = listOf(
subsNav, controlNav, settingsNav
@ -34,17 +35,22 @@ data class BottomNavItem(
val route: String,
)
@HiltViewModel
class HomePageVm @Inject constructor() : ViewModel() {
val tabFlow = MutableStateFlow(controlNav)
}
@RootNavGraph(start = true)
@Destination
@Destination(style = ProfileTransitions::class)
@Composable
fun HomePage() {
val context = LocalContext.current as Activity
val vm = hiltViewModel<HomePageVm>()
val tab by vm.tabFlow.collectAsState()
val intent by vm.intentFlow.collectAsState()
LaunchedEffect(key1 = Unit, block = {
vm.intentFlow.value = context.intent
})
LaunchedEffect(intent, block = {
if (getImportUrl(intent) != null) {
vm.tabFlow.value = subsNav
}
})
Scaffold(bottomBar = {
BottomNavigation(

View File

@ -0,0 +1,13 @@
package li.songe.gkd.ui
import android.content.Intent
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
@HiltViewModel
class HomePageVm @Inject constructor() : ViewModel() {
val tabFlow = MutableStateFlow(controlNav)
val intentFlow = MutableStateFlow<Intent?>(null)
}

View File

@ -26,9 +26,10 @@ import com.ramcosta.composedestinations.annotation.RootNavGraph
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.withContext
import li.songe.gkd.util.LaunchedEffectTry
import li.songe.gkd.util.ProfileTransitions
@RootNavGraph
@Destination
@Destination(style = ProfileTransitions::class)
@Composable
fun ImagePreviewPage(
filePath: String?,

View File

@ -1,22 +0,0 @@
package li.songe.gkd.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import li.songe.gkd.db.DbSet
import javax.inject.Inject
@HiltViewModel
class RecordVm @Inject constructor() : ViewModel() {
val triggerLogsFlow = DbSet.triggerLogDb.triggerLogDao().query().stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val subItemsFlow = DbSet.subsItemDao.query().onEach {
withContext(Dispatchers.IO) {
it.forEach { subs -> subs.subscriptionRaw }
}
}
}

View File

@ -5,9 +5,8 @@ import android.content.Context
import android.content.Intent
import android.media.projection.MediaProjectionManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -17,11 +16,17 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme.typography
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.material.TextButton
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@ -32,24 +37,36 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.content.ContextCompat
import com.blankj.utilcode.util.RomUtils
import com.blankj.utilcode.util.ToastUtils
import com.dylanc.activityresult.launcher.launchForResult
import com.ramcosta.composedestinations.navigation.navigate
import li.songe.gkd.MainActivity
import li.songe.gkd.appScope
import li.songe.gkd.debug.FloatingService
import li.songe.gkd.debug.HttpService
import li.songe.gkd.debug.ScreenshotService
import li.songe.gkd.shizuku.shizukuIsSafeOK
import li.songe.gkd.ui.component.AuthCard
import li.songe.gkd.ui.component.SettingItem
import li.songe.gkd.ui.component.TextSwitch
import li.songe.gkd.ui.destinations.AboutPageDestination
import li.songe.gkd.ui.destinations.ClickLogPageDestination
import li.songe.gkd.ui.destinations.DebugPageDestination
import li.songe.gkd.ui.destinations.SnapshotPageDestination
import li.songe.gkd.util.Ext
import li.songe.gkd.util.LocalLauncher
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.SafeR
import li.songe.gkd.util.checkUpdate
import li.songe.gkd.util.launchAsFn
import li.songe.gkd.util.launchTry
import li.songe.gkd.util.storeFlow
import li.songe.gkd.util.updateStore
import li.songe.gkd.util.updateStorage
import li.songe.gkd.util.usePollState
import rikka.shizuku.Shizuku
@ -61,11 +78,10 @@ val settingsNav = BottomNavItem(
fun SettingsPage() {
val context = LocalContext.current as MainActivity
val launcher = LocalLauncher.current
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
val store by storeFlow.collectAsState()
var showPortDlg by remember {
mutableStateOf(false)
}
@ -86,181 +102,71 @@ fun SettingsPage() {
.padding(contentPadding)
) {
val shizukuIsOk by usePollState { shizukuIsSafeOK() }
TextSwitch(name = "Shizuku授权",
desc = "高级运行模式,能更准确识别界面活动ID",
shizukuIsOk,
onCheckedChange = scope.launchAsFn<Boolean> {
if (!it) return@launchAsFn
try {
Shizuku.requestPermission(Activity.RESULT_OK)
} catch (e: Exception) {
ToastUtils.showShort("Shizuku可能没有运行")
}
})
val canDrawOverlays by usePollState {
Settings.canDrawOverlays(context)
}
Spacer(modifier = Modifier.height(5.dp))
TextSwitch(name = "悬浮窗授权",
desc = "用于后台提示,主动保存快照等功能",
canDrawOverlays,
onCheckedChange = scope.launchAsFn<Boolean> {
if (!Settings.canDrawOverlays(context)) {
val intent = Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:$context.packageName")
)
launcher.launch(intent) { resultCode, _ ->
if (resultCode != ComponentActivity.RESULT_OK) return@launch
if (!Settings.canDrawOverlays(context)) return@launch
val intent1 = Intent(context, FloatingService::class.java)
ContextCompat.startForegroundService(context, intent1)
}
}
})
val httpServerRunning by usePollState { HttpService.isRunning() }
TextSwitch(
name = "HTTP服务",
desc = "开启HTTP服务, 以便在同一局域网下传递数据" + if (httpServerRunning) "\n${
Ext.getIpAddressInLocalNetwork()
.map { host -> "http://${host}:${store.httpServerPort}" }.joinToString(",")
}" else "\n暂无地址",
httpServerRunning
) {
if (it) {
HttpService.start()
} else {
HttpService.stop()
}
}
SettingItem(title = "HTTP服务端口-${store.httpServerPort}") {
showPortDlg = true
}
val screenshotRunning by usePollState { ScreenshotService.isRunning() }
TextSwitch(name = "截屏服务",
desc = "生成快照需要截取屏幕,Android>=11无需开启",
screenshotRunning,
scope.launchAsFn<Boolean> {
if (it) {
val mediaProjectionManager =
context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val activityResult =
launcher.launchForResult(mediaProjectionManager.createScreenCaptureIntent())
if (activityResult.resultCode == Activity.RESULT_OK && activityResult.data != null) {
ScreenshotService.start(intent = activityResult.data!!)
}
} else {
ScreenshotService.stop()
}
})
val floatingRunning by usePollState {
FloatingService.isRunning()
}
TextSwitch(name = "悬浮窗服务", desc = "便于用户主动保存快照", floatingRunning) {
if (it) {
if (Settings.canDrawOverlays(context)) {
val intent = Intent(context, FloatingService::class.java)
ContextCompat.startForegroundService(context, intent)
} else {
val intent = Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:$context.packageName")
)
launcher.launch(intent) { resultCode, _ ->
if (resultCode != ComponentActivity.RESULT_OK) return@launch
if (!Settings.canDrawOverlays(context)) return@launch
val intent1 = Intent(context, FloatingService::class.java)
ContextCompat.startForegroundService(context, intent1)
}
}
} else {
FloatingService.stop(context)
}
}
TextSwitch(name = "隐藏后台",
TextSwitch(name = "后台隐藏",
desc = "在[最近任务]界面中隐藏本应用",
checked = store.excludeFromRecents,
onCheckedChange = {
updateStore(
store.copy(
updateStorage(
storeFlow, store.copy(
excludeFromRecents = it
)
)
})
Divider()
TextSwitch(name = "日志输出",
desc = "保持日志输出到控制台",
checked = store.enableConsoleLogOut,
Spacer(modifier = Modifier.height(5.dp))
TextSwitch(name = "自动更新订阅",
desc = "每隔一段时间自动更新订阅规则文件",
checked = store.autoUpdateSubs,
onCheckedChange = {
updateStore(
store.copy(
enableConsoleLogOut = it
updateStorage(
storeFlow, store.copy(
autoUpdateSubs = it
)
)
})
Divider()
TextSwitch(name = "自动更新应用",
desc = "打开应用时自动检测是否存在新版本",
checked = store.autoCheckAppUpdate,
onCheckedChange = {
updateStorage(
storeFlow, store.copy(
autoCheckAppUpdate = it
)
)
})
Divider()
Spacer(modifier = Modifier.height(5.dp))
TextSwitch(
"自动快照",
"当用户截屏时,自动保存当前界面的快照,目前仅支持miui",
store.enableCaptureScreenshot
) {
updateStore(
store.copy(
enableCaptureScreenshot = it
SettingItem(title = "检查更新", onClick = {
appScope.launchTry {
val newVersion = checkUpdate()
if (newVersion == null) {
ToastUtils.showShort("暂无更新")
}
}
})
Divider()
SettingItem(title = "问题反馈", onClick = {
context.startActivity(
Intent(
Intent.ACTION_VIEW, Uri.parse("https://github.com/gkd-kit/subscription")
)
)
}
})
Divider()
SettingItem(title = "高级模式", onClick = {
navController.navigate(DebugPageDestination)
})
Divider()
SettingItem(title = "关于", onClick = {
navController.navigate(AboutPageDestination)
})
Spacer(modifier = Modifier.height(40.dp))
}
})
if (showPortDlg) {
Dialog(onDismissRequest = { showPortDlg = false }) {
var value by remember {
mutableStateOf(store.httpServerPort.toString())
}
Column(
modifier = Modifier
.padding(10.dp)
.width(300.dp)
) {
TextField(value = value, onValueChange = { value = it.trim() }, singleLine = true)
Spacer(modifier = Modifier.height(10.dp))
Row(
horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()
) {
Text(text = "取消",
modifier = Modifier
.clickable { showPortDlg = false }
.padding(5.dp))
Spacer(modifier = Modifier.width(5.dp))
Text(text = "确认", modifier = Modifier
.clickable {
val newPort = value.toIntOrNull()
if (newPort == null || !(5000 <= newPort && newPort <= 65535)) {
ToastUtils.showShort("请输入在 5000~65535 的任意数字")
return@clickable
}
updateStore(
store.copy(
httpServerPort = newPort
)
)
showPortDlg = false
}
.padding(5.dp))
}
}
}
}
}

View File

@ -46,11 +46,12 @@ import li.songe.gkd.debug.SnapshotExt
import li.songe.gkd.ui.component.SimpleTopAppBar
import li.songe.gkd.ui.destinations.ImagePreviewPageDestination
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.ProfileTransitions
import li.songe.gkd.util.format
import li.songe.gkd.util.launchAsFn
@RootNavGraph
@Destination
@Destination(style = ProfileTransitions::class)
@Composable
fun SnapshotPage() {
val scope = rememberCoroutineScope()
@ -95,8 +96,6 @@ fun SnapshotPage() {
Text(text = snapshot.appName ?: "")
}
Spacer(modifier = Modifier.width(10.dp))
Text(text = snapshot.appId ?: "")
Spacer(modifier = Modifier.width(10.dp))
Text(text = snapshot.activityId ?: "")
}
}

View File

@ -1,6 +1,7 @@
package li.songe.gkd.ui
import android.webkit.URLUtil
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
@ -9,25 +10,32 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.Card
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.FloatingActionButton
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.material.TextButton
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
@ -41,11 +49,12 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.painterResource
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
@ -53,22 +62,24 @@ import com.blankj.utilcode.util.ClipboardUtils
import com.blankj.utilcode.util.ToastUtils
import com.google.zxing.BarcodeFormat
import com.ramcosta.composedestinations.navigation.navigate
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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.SubsPageDestination
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.SafeR
import li.songe.gkd.util.Singleton
import li.songe.gkd.util.getImportUrl
import li.songe.gkd.util.launchAsFn
import li.songe.gkd.util.subsIdToRawFlow
import li.songe.gkd.util.subsItemsFlow
import li.songe.gkd.util.useNavigateForQrcodeResult
import li.songe.gkd.util.useTask
import org.burnoutcrew.reorderable.ReorderableItem
import org.burnoutcrew.reorderable.detectReorderAfterLongPress
import org.burnoutcrew.reorderable.rememberReorderableLazyListState
import org.burnoutcrew.reorderable.reorderable
val subsNav = BottomNavItem(
label = "订阅", icon = SafeR.ic_link, route = "subscription"
@ -80,8 +91,28 @@ fun SubsManagePage() {
val scope = rememberCoroutineScope()
val navController = LocalNavController.current
val navigateForQrcodeResult = useNavigateForQrcodeResult()
val vm = hiltViewModel<SubsManageVm>()
val subItems by vm.subsItemsFlow.collectAsState()
val homeVm = hiltViewModel<HomePageVm>()
val subItems by subsItemsFlow.collectAsState()
val subsIdToRaw by subsIdToRawFlow.collectAsState()
val intent by homeVm.intentFlow.collectAsState()
LaunchedEffect(key1 = intent, block = {
val importUrl = getImportUrl(intent)
if (importUrl != null) {
homeVm.intentFlow.value = null
}
})
val orderSubItems = remember {
mutableStateOf(subItems)
}
LaunchedEffect(subItems, block = {
orderSubItems.value = subItems
})
var shareSubItem: SubsItem? by remember { mutableStateOf(null) }
var shareQrcode: ImageBitmap? by remember { mutableStateOf(null) }
@ -93,40 +124,26 @@ fun SubsManagePage() {
var link by remember { mutableStateOf("") }
val refreshing = scope.useTask()
val pullRefreshState = rememberPullRefreshState(refreshing.loading, refreshing.launchAsFn(IO) {
val newItems = subItems.mapNotNull { oldItem ->
try {
val subscriptionRaw = SubscriptionRaw.parse5(
Singleton.client.get(oldItem.updateUrl).bodyAsText()
)
if (subscriptionRaw.version <= oldItem.version) {
return@mapNotNull null
}
val newItem = oldItem.copy(
updateUrl = subscriptionRaw.updateUrl ?: oldItem.updateUrl,
name = subscriptionRaw.name,
mtime = System.currentTimeMillis(),
version = subscriptionRaw.version
)
withContext(IO) {
newItem.subsFile.writeText(
SubscriptionRaw.stringify(
subscriptionRaw
val refreshing by vm.refreshingFlow.collectAsState()
val pullRefreshState = rememberPullRefreshState(refreshing, vm::refreshSubs)
val state = rememberReorderableLazyListState(onMove = { from, to ->
orderSubItems.value = orderSubItems.value.toMutableList().apply {
add(to.index, removeAt(from.index))
}
}, onDragEnd = { _, _ ->
vm.viewModelScope.launch(Dispatchers.IO) {
val changeItems = mutableListOf<SubsItem>()
orderSubItems.value.forEachIndexed { index, subsItem ->
if (subItems[index] != subsItem) {
changeItems.add(
subsItem.copy(
order = index
)
)
}
newItem
} catch (e: Exception) {
ToastUtils.showShort(e.message)
null
}
}
if (newItems.isEmpty()) {
ToastUtils.showShort("暂无更新")
} else {
DbSet.subsItemDao.update(*newItems.toTypedArray())
ToastUtils.showShort("更新 ${newItems.size} 条订阅")
DbSet.subsItemDao.update(*changeItems.toTypedArray())
}
})
@ -139,15 +156,12 @@ fun SubsManagePage() {
})
},
floatingActionButton = {
FloatingActionButton(onClick = {}) {
Image(painter = painterResource(SafeR.ic_add),
contentDescription = "add_subs_item",
modifier = Modifier
.clickable {
showAddDialog = true
}
.padding(4.dp)
.size(25.dp))
FloatingActionButton(onClick = { showAddDialog = true }) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "info",
modifier = Modifier.size(30.dp)
)
}
},
) { padding ->
@ -155,41 +169,50 @@ fun SubsManagePage() {
modifier = Modifier
.fillMaxSize()
.padding(padding)
.pullRefresh(pullRefreshState)
.pullRefresh(pullRefreshState, subItems.isNotEmpty())
) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
state = state.listState,
modifier = Modifier
.reorderable(state)
.detectReorderAfterLongPress(state)
.fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(subItems, { it.id }) { subItem ->
Card(
modifier = Modifier
.animateItemPlacement()
.padding(vertical = 3.dp, horizontal = 8.dp)
.clickable(
onClick = scope
.useTask()
.launchAsFn {
navController.navigate(SubsPageDestination(subItem.id))
}),
elevation = 0.dp,
border = BorderStroke(1.dp, Color(0xfff6f6f6)),
shape = RoundedCornerShape(8.dp),
) {
SubsItemCard(
subsItem = subItem,
onMenuClick = {
menuSubItem = subItem
},
onCheckedChange = scope.launchAsFn<Boolean> {
DbSet.subsItemDao.update(subItem.copy(enable = it))
},
itemsIndexed(orderSubItems.value, { _, subItem -> subItem.id }) { index, subItem ->
ReorderableItem(state, key = subItem.id) { isDragging ->
val elevation = animateDpAsState(
if (isDragging) 16.dp else 0.dp, label = ""
)
Card(
modifier = Modifier
.shadow(elevation.value)
.animateItemPlacement()
.padding(vertical = 3.dp, horizontal = 8.dp)
.clickable {
navController.navigate(SubsPageDestination(subItem.id))
},
elevation = 0.dp,
border = BorderStroke(1.dp, Color(0xfff6f6f6)),
shape = RoundedCornerShape(8.dp),
) {
SubsItemCard(
subsItem = subItem,
subscriptionRaw = subsIdToRaw[subItem.id],
index = index + 1,
onMenuClick = {
menuSubItem = subItem
},
onCheckedChange = scope.launchAsFn<Boolean> {
DbSet.subsItemDao.update(subItem.copy(enable = it))
},
)
}
}
}
}
PullRefreshIndicator(
refreshing = refreshing.loading,
refreshing = refreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter),
)
@ -199,33 +222,34 @@ fun SubsManagePage() {
shareSubItem?.let { shareSubItemVal ->
Dialog(onDismissRequest = { shareSubItem = null }) {
Box(
Modifier
Column(
modifier = Modifier
.width(250.dp)
.background(Color.White)
.padding(8.dp)
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = "二维码", modifier = Modifier
.clickable {
Text(text = "显示二维码", modifier = Modifier
.clickable {
shareSubItem = null
scope.launch(Dispatchers.Default) {
shareQrcode = Singleton.barcodeEncoder
.encodeBitmap(
shareSubItemVal.updateUrl, BarcodeFormat.QR_CODE, 500, 500
)
.asImageBitmap()
shareSubItem = null
}
.fillMaxWidth()
.padding(8.dp))
Text(text = "导出至剪切板", modifier = Modifier
.clickable {
ClipboardUtils.copyText(shareSubItemVal.updateUrl)
ToastUtils.showShort("复制成功")
shareSubItem = null
}
.fillMaxWidth()
.padding(8.dp))
}
}
.fillMaxWidth()
.padding(8.dp))
Text(text = "导出至剪切板", modifier = Modifier
.clickable {
shareSubItem = null
ClipboardUtils.copyText(shareSubItemVal.updateUrl)
ToastUtils.showShort("复制成功")
}
.fillMaxWidth()
.padding(8.dp))
}
}
}
@ -233,14 +257,13 @@ fun SubsManagePage() {
shareQrcode?.let { shareQrcodeVal ->
Dialog(onDismissRequest = { shareQrcode = null }) {
Image(
bitmap = shareQrcodeVal,
contentDescription = "qrcode",
modifier = Modifier.size(400.dp)
bitmap = shareQrcodeVal, contentDescription = null, modifier = Modifier.size(400.dp)
)
}
}
menuSubItem?.let { menuSubItemVal ->
Dialog(onDismissRequest = { menuSubItem = null }) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
@ -257,6 +280,7 @@ fun SubsManagePage() {
}
.fillMaxWidth()
.padding(8.dp))
Text(text = "删除", modifier = Modifier
.clickable {
deleteSubItem = menuSubItemVal
@ -264,52 +288,7 @@ fun SubsManagePage() {
}
.fillMaxWidth()
.padding(8.dp))
if (subItems.firstOrNull() != menuSubItemVal) {
Text(
text = "上移",
modifier = Modifier
.clickable(
onClick = scope
.useTask()
.launchAsFn {
val lastItem =
subItems[subItems.indexOf(menuSubItemVal) - 1]
DbSet.subsItemDao.update(
lastItem.copy(
order = menuSubItemVal.order
), menuSubItemVal.copy(
order = lastItem.order
)
)
menuSubItem = null
})
.fillMaxWidth()
.padding(8.dp)
)
}
if (subItems.lastOrNull() != menuSubItemVal) {
Text(
text = "下移",
modifier = Modifier
.clickable(
onClick = scope
.useTask()
.launchAsFn {
val nextItem =
subItems[subItems.indexOf(menuSubItemVal) + 1]
DbSet.subsItemDao.update(
nextItem.copy(
order = menuSubItemVal.order
), menuSubItemVal.copy(
order = nextItem.order
)
)
menuSubItem = null
})
.fillMaxWidth()
.padding(8.dp)
)
}
}
}
}
@ -317,9 +296,9 @@ fun SubsManagePage() {
deleteSubItem?.let { deleteSubItemVal ->
AlertDialog(onDismissRequest = { deleteSubItem = null },
title = { Text(text = "是否删除该项") },
title = { Text(text = "是否删除 ${subsIdToRaw[deleteSubItemVal.id]?.name}?") },
confirmButton = {
Button(onClick = scope.launchAsFn {
TextButton(onClick = scope.launchAsFn {
deleteSubItemVal.removeAssets()
deleteSubItem = null
}) {
@ -327,7 +306,7 @@ fun SubsManagePage() {
}
},
dismissButton = {
Button(onClick = {
TextButton(onClick = {
deleteSubItem = null
}) {
Text("")
@ -344,6 +323,16 @@ fun SubsManagePage() {
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (subItems.all { it.id != 0L }) {
Text(text = "默认订阅", modifier = Modifier
.clickable {
showAddDialog = false
vm.addSubsFromUrl("https://registry.npmmirror.com/@gkd-kit/subscription/latest/files")
}
.fillMaxWidth()
.padding(8.dp))
}
Text(
text = "二维码", modifier = Modifier
.clickable(onClick = scope.launchAsFn {
@ -360,8 +349,8 @@ fun SubsManagePage() {
)
Text(text = "链接", modifier = Modifier
.clickable {
showAddLinkDialog = true
showAddDialog = false
showAddLinkDialog = true
}
.fillMaxWidth()
.padding(8.dp))
@ -378,25 +367,35 @@ fun SubsManagePage() {
}
if (showAddLinkDialog) {
Dialog(onDismissRequest = { showAddLinkDialog = false }) {
Box(
Modifier
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.width(300.dp)
.background(Color.White)
.padding(8.dp)
.padding(10.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = "请输入订阅链接")
TextField(
value = link, onValueChange = { link = it.trim() }, singleLine = true
)
Button(onClick = {
Text(text = "请输入订阅链接", fontSize = 20.sp)
Spacer(modifier = Modifier.height(2.dp))
OutlinedTextField(
value = link,
onValueChange = { link = it.trim() },
maxLines = 2,
textStyle = LocalTextStyle.current.copy(fontSize = 14.sp)
)
Row(
horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()
) {
TextButton(onClick = {
if (!URLUtil.isNetworkUrl(link)) {
return@Button ToastUtils.showShort("非法链接")
ToastUtils.showShort("非法链接")
return@TextButton
}
if (subItems.any { s -> s.updateUrl == link }) {
ToastUtils.showShort("链接已存在")
return@TextButton
}
showAddLinkDialog = false
vm.viewModelScope.launch {
vm.addSubsFromUrl(url = link)
}
vm.addSubsFromUrl(url = link)
}) {
Text(text = "添加")
}

View File

@ -8,56 +8,120 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import li.songe.gkd.data.SubsItem
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.db.DbSet
import li.songe.gkd.util.Singleton
import li.songe.gkd.util.subsIdToRawFlow
import li.songe.gkd.util.subsItemsFlow
import javax.inject.Inject
@HiltViewModel
class SubsManageVm @Inject constructor() : ViewModel() {
val subsItemsFlow = DbSet.subsItemDao.query().stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
suspend fun addSubsFromUrl(url: String) {
fun addSubsFromUrl(url: String) = viewModelScope.launch {
if (refreshingFlow.value) return@launch
if (!URLUtil.isNetworkUrl(url)) {
ToastUtils.showShort("非法链接")
return
return@launch
}
val subItems = subsItemsFlow.first()
val subItems = subsItemsFlow.value
if (subItems.any { it.updateUrl == url }) {
ToastUtils.showShort("订阅链接已存在")
return
return@launch
}
val text = try {
Singleton.client.get(url).bodyAsText()
} catch (e: Exception) {
e.printStackTrace()
ToastUtils.showShort("下载订阅文件失败")
return
refreshingFlow.value = true
try {
val text = try {
Singleton.client.get(url).bodyAsText()
} catch (e: Exception) {
e.printStackTrace()
ToastUtils.showShort("下载订阅文件失败")
return@launch
}
val newSubsRaw = try {
SubscriptionRaw.parse5(text)
} catch (e: Exception) {
e.printStackTrace()
ToastUtils.showShort("解析订阅文件失败")
return@launch
}
if (subItems.any { it.id == newSubsRaw.id }) {
ToastUtils.showShort("订阅已存在")
return@launch
}
if (newSubsRaw.id < 0) {
ToastUtils.showShort("订阅id不可小于0")
return@launch
}
val newItem = SubsItem(
id = newSubsRaw.id,
updateUrl = newSubsRaw.updateUrl ?: url,
order = if (subItems.isEmpty()) 1 else (subItems.maxBy { it.order }.order + 1)
)
withContext(Dispatchers.IO) {
newItem.subsFile.writeText(text)
}
DbSet.subsItemDao.insert(newItem)
ToastUtils.showShort("成功添加订阅")
} finally {
refreshingFlow.value = false
}
val subscriptionRaw = try {
SubscriptionRaw.parse5(text)
} catch (e: Exception) {
e.printStackTrace()
ToastUtils.showShort("解析订阅文件失败")
return
}
val refreshingFlow = MutableStateFlow(false)
fun refreshSubs() = viewModelScope.launch(Dispatchers.IO) {
if (refreshingFlow.value) return@launch
refreshingFlow.value = true
val st = System.currentTimeMillis()
var errorNum = 0
val oldSubItems = subsItemsFlow.value
val newSubsItems = oldSubItems.mapNotNull { oldItem ->
val oldSubsRaw = subsIdToRawFlow.value[oldItem.id]
try {
val newSubsRaw = SubscriptionRaw.parse5(
Singleton.client.get(oldItem.updateUrl).bodyAsText()
)
if (oldSubsRaw != null && newSubsRaw.version <= oldSubsRaw.version) {
return@mapNotNull null
}
val newItem = oldItem.copy(
updateUrl = newSubsRaw.updateUrl ?: oldItem.updateUrl,
mtime = System.currentTimeMillis(),
)
withContext(Dispatchers.IO) {
newItem.subsFile.writeText(
SubscriptionRaw.stringify(
newSubsRaw
)
)
}
newItem
} catch (e: Exception) {
e.printStackTrace()
errorNum++
null
}
}
val newItem = SubsItem(
updateUrl = subscriptionRaw.updateUrl ?: url,
name = subscriptionRaw.name,
version = subscriptionRaw.version,
order = subItems.size + 1
)
withContext(Dispatchers.IO) {
newItem.subsFile.writeText(text)
if (newSubsItems.isEmpty()) {
if (errorNum == oldSubItems.size) {
ToastUtils.showShort("更新失败")
} else {
ToastUtils.showShort("暂无更新")
}
} else {
DbSet.subsItemDao.update(*newSubsItems.toTypedArray())
ToastUtils.showShort("更新 ${newSubsItems.size} 条订阅")
}
DbSet.subsItemDao.insert(newItem)
ToastUtils.showShort("成功添加订阅")
delay(500)
refreshingFlow.value = false
}
}

View File

@ -1,77 +1,104 @@
package li.songe.gkd.ui
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.AlertDialog
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import com.ramcosta.composedestinations.navigation.navigate
import li.songe.gkd.data.SubsConfig
import li.songe.gkd.data.getAppInfo
import li.songe.gkd.db.DbSet
import li.songe.gkd.ui.component.SimpleTopAppBar
import li.songe.gkd.ui.component.SubsAppCard
import li.songe.gkd.ui.destinations.AppItemPageDestination
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.ProfileTransitions
import li.songe.gkd.util.SafeR
import li.songe.gkd.util.appInfoCacheFlow
import li.songe.gkd.util.formatTimeAgo
import li.songe.gkd.util.launchAsFn
import java.text.Collator
import java.util.Locale
import li.songe.gkd.util.subsIdToRawFlow
@RootNavGraph
@Destination
@Destination(style = ProfileTransitions::class)
@Composable
fun SubsPage(
subsItemId: Long,
) {
val scope = rememberCoroutineScope()
val navController = LocalNavController.current
val context = LocalContext.current
val vm = hiltViewModel<SubsVm>()
val subsItem by vm.subsItemFlow.collectAsState()
val subsConfigs by vm.subsConfigsFlow.collectAsState(initial = emptyList())
val subsIdToRaw by subsIdToRawFlow.collectAsState()
val appAndConfigs by vm.appAndConfigsFlow.collectAsState()
val appInfoCache by appInfoCacheFlow.collectAsState()
val orderedApps by remember(subsItem) {
derivedStateOf {
(subsItem?.subscriptionRaw?.apps ?: emptyList()).sortedWith { a, b ->
Collator.getInstance(Locale.CHINESE)
.compare(getAppInfo(a.id).realName, getAppInfo(b.id).realName)
}
}
val subsRaw = subsIdToRaw[subsItem?.id]
var showDetailDlg by remember {
mutableStateOf(false)
}
// val openAppPage = scope.useTask().launchAsFn<SubsAppCardData> {
// navController.navigate(AppItemPageDestination(it.subsConfig.subsItemId, it.appRaw.id))
// }
Scaffold(
topBar = {
SimpleTopAppBar(
onClickIcon = { navController.popBackStack() }, title = subsItem?.name ?: ""
)
// 右上角菜单显示关于 dialog 一级属性
SimpleTopAppBar(onClickIcon = { navController.popBackStack() },
title = subsRaw?.name ?: "",
actions = {
IconButton(onClick = {
if (subsRaw != null) {
showDetailDlg = true
}
}) {
Icon(
painter = painterResource(SafeR.ic_info),
contentDescription = "info",
modifier = Modifier.size(30.dp)
)
}
})
},
) { padding ->
subsItem?.subscriptionRaw?.let {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.padding(padding)
) {
items(orderedApps, { it.id }) { appRaw ->
val subsConfig = subsConfigs.find { s -> s.appId == appRaw.id }
SubsAppCard(appRaw = appRaw, subsConfig = subsConfig, onClick = {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(0.dp), modifier = Modifier.padding(padding)
) {
itemsIndexed(appAndConfigs, { i, a -> i.toString() + a.first.id }) { _, a ->
val (appRaw, subsConfig) = a
SubsAppCard(appRaw = appRaw,
appInfo = appInfoCache[appRaw.id],
subsConfig = subsConfig,
onClick = {
navController.navigate(AppItemPageDestination(subsItemId, appRaw.id))
}, onValueChange = scope.launchAsFn { enable ->
},
onValueChange = scope.launchAsFn { enable ->
val newItem = subsConfig?.copy(
enable = enable
) ?: SubsConfig(
@ -82,11 +109,56 @@ fun SubsPage(
)
DbSet.subsConfigDao.insert(newItem)
})
}
item(null) {
Spacer(modifier = Modifier.height(10.dp))
}
item {
if (appAndConfigs.isEmpty()) {
Spacer(modifier = Modifier.height(40.dp))
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = "此订阅文件暂无规则")
}
}
}
item {
Spacer(modifier = Modifier.height(20.dp))
}
}
}
if (showDetailDlg && subsRaw != null && subsItem != null) {
AlertDialog(onDismissRequest = { showDetailDlg = false }, title = {
Text(text = "订阅详情")
}, text = {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier
) {
Text(text = "名称: " + subsRaw.name)
Text(text = "版本: " + subsRaw.version)
if (subsRaw.author != null) {
Text(text = "作者: " + subsRaw.author)
}
val apps = subsRaw.apps
val groupsSize = apps.sumOf { it.groups.size }
if (groupsSize > 0) {
Text(text = "规则: ${apps.size}应用/${groupsSize}规则组")
}
Text(text = "更新: " + formatTimeAgo(subsItem!!.mtime))
}
}, confirmButton = {
if (subsRaw.supportUrl != null) {
TextButton(onClick = {
context.startActivity(
Intent(
Intent.ACTION_VIEW, Uri.parse(subsRaw.supportUrl)
)
)
}) {
Text(text = "问题反馈")
}
}
})
}
}

View File

@ -4,28 +4,42 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import li.songe.gkd.data.SubsItem
import li.songe.gkd.data.getAppInfo
import li.songe.gkd.db.DbSet
import li.songe.gkd.ui.destinations.SubsPageDestination
import li.songe.gkd.util.appInfoCacheFlow
import li.songe.gkd.util.subsIdToRawFlow
import java.text.Collator
import java.util.Locale
import javax.inject.Inject
@HiltViewModel
class SubsVm @Inject constructor(stateHandle: SavedStateHandle) : ViewModel() {
private val args = SubsPageDestination.argsFrom(stateHandle)
val subsItemFlow = MutableStateFlow<SubsItem?>(null)
val subsConfigsFlow = DbSet.subsConfigDao.queryAppTypeConfig(args.subsItemId)
val subsItemFlow = DbSet.subsItemDao.queryById(args.subsItemId)
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private val subsConfigsFlow = DbSet.subsConfigDao.queryAppTypeConfig(args.subsItemId)
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
init {
viewModelScope.launch(Dispatchers.IO) {
val subsItem = DbSet.subsItemDao.queryById(args.subsItemId)
subsItemFlow.value = subsItem
val appAndConfigsFlow = combine(
subsItemFlow,
subsConfigsFlow,
appInfoCacheFlow
) { subsItem, subsConfigs, appInfoCache ->
if (subsItem == null) return@combine emptyList()
val apps = (subsIdToRawFlow.value[subsItem.id]?.apps ?: emptyList()).sortedWith { a, b ->
Collator.getInstance(Locale.CHINESE)
.compare(appInfoCache[a.id]?.name ?: a.id, appInfoCache[b.id]?.name ?: b.id)
}
}
apps.map { app ->
val subsConfig = subsConfigs.find { s -> s.appId == app.id }
app to subsConfig
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
}

View File

@ -0,0 +1,36 @@
package li.songe.gkd.ui.component
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.OutlinedButton
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun AuthCard(title: String, desc: String, onAuthClick: () -> Unit) {
Row(
modifier = Modifier.padding(10.dp, 5.dp), verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title, fontSize = 18.sp
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = desc, fontSize = 14.sp
)
}
Spacer(modifier = Modifier.width(10.dp))
OutlinedButton(onClick = onAuthClick) {
Text(text = "授权")
}
}
}

View File

@ -9,9 +9,12 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -20,6 +23,7 @@ import li.songe.gkd.icon.ArrowIcon
@Composable
fun SettingItem(
title: String,
imageVector: ImageVector = Icons.Default.KeyboardArrowRight,
onClick: () -> Unit,
) {
Row(
@ -34,7 +38,7 @@ fun SettingItem(
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = title, fontSize = 18.sp)
Icon(imageVector = ArrowIcon, contentDescription = title)
Icon(imageVector = imageVector, contentDescription = title)
}
}

View File

@ -5,9 +5,11 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.ripple.rememberRipple
@ -24,6 +26,7 @@ import li.songe.gkd.util.SafeR
fun SimpleTopAppBar(
@DrawableRes iconId: Int = SafeR.ic_back,
onClickIcon: (() -> Unit)? = null,
actions: @Composable() (RowScope.() -> Unit) = {},
title: String,
) {
TopAppBar(backgroundColor = Color(0xfff8f9f9), navigationIcon = {
@ -32,16 +35,16 @@ fun SimpleTopAppBar(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(painter = painterResource(id = iconId),
contentDescription = null,
modifier = Modifier
.size(30.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false),
) {
onClickIcon?.invoke()
})
IconButton(onClick = {
onClickIcon?.invoke()
}) {
Icon(
painter = painterResource(id = iconId),
contentDescription = null,
modifier = Modifier.size(30.dp)
)
}
}
}, title = { Text(text = title) })
}, title = { Text(text = title) }, actions = actions
)
}

View File

@ -22,20 +22,20 @@ import androidx.compose.ui.res.painterResource
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.SubsConfig
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.data.getAppInfo
import li.songe.gkd.util.SafeR
@Composable
fun SubsAppCard(
appRaw: SubscriptionRaw.AppRaw,
appInfo: AppInfo? = null,
subsConfig: SubsConfig? = null,
onClick: (() -> Unit)? = null,
onValueChange: ((Boolean) -> Unit)? = null,
) {
val info = getAppInfo(appRaw.id)
Row(
modifier = Modifier
.height(60.dp)
@ -48,7 +48,7 @@ fun SubsAppCard(
) {
Image(
painter = if (info.icon != null) rememberDrawablePainter(info.icon) else painterResource(
painter = if (appInfo?.icon != null) rememberDrawablePainter(appInfo.icon) else painterResource(
SafeR.ic_app_2
), contentDescription = null, modifier = Modifier
.fillMaxHeight()
@ -65,7 +65,7 @@ fun SubsAppCard(
verticalArrangement = Arrangement.SpaceBetween
) {
Text(
text = info.realName,
text = appInfo?.name ?: appRaw.id,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,

View File

@ -8,9 +8,13 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Surface
import androidx.compose.material.Switch
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -20,16 +24,18 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import li.songe.gkd.data.SubsItem
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.util.SafeR
import li.songe.gkd.util.formatTimeAgo
@Composable
fun SubsItemCard(
subsItem: SubsItem,
subscriptionRaw: SubscriptionRaw?,
index: Int,
onMenuClick: (() -> Unit)? = null,
onCheckedChange: ((Boolean) -> Unit)? = null,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@ -37,24 +43,41 @@ fun SubsItemCard(
.alpha(if (subsItem.enable) 1f else .3f),
) {
Column(modifier = Modifier.weight(1f)) {
Row {
if (subscriptionRaw != null) {
Row {
Text(
text = index.toString() + ". " + (subscriptionRaw.name),
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis
)
}
Row {
Text(
text = formatTimeAgo(subsItem.mtime) + "更新",
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = "v" + (subscriptionRaw.version.toString()),
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis
)
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}规则组")
} else {
Text(text = "无规则")
}
}
} else {
Text(
text = subsItem.order.toString() + ". " + subsItem.name,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis
)
}
Row {
Text(
text = formatTimeAgo(subsItem.mtime) + "更新",
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = "v" + subsItem.version.toString(),
text = "本地无订阅文件,请刷新",
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis
@ -62,42 +85,13 @@ fun SubsItemCard(
}
}
Spacer(modifier = Modifier.width(5.dp))
Image(painter = painterResource(SafeR.ic_menu),
contentDescription = "refresh",
Icon(imageVector = Icons.Default.MoreVert,
contentDescription = "more",
modifier = Modifier
.clickable {
onMenuClick?.invoke()
}
.size(30.dp)
)
// Spacer(modifier = Modifier.width(5.dp))
// Image(painter = painterResource(SafeR.ic_refresh),
// contentDescription = "refresh",
// modifier = Modifier
// .clickable {
// onRefreshClick?.invoke()
// }
// .padding(4.dp)
// .size(20.dp))
// Spacer(modifier = Modifier.width(5.dp))
// Image(painter = painterResource(SafeR.ic_share),
// contentDescription = "share",
// modifier = Modifier
// .clickable {
// onShareClick?.invoke()
// }
// .padding(4.dp)
// .size(20.dp))
// Spacer(modifier = Modifier.width(5.dp))
// Image(painter = painterResource(SafeR.ic_del),
// contentDescription = "edit",
// modifier = Modifier
// .clickable {
// onDelClick?.invoke()
// }
// .padding(4.dp)
// .size(20.dp))
.size(30.dp))
Spacer(modifier = Modifier.width(10.dp))
Switch(
@ -113,10 +107,10 @@ fun PreviewSubscriptionItemCard() {
Surface(modifier = Modifier.width(400.dp)) {
SubsItemCard(
SubsItem(
id = 0,
order = 1,
updateUrl = "https://registry.npmmirror.com/@gkd-kit/subscription/latest/files",
name = "GKD官方订阅",
author = "gkd",
)
), subscriptionRaw = null, index = 1
)
}
}

View File

@ -1,9 +0,0 @@
package li.songe.gkd.ui.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
@Composable
fun Tabs() {
val x = rememberCoroutineScope()
}

View File

@ -3,11 +3,9 @@ package li.songe.gkd.ui.component
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.Surface
import androidx.compose.material.Switch
import androidx.compose.material.Text
import androidx.compose.runtime.Composable

View File

@ -0,0 +1,11 @@
package li.songe.gkd.ui.component
import androidx.compose.runtime.Composable
@Composable
fun UnDialog(
title: String? = null,
content: @Composable () -> Unit,
) {
}

View File

@ -5,19 +5,17 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val DarkColorPalette = darkColors()
private val darkColorPalette = darkColors()
private val LightColorPalette = lightColors(
)
private val lightColorPalette = lightColors()
@Composable
fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
val colors = if (darkTheme) {
DarkColorPalette
darkColorPalette
} else {
LightColorPalette
lightColorPalette
}
MaterialTheme(

View File

@ -0,0 +1,82 @@
package li.songe.gkd.util
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import com.blankj.utilcode.util.AppUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import li.songe.gkd.app
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
private val packageReceiver by lazy {
object : BroadcastReceiver() {
/**
* 小米应用商店更新应用产生连续3个事件: PACKAGE_REMOVED->PACKAGE_ADDED->PACKAGE_REPLACED
*
*/
override fun onReceive(context: Context?, intent: Intent?) {
val appId = intent?.data?.schemeSpecificPart ?: return
if (intent.action == Intent.ACTION_PACKAGE_ADDED || intent.action == Intent.ACTION_PACKAGE_REPLACED) {
// update
updateAppInfo(appId)
}
}
}.apply {
app.registerReceiver(this, IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REPLACED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package")
})
}
}
private fun getAppInfo(id: String): AppInfo? {
val packageManager = app.packageManager
val info = try { // 需要权限
val rawInfo = app.packageManager.getApplicationInfoExt(
id, PackageManager.GET_META_DATA
)
val info = AppUtils.getAppInfo(id) ?: return null
AppInfo(
id = id,
name = packageManager.getApplicationLabel(rawInfo).toString(),
icon = packageManager.getApplicationIcon(rawInfo),
versionCode = info.versionCode,
versionName = info.versionName
)
} catch (e: Exception) {
return null
}
return info
}
fun updateAppInfo(vararg appIds: String) {
val newMap = _appInfoCacheFlow.value.toMutableMap()
var changed = false
appIds.forEach { appId ->
val newAppInfo = getAppInfo(appId)
if (newAppInfo != null && newMap[appId] != newAppInfo) {
newMap[appId] = newAppInfo
changed = true
}
}
if (!changed) return
_appInfoCacheFlow.value = newMap
}
fun initAppState() {
packageReceiver
}

View File

@ -16,10 +16,8 @@ import android.os.Looper
import androidx.compose.runtime.*
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.suspendCancellableCoroutine
import li.songe.gkd.MainActivity
import li.songe.gkd.db.DbSet
import java.net.NetworkInterface
import kotlin.coroutines.resume
@ -118,11 +116,6 @@ object Ext {
return localAddresses
}
suspend fun getSubsFileLastModified(): Long {
return DbSet.subsItemDao.query().first().map { it.subsFile }
.filter { it.isFile && it.exists() }.maxOfOrNull { it.lastModified() } ?: -1L
}
fun createNotificationChannel(context: Service) {
// 通知渠道
val channelId = "无障碍服务"

View File

@ -3,17 +3,28 @@ package li.songe.gkd.util
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.blankj.utilcode.util.LogUtils
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanIntentResult
import com.journeyapps.barcodescanner.ScanOptions
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
data class Ref<T>(var value: T)
@Composable
fun <T> useRef(init: T): Ref<T> {
return remember {
Ref(init)
}
}
@Composable
fun useNavigateForQrcodeResult(): suspend () -> ScanIntentResult {
var resolve: ((ScanIntentResult) -> Unit)? = null
val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result ->
resolve?.invoke(result)
val contract = remember { ScanContract() }
val fc = useRef<((ScanIntentResult) -> Unit)?>(null)
val scanLauncher = rememberLauncherForActivityResult(contract) { result ->
fc.value?.invoke(result)
}
return remember {
suspend {
@ -22,9 +33,11 @@ fun useNavigateForQrcodeResult(): suspend () -> ScanIntentResult {
setBeepEnabled(false)
})
suspendCoroutine { continuation ->
resolve = { s -> continuation.resume(s) }
fc.value = { s -> continuation.resume(s) }
}
}
}
}

View File

@ -0,0 +1,15 @@
package li.songe.gkd.util
import android.content.Intent
import android.webkit.URLUtil
fun getImportUrl(intent: Intent?): String? {
val data = intent?.data
if (data?.toString()?.startsWith("gkd://import?url=") == true) {
val url = data.getQueryParameter("url")
if (URLUtil.isNetworkUrl(url)) {
return url
}
}
return null
}

View File

@ -4,3 +4,5 @@ import com.blankj.utilcode.util.ProcessUtils
val isMainProcess by lazy { ProcessUtils.isMainProcess() }
val currentProcessName by lazy { ProcessUtils.getCurrentProcessName() }

View File

@ -0,0 +1,38 @@
package li.songe.gkd.util
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.navigation.NavBackStackEntry
import com.blankj.utilcode.util.ScreenUtils
import com.ramcosta.composedestinations.spec.DestinationStyle
object ProfileTransitions : DestinationStyle.Animated {
private const val durationMillis = 400
override fun AnimatedContentTransitionScope<NavBackStackEntry>.enterTransition(): EnterTransition? {
return slideInHorizontally(
initialOffsetX = { ScreenUtils.getScreenWidth() }, animationSpec = tween(durationMillis)
)
}
override fun AnimatedContentTransitionScope<NavBackStackEntry>.exitTransition(): ExitTransition? {
return slideOutHorizontally(
targetOffsetX = { -ScreenUtils.getScreenWidth()/2 }, animationSpec = tween(durationMillis)
)
}
override fun AnimatedContentTransitionScope<NavBackStackEntry>.popEnterTransition(): EnterTransition? {
return slideInHorizontally(
initialOffsetX = { -ScreenUtils.getScreenWidth()/2 }, animationSpec = tween(durationMillis)
)
}
override fun AnimatedContentTransitionScope<NavBackStackEntry>.popExitTransition(): ExitTransition? {
return slideOutHorizontally(
targetOffsetX = { ScreenUtils.getScreenWidth() }, animationSpec = tween(durationMillis)
)
}
}

View File

@ -22,4 +22,5 @@ object SafeR {
val ic_refresh: Int = R.drawable.ic_refresh
val ic_share: Int = R.drawable.ic_share
val ic_home: Int = R.drawable.ic_home
val ic_info: Int = R.drawable.ic_info
}

View File

@ -3,13 +3,11 @@ package li.songe.gkd.util
import blue.endless.jankson.Jankson
import com.journeyapps.barcodescanner.BarcodeEncoder
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.http.ContentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import java.text.SimpleDateFormat
import java.util.Locale
/**
* 所有单例及其属性必须是不可变属性,以保持多进程下的配置统一性
@ -24,18 +22,17 @@ object Singleton {
}
}
val json5: Jankson by lazy { Jankson.builder().build() }
val client by lazy {
HttpClient(Android) {
HttpClient(OkHttp) {
install(ContentNegotiation) {
json(json, ContentType.Any)
}
engine {
connectTimeout = 10_000
socketTimeout = 10_000
clientCacheSize = 0
}
}
}
val simpleDateFormat by lazy { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
val barcodeEncoder by lazy { BarcodeEncoder() }

View File

@ -1,86 +0,0 @@
package li.songe.gkd.util
import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisallowComposableCalls
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavHostController
data class StateCache(
var list: MutableList<Any?> = mutableListOf(),
var visitCount: Int = 0
) {
fun assign(other: StateCache) {
other.list = list
other.visitCount = visitCount
}
}
val LocalStateCache = compositionLocalOf<StateCache> { error("not default value for StateCache") }
@Suppress("UNCHECKED_CAST")
@Composable
inline fun <T> rememberCache(
crossinline calculation: @DisallowComposableCalls () -> T
): T {
val cache = LocalStateCache.current
val state = remember {
val visitCount = cache.visitCount
cache.visitCount++
if (cache.list.size > visitCount) {
val value = cache.list[visitCount] as T
value
} else {
val value = calculation()
cache.list.add(value)
value
}
}
DisposableEffect(Unit) {
onDispose {
cache.visitCount = 0
}
}
return state
}
/**
* 如果不在乎进程的重建,可以使用此缓存保存任意数据
*/
@Composable
fun StackCacheProvider(navController: NavHostController, content: @Composable () -> Unit) {
val stackCaches = remember {
Array(navController.backQueue.size) { StateCache() }.toMutableList()
}
// 不使用 mutableStateOf 来避免多余的重组
val currentCache = remember {
StateCache()
}
DisposableEffect(Unit) {
val listener: (NavController, NavDestination, Bundle?) -> Unit =
{ navController: NavController, _: NavDestination, _: Bundle? ->
val realSize = navController.backQueue.size
while (realSize != stackCaches.size) {
if (stackCaches.size > realSize) {
stackCaches.removeLast()
} else if (stackCaches.size < realSize) {
stackCaches.add(StateCache())
} else {
break
}
}
stackCaches.last().assign(currentCache)
}
navController.addOnDestinationChangedListener(listener)
onDispose {
navController.removeOnDestinationChangedListener(listener)
}
}
CompositionLocalProvider(LocalStateCache provides currentCache, content = content)
}

View File

@ -5,16 +5,60 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Parcelable
import com.blankj.utilcode.util.LogUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.map
import kotlinx.parcelize.Parcelize
import li.songe.gkd.app
import li.songe.gkd.appScope
import java.util.WeakHashMap
private val onReceives by lazy {
mutableListOf<(
context: Context?,
intent: Intent?,
) -> Unit>()
}
private val receiver by lazy {
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
onReceives.forEach { fc -> fc(context, intent) }
}
}.apply {
app.registerReceiver(this, IntentFilter(app.packageName))
}
}
private val stateFlowToKey by lazy { WeakHashMap<StateFlow<*>, String>() }
private inline fun <reified T : Parcelable> createStorageFlow(
key: String,
crossinline init: () -> T,
): StateFlow<T> {
val stateFlow = MutableStateFlow(kv.decodeParcelable(key, T::class.java) ?: init())
receiver
onReceives.add { _, intent ->
val extras = intent?.extras ?: return@add
val type = extras.getString("type") ?: return@add
val itKey = extras.getString("key") ?: return@add
if (type == "update_storage" && itKey == key) {
stateFlow.value = kv.decodeParcelable(key, T::class.java) ?: init()
}
}
stateFlowToKey[stateFlow] = key
return stateFlow
}
fun <T : Parcelable> updateStorage(stateFlow: StateFlow<T>, newState: T) {
val key = stateFlowToKey[stateFlow] ?: error("not found stateFlow key")
kv.encode(key, newState)
app.sendBroadcast(Intent(app.packageName).apply {
putExtra("type", "update_storage")
putExtra("key", key)
})
}
private const val STORE_KEY = "store-v1"
private const val EVENT_KEY = "updateStore"
/**
* 属性不可删除,注释弃用即可
@ -25,40 +69,41 @@ private const val EVENT_KEY = "updateStore"
@Parcelize
data class Store(
val enableService: Boolean = true,
val excludeFromRecents: Boolean = true,
val enableConsoleLogOut: Boolean = true,
val enableCaptureScreenshot: Boolean = true,
val excludeFromRecents: Boolean = false,
val captureScreenshot: Boolean = false,
val httpServerPort: Int = 8888,
val autoUpdateSubsIntervalTimeMillis: Long = 60 * 60_000,
val autoUpdateSubs: Boolean = false,
val captureVolumeKey: Boolean = false,
val autoCheckAppUpdate: Boolean = true,
) : Parcelable
private fun getStore(): Store {
return kv.decodeParcelable(STORE_KEY, Store::class.java) ?: Store()
val storeFlow by lazy {
createStorageFlow("store") { Store() }
}
val storeFlow by lazy<StateFlow<Store>> {
val state = MutableStateFlow(getStore())
val receiver=object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
intent?.extras?.getString(EVENT_KEY) ?: return
state.value = getStore()
}
}
app.registerReceiver(receiver, IntentFilter(app.packageName))
// app.unregisterReceiver(receiver)
appScope.launch {
LogUtils.getConfig().setConsoleSwitch(state.value.enableConsoleLogOut)
state.collect {
LogUtils.getConfig().setConsoleSwitch(state.value.enableConsoleLogOut)
}
}
state
@Parcelize
data class RecordStore(
val clickCount: Int = 0,
) : Parcelable
val recordStoreFlow by lazy {
createStorageFlow("record_store") { RecordStore() }
}
fun updateStore(newStore: Store) {
if (storeFlow.value == newStore) return
kv.encode(STORE_KEY, newStore)
app.sendBroadcast(Intent(app.packageName).apply {
putExtra(EVENT_KEY, EVENT_KEY)
})
val clickCountFlow by lazy {
recordStoreFlow.map { r -> r.clickCount }
}
fun increaseClickCount(n: Int = 1) {
updateStorage(
recordStoreFlow,
recordStoreFlow.value.copy(clickCount = recordStoreFlow.value.clickCount + n)
)
}
fun initStore() {
storeFlow.value
recordStoreFlow.value
}

View File

@ -0,0 +1,205 @@
package li.songe.gkd.util
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import li.songe.gkd.appScope
import li.songe.gkd.data.DeviceInfo
import li.songe.gkd.data.Rule
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
fun SubscriptionRaw.NumberFilter?.match(value: Int?): Boolean {
value ?: return true
this ?: return true
enum?.apply {
return contains(value)
}
if (minimum != null && minimum > value) {
return false
}
if (maximum != null && maximum < value) {
return false
}
return true
}
fun SubscriptionRaw.StringFilter?.match(value: String?): Boolean {
value ?: return true
this ?: return true
enum?.apply {
return contains(value)
}
if (minLength != null && minLength > value.length) {
return false
}
if (maxLength != null && maxLength < value.length) {
return false
}
patternRegex?.apply {
return match(value)
}
return true
}
val subsItemsFlow by lazy {
DbSet.subsItemDao.query().stateIn(appScope, SharingStarted.Eagerly, emptyList())
}
private val subsIdToMtimeFlow by lazy {
DbSet.subsItemDao.query().map { it.sortedBy { s -> s.id }.associate { s -> s.id to s.mtime } }
.stateIn(appScope, SharingStarted.Eagerly, emptyMap())
}
val subsIdToRawFlow by lazy {
subsIdToMtimeFlow.map { subsIdToMtime ->
subsIdToMtime.map { entry ->
entry.key to SubsItem.getSubscriptionRaw(entry.key)
}.toMap()
}.onEach { rawMap ->
updateAppInfo(*rawMap.values.map { subsRaw ->
subsRaw?.apps?.map { a -> a.id }
}.flatMap { it ?: emptyList() }.toTypedArray())
}.stateIn(appScope, SharingStarted.Eagerly, emptyMap())
}
val subsConfigsFlow by lazy {
DbSet.subsConfigDao.query().stateIn(appScope, SharingStarted.Eagerly, emptyList())
}
val appIdToRulesFlow by lazy {
combine(
subsItemsFlow, subsIdToRawFlow, subsConfigsFlow, appInfoCacheFlow
) { subsItems, subsIdToRaw, subsConfigs, appInfoCache ->
val appSubsConfigs = subsConfigs.filter { it.type == SubsConfig.AppType }
val groupSubsConfigs = subsConfigs.filter { it.type == SubsConfig.GroupType }
val appIdToRules = mutableMapOf<String, MutableList<Rule>>()
subsItems.filter { it.enable }.forEach { subsItem ->
(subsIdToRaw[subsItem.id]?.apps ?: emptyList()).filter { appRaw ->
// 筛选当前启用的 app 订阅规则
appSubsConfigs.find { subsConfig ->
subsConfig.subsItemId == subsItem.id && subsConfig.appId == appRaw.id
}?.enable ?: true
}.forEach { appRaw ->
val rules = appIdToRules[appRaw.id] ?: mutableListOf()
appIdToRules[appRaw.id] = rules
appRaw.groups.filter { groupRaw ->
// 筛选已经启用的规则组
groupSubsConfigs.find { subsConfig ->
subsConfig.subsItemId == subsItem.id && subsConfig.appId == appRaw.id && subsConfig.groupKey == groupRaw.key
}?.enable ?: groupRaw.enable ?: true
}.filter { groupRaw ->
// 筛选合法选择器的规则组, 如果一个规则组内某个选择器语法错误, 则禁用/丢弃此规则组
groupRaw.valid
}.forEach { groupRaw ->
val ruleGroupList = mutableListOf<Rule>()
groupRaw.rules.forEachIndexed { ruleIndex, ruleRaw ->
// 根据设备信息筛选
val deviceFilter =
ruleRaw.deviceFilter ?: groupRaw.deviceFilter ?: appRaw.deviceFilter
if (deviceFilter != null) {
val deviceInfo = DeviceInfo.instance
if (deviceFilter.device?.match(deviceInfo.device) == false ||
deviceFilter.model?.match(deviceInfo.model) == false ||
deviceFilter.manufacturer?.match(deviceInfo.manufacturer) == false ||
deviceFilter.brand?.match(deviceInfo.brand) == false ||
deviceFilter.sdkInt?.match(deviceInfo.sdkInt) == false ||
deviceFilter.release?.match(deviceInfo.release) == false
) return@forEachIndexed
}
// 根据当前设备安装的 app 信息筛选
val appInfo = appInfoCache[appRaw.id]
val appFilter = ruleRaw.appFilter ?: groupRaw.appFilter ?: appRaw.appFilter
if (appFilter != null && appInfo != null) {
if (appFilter.name?.match(appInfo.name) == false ||
appFilter.versionCode?.match(appInfo.versionCode) == false ||
appFilter.versionName?.match(appInfo.versionName) == false
) return@forEachIndexed
}
val cd = Rule.defaultMiniCd.coerceAtLeast(
ruleRaw.cd ?: groupRaw.cd ?: appRaw.cd ?: Rule.defaultMiniCd
)
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()
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()
ruleGroupList.add(
Rule(
cd = cd,
index = ruleIndex,
matches = ruleRaw.matches.map { Selector.parse(it) },
excludeMatches = ruleRaw.excludeMatches.map {
Selector.parse(
it
)
},
appId = appRaw.id,
activityIds = activityIds,
excludeActivityIds = excludeActivityIds,
key = ruleRaw.key,
preKeys = ruleRaw.preKeys.toSet(),
rule = ruleRaw,
group = groupRaw,
app = appRaw,
subsItem = subsItem
)
)
}
ruleGroupList.forEachIndexed { index, ruleConfig ->
ruleGroupList[index] = ruleConfig.copy(
preRules = ruleGroupList.filter {
(it.key != null) && it.preKeys.contains(
it.key
)
}.toSet()
)
}
rules.addAll(ruleGroupList)
}
}
}
appIdToRules
}.stateIn<Map<String, List<Rule>>>(appScope, SharingStarted.Eagerly, emptyMap())
}
fun initSubsState() {
subsItemsFlow.value
subsIdToMtimeFlow.value
subsIdToRawFlow.value
subsConfigsFlow.value
}

View File

@ -0,0 +1,192 @@
package li.songe.gkd.util
import android.os.Parcelable
import android.util.Log
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.AlertDialog
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.blankj.utilcode.util.AppUtils
import com.blankj.utilcode.util.PathUtils
import io.ktor.client.call.body
import io.ktor.client.plugins.onDownload
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsChannel
import io.ktor.util.cio.writeChannel
import io.ktor.utils.io.copyAndClose
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import li.songe.gkd.BuildConfig
import li.songe.gkd.appScope
import java.io.File
import java.net.URI
@Serializable
@Parcelize
data class NewVersion(
val versionCode: Int,
val versionName: String,
val changelog: String,
val downloadUrl: String,
) : Parcelable
sealed class LoadStatus<out T> {
data class Loading(val progress: Float) : LoadStatus<Nothing>()
data class Failure(val exception: Exception) : LoadStatus<Nothing>()
data class Success<T>(val result: T) : LoadStatus<T>()
}
private const val UPDATE_URL = "https://registry.npmmirror.com/@gkd-kit/app/latest/files/index.json"
val checkUpdatingFlow by lazy { MutableStateFlow(false) }
val newVersionFlow by lazy { MutableStateFlow<NewVersion?>(null) }
val downloadStatusFlow by lazy { MutableStateFlow<LoadStatus<String>?>(null) }
suspend fun checkUpdate(): NewVersion? {
if (checkUpdatingFlow.value || newVersionFlow.value != null || downloadStatusFlow.value != null) return null
checkUpdatingFlow.value = true
try {
val newVersion = Singleton.client.get(UPDATE_URL).body<NewVersion>()
if (newVersion.versionCode > BuildConfig.VERSION_CODE) {
newVersionFlow.value = newVersion
return newVersion
} else {
Log.d("Upgrade", "no new version")
}
} finally {
checkUpdatingFlow.value = false
}
return null
}
fun startDownload(newVersion: NewVersion) {
if (downloadStatusFlow.value is LoadStatus.Loading) return
downloadStatusFlow.value = LoadStatus.Loading(0f)
val newApkFile = File(PathUtils.getExternalAppCachePath() + "/v${newVersion.versionCode}.apk")
if (newApkFile.exists()) {
newApkFile.delete()
}
appScope.launch {
try {
val channel =
Singleton.client.get(URI(UPDATE_URL).resolve(newVersion.downloadUrl).toString()) {
onDownload { bytesSentTotal, contentLength ->
downloadStatusFlow.value =
LoadStatus.Loading(bytesSentTotal.toFloat() / contentLength)
}
}.bodyAsChannel()
channel.copyAndClose(newApkFile.writeChannel())
downloadStatusFlow.value = LoadStatus.Success(newApkFile.absolutePath)
} catch (e: Exception) {
downloadStatusFlow.value = LoadStatus.Failure(e)
}
}
}
@Composable
fun UpgradeDialog() {
val newVersion by newVersionFlow.collectAsState()
newVersion?.let { newVersionVal ->
AlertDialog(title = {
Text(text = "检测到新版本")
}, text = {
Text(text = "v${BuildConfig.VERSION_NAME} -> v${newVersionVal.versionName}\n\n${newVersionVal.changelog}".trimEnd())
}, onDismissRequest = { }, confirmButton = {
TextButton(onClick = {
newVersionFlow.value = null
startDownload(newVersionVal)
}) {
Text(text = "下载更新")
}
}, dismissButton = {
TextButton(onClick = { newVersionFlow.value = null }) {
Text(text = "取消")
}
})
}
val downloadStatus by downloadStatusFlow.collectAsState()
downloadStatus?.let { downloadStatusVal ->
when (downloadStatusVal) {
is LoadStatus.Loading -> {
Dialog(onDismissRequest = { }) {
Column(modifier = Modifier.padding(10.dp)) {
Text(text = "下载新版本中,稍等片刻", fontSize = 16.sp)
Spacer(modifier = Modifier.height(10.dp))
LinearProgressIndicator(progress = downloadStatusVal.progress)
}
}
}
is LoadStatus.Failure -> {
AlertDialog(
title = { Text(text = "下载失败") },
text = { Text(text = downloadStatusVal.exception.toString()) },
onDismissRequest = { downloadStatusFlow.value = null },
confirmButton = {
TextButton(onClick = {
downloadStatusFlow.value = null
}) {
Text(text = "关闭")
}
},
)
}
is LoadStatus.Success -> {
AlertDialog(title = { Text(text = "下载完毕") },
onDismissRequest = {},
dismissButton = {
TextButton(onClick = {
downloadStatusFlow.value = null
}) {
Text(text = "关闭")
}
},
confirmButton = {
TextButton(onClick = {
AppUtils.installApp(downloadStatusVal.result)
}) {
Text(text = "安装")
}
})
}
}
}
}
fun initUpgrade() {
if (storeFlow.value.autoCheckAppUpdate) {
appScope.launch {
try {
checkUpdate()
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M8,7a0.5,0.5 0,0 1,0.5 0.5v3a0.5,0.5 0,0 1,-1 0v-3A0.5,0.5 0,0 1,8 7zM8,6.25A0.749,0.749 0,1 0,8 4.75a0.749,0.749 0,0 0,0 1.498zM2,8a6,6 0,1 1,12 0A6,6 0,0 1,2 8zM8,3a5,5 0,1 0,0 10A5,5 0,0 0,8 3z"
android:fillColor="#000"/>
</vector>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<vector
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#627bb3"
android:pathData="M65.08,84.13C64.01,84.13 63.13,83.26 63.13,82.18C63.13,81.11 64,80.24 65.08,80.24C66.15,80.24 67.02,81.11 67.02,82.18C67.02,83.26 66.15,84.13 65.08,84.13ZM43.6,84.13C42.53,84.13 41.65,83.26 41.65,82.18C41.65,81.11 42.52,80.24 43.6,80.24C44.66,80.24 45.54,81.11 45.54,82.18C45.54,83.26 44.67,84.13 43.6,84.13ZM65.77,72.44L69.66,65.73C69.88,65.35 69.74,64.85 69.36,64.63C68.97,64.41 68.48,64.54 68.25,64.93L64.32,71.73C61.31,70.36 57.94,69.59 54.33,69.59C50.73,69.59 47.35,70.36 44.34,71.73L40.41,64.93C40.19,64.54 39.69,64.41 39.31,64.63C38.92,64.85 38.79,65.35 39.01,65.73L42.89,72.44C36.22,76.07 31.67,82.81 31,90.77H77.67C77,82.8 72.44,76.06 65.77,72.44Z" />
<path
android:fillColor="#627bb3"
android:fillType="evenOdd"
android:pathData="M46.57,35H46.57C46.1,35 45.72,35.38 45.72,35.85L45.72,43.15H44.19C43.35,43.15 42.67,43.83 42.67,44.68C42.67,45.52 43.35,46.2 44.19,46.2H45.72V43.15H47.42C48.17,43.15 48.78,42.54 48.78,41.79L48.78,37.72H49.97C50.43,37.72 50.81,37.34 50.81,36.87V35.85C50.81,35.38 50.43,35 49.97,35H47.42H46.57ZM46.57,54.35H46.57H47.42H49.97C50.43,54.35 50.81,53.97 50.81,53.5V52.48C50.81,52.02 50.43,51.64 49.97,51.64H48.78L48.78,47.56C48.78,46.81 48.17,46.2 47.42,46.2H45.72L45.72,53.5C45.72,53.97 46.1,54.35 46.57,54.35ZM61.54,35H61.54C62.01,35 62.39,35.38 62.39,35.85V43.15H63.92C64.76,43.15 65.44,43.83 65.44,44.68C65.44,45.52 64.76,46.2 63.92,46.2H62.39V43.15H60.69C59.94,43.15 59.33,42.54 59.33,41.79V37.72H58.15C57.68,37.72 57.3,37.34 57.3,36.87V35.85C57.3,35.38 57.68,35 58.15,35H60.69H61.54ZM61.54,54.35H61.54H60.69H58.15C57.68,54.35 57.3,53.97 57.3,53.5V52.48C57.3,52.02 57.68,51.64 58.15,51.64H59.33V47.56C59.33,46.81 59.94,46.2 60.69,46.2H62.39V53.5C62.39,53.97 62.01,54.35 61.54,54.35Z" />
</vector>
</item>
</layer-list>

View File

@ -6,4 +6,13 @@
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
</style>
<style name="SplashScreenTheme" parent="Theme.SplashScreen">
<item name="windowSplashScreenAnimatedIcon">
@drawable/ic_launcher_circle
</item>
<item name="windowSplashScreenBackground">@android:color/white</item>
<item name="windowSplashScreenAnimationDuration">1000</item>
<!-- postSplashScreenTheme must invoke installSplashScreen -->
<item name="postSplashScreenTheme">@style/AppTheme</item>
</style>
</resources>

View File

@ -1,4 +1,4 @@
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="Theme.AppCompat">
<item name="android:statusBarColor">@android:color/transparent</item>
@ -7,4 +7,14 @@
<item name="android:windowBackground">@android:color/transparent</item>
</style>
<style name="SplashScreenTheme" parent="Theme.SplashScreen">
<item name="windowSplashScreenAnimatedIcon">
@drawable/ic_launcher_circle
</item>
<item name="windowSplashScreenBackground">@android:color/white</item>
<item name="windowSplashScreenAnimationDuration">1000</item>
<!-- postSplashScreenTheme must invoke installSplashScreen -->
<item name="postSplashScreenTheme">@style/AppTheme</item>
</style>
</resources>

View File

@ -2,10 +2,11 @@
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeAllMask"
android:accessibilityFeedbackType="feedbackAllMask"
android:accessibilityFlags="flagReportViewIds|flagIncludeNotImportantViews|flagDefault|flagRetrieveInteractiveWindows"
android:accessibilityFlags="flagReportViewIds|flagIncludeNotImportantViews|flagDefault|flagRetrieveInteractiveWindows|flagRequestFilterKeyEvents"
android:canPerformGestures="true"
android:canRequestFilterKeyEvents="true"
android:canRetrieveWindowContent="true"
android:canTakeScreenshot="true"
android:description="@string/ab_desc"
android:notificationTimeout="100"
android:canTakeScreenshot="true"
android:settingsActivity="li.songe.gkd.MainActivity" />

View File

@ -17,10 +17,20 @@ buildscript {
// https://youtrack.jetbrains.com/issue/KTIJ-19369
@Suppress(
"DSL_SCOPE_VIOLATION",
)
plugins {
) plugins {
alias(libs.plugins.google.ksp) apply false
alias(libs.plugins.google.hilt) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.kotlin.parcelize) apply false
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.kapt) apply false
alias(libs.plugins.rikka.refine) apply false
}

View File

@ -1,5 +1,5 @@
plugins {
id("com.android.library")
alias(libs.plugins.android.library)
}
android {

View File

@ -1,6 +1,6 @@
plugins {
id("org.jetbrains.kotlin.multiplatform")
id("org.jetbrains.kotlin.plugin.serialization")
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {

View File

@ -25,6 +25,7 @@ class CommonSelector private constructor(
companion object {
fun parse(source: String) = CommonSelector(Selector.parse(source))
fun check(source: String) = Selector.check(source)
}
}

View File

@ -40,5 +40,13 @@ class Selector internal constructor(private val propertyWrapper: PropertyWrapper
companion object {
fun parse(source: String) = ParserSet.selectorParser(source)
fun check(source: String): Boolean {
return try {
ParserSet.selectorParser(source)
true
} catch (e: Exception) {
false
}
}
}
}

View File

@ -26,6 +26,10 @@ data class PropertyWrapper(
if (to == null) {
return trackNodes
}
return to.matchTracks(node, transform, trackNodes)
val r = to.matchTracks(node, transform, trackNodes)
if (r == null) {
trackNodes.removeLast()
}
return r
}
}

View File

@ -70,4 +70,40 @@ class ParserTest {
fun check_parser() {
println(Selector.parse("View > Text"))
}
@Test
fun check_query(){
val projectCwd = File("../").absolutePath
val text =
"@TextView[text^='跳过'] + LinearLayout TextView[text*=`跳转`]"
val selector = Selector.parse(text)
println("selector: $selector")
println(selector.trackIndex)
println(selector.tracks.toList())
val jsonString = File("$projectCwd/_assets/snapshot-1693227637861.json").readText()
val json = Json {
ignoreUnknownKeys = true
}
val nodes = json.decodeFromString<TestSnapshot>(jsonString).nodes
nodes.forEach { node ->
node.parent = nodes.getOrNull(node.pid)
node.parent?.apply {
children.add(node)
}
}
val transform = Transform<TestNode>(getAttr = { node, name ->
if (name=="_id") return@Transform node.id
if (name=="_pid") return@Transform node.pid
val value = node.attr[name] ?: return@Transform null
if (value is JsonNull) return@Transform null
value.intOrNull ?: value.booleanOrNull ?: value.content
}, getName = { node -> node.attr["name"]?.content }, getChildren = { node ->
node.children.asSequence()
}, getParent = { node -> node.parent })
val targets = transform.querySelectorAll(nodes.first(), selector).toList()
println("target_size: " + targets.size)
println(targets.firstOrNull())
}
}

View File

@ -5,6 +5,10 @@ include(":hidden_api")
pluginManagement {
repositories {
mavenLocal()
mavenCentral()
google()
maven("https://jitpack.io")
maven("https://plugins.gradle.org/m2/")
}
}
@ -24,21 +28,29 @@ dependencyResolutionManagement {
versionCatalogs {
create("libs") {
val kotlinVersion = "1.8.20"
// use jdk17
version("jdkVersion", JavaVersion.VERSION_17.majorVersion)
version("kotlinVersion", "1.8.20")
version("kotlinVersion", kotlinVersion)
version("android.compileSdk", "33")
version("android.targetSdk", "33")
version("android.buildToolsVersion", "33.0.2")
version("android.compileSdk", "34")
version("android.targetSdk", "34")
version("android.buildToolsVersion", "34.0.0")
version("android.minSdk", "26")
library("android.gradle", "com.android.tools.build:gradle:8.1.0")
plugin("android.library", "com.android.library").version("8.1.0")
plugin("android.application", "com.android.application").version("8.1.0")
// 当前 android 项目 kotlin 的版本
library("kotlin.gradle.plugin", "org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.20")
library("kotlin.serialization", "org.jetbrains.kotlin:kotlin-serialization:1.8.20")
library("kotlin.stdlib.common", "org.jetbrains.kotlin:kotlin-stdlib-common:1.8.20")
library("kotlin.gradle.plugin", "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
library("kotlin.serialization", "org.jetbrains.kotlin:kotlin-serialization:$kotlinVersion")
library("kotlin.stdlib.common", "org.jetbrains.kotlin:kotlin-stdlib-common:$kotlinVersion")
plugin("kotlin.serialization", "org.jetbrains.kotlin.plugin.serialization").version(kotlinVersion)
plugin("kotlin.parcelize", "org.jetbrains.kotlin.plugin.parcelize").version(kotlinVersion)
plugin("kotlin.kapt", "org.jetbrains.kotlin.kapt").version(kotlinVersion)
plugin("kotlin.multiplatform", "org.jetbrains.kotlin.multiplatform").version(kotlinVersion)
plugin("kotlin.android", "org.jetbrains.kotlin.android").version(kotlinVersion)
// compose 编译器的版本, 需要注意它与 compose 的版本没有关联
// https://mvnrepository.com/artifact/androidx.compose.compiler/compiler
@ -74,14 +86,15 @@ dependencyResolutionManagement {
library("others.utilcodex", "com.blankj:utilcodex:1.31.0")
// https://dylancaicoding.github.io/ActivityResultLauncher/#/
library(
"others.ActivityResultLauncher",
"others.activityResultLauncher",
"com.github.DylanCaiCoding:ActivityResultLauncher:1.1.2"
)
// https://github.com/falkreon/Jankson
library("others.jankson", "blue.endless:jankson:1.2.1")
// https://github.com/journeyapps/zxing-android-embedded
library("others.zxing.android.embedded", "com.journeyapps:zxing-android-embedded:4.3.0")
library("others.floating.bubble.view", "io.github.torrydo:floating-bubble-view:0.5.2")
// https://github.com/TorryDo/Floating-Bubble-View
library("others.floating.bubble.view", "io.github.torrydo:floating-bubble-view:0.5.6")
library("androidx.appcompat", "androidx.appcompat:appcompat:1.6.1")
library("androidx.core.ktx", "androidx.core:core-ktx:1.10.0")
@ -123,9 +136,8 @@ dependencyResolutionManagement {
"ktor.server.content.negotiation", "io.ktor:ktor-server-content-negotiation:2.3.1"
)
library("ktor.client.core", "io.ktor:ktor-client-core:2.3.1")
// library("ktor.client.okhttp", "io.ktor:ktor-client-okhttp:2.3.1")
library("ktor.client.okhttp", "io.ktor:ktor-client-okhttp:2.3.1")
// https://ktor.io/docs/http-client-engines.html#android android 平台使用 android 或者 okhttp 都行
library("ktor.client.android", "io.ktor:ktor-client-android:2.3.1")
library(
"ktor.client.content.negotiation", "io.ktor:ktor-client-content-negotiation:2.3.1"
)
@ -152,18 +164,22 @@ dependencyResolutionManagement {
plugin("google.hilt", "com.google.dagger.hilt.android").version("2.44")
library("google.hilt.android", "com.google.dagger:hilt-android:2.44")
library("google.hilt.android.compiler", "com.google.dagger:hilt-android-compiler:2.44")
library("androidx.hilt.navigation.compose", "androidx.hilt:hilt-navigation-compose:1.0.0")
library(
"androidx.hilt.navigation.compose", "androidx.hilt:hilt-navigation-compose:1.0.0"
)
// https://composedestinations.rafaelcosta.xyz/setup
library(
"destinations.core",
"io.github.raamcosta.compose-destinations:core:1.8.42-beta"
)
library("destinations.ksp", "io.github.raamcosta.compose-destinations:ksp:1.8.42-beta")
library(
"destinations.animations",
"io.github.raamcosta.compose-destinations:animations-core:1.8.42-beta"
"destinations.core", "io.github.raamcosta.compose-destinations:core:1.9.52"
)
library("destinations.ksp", "io.github.raamcosta.compose-destinations:ksp:1.9.52")
// library(
// "destinations.animations",
// "io.github.raamcosta.compose-destinations:animations-core:1.9.52"
// )
// https://github.com/aclassen/ComposeReorderable
library("others.reorderable", "org.burnoutcrew.composereorderable:reorderable:0.9.6")
}
}
}