mirror of
https://github.com/gkd-kit/gkd.git
synced 2024-11-15 19:22:26 +08:00
feat: 基本可用
This commit is contained in:
parent
b087a250aa
commit
bff13cbac0
28
README.md
28
README.md
|
@ -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/>
|
||||
-
|
||||
- 点击跳过任意开屏广告/点击关闭应用内部任意弹窗广告, 如关闭百度贴吧帖子广告卡片/知乎回答底部推荐广告卡片
|
||||
- 一些快捷操作, 如微信电脑登录自动同意/微信扫描登录自动同意/微信抢红包
|
||||
|
|
1
_assets/snapshot-1693227637861.json
Normal file
1
_assets/snapshot-1693227637861.json
Normal file
File diff suppressed because one or more lines are too long
|
@ -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)
|
||||
}
|
|
@ -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" />
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
package li.songe.gkd.composition
|
||||
|
||||
import android.view.KeyEvent
|
||||
|
||||
interface CanOnKeyEvent {
|
||||
fun onKeyEvent(f: (KeyEvent?) -> Unit): Unit
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
|
@ -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,
|
||||
|
|
|
@ -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?>
|
||||
}
|
||||
}
|
7
app/src/main/java/li/songe/gkd/data/FileStatus.kt
Normal file
7
app/src/main/java/li/songe/gkd/data/FileStatus.kt
Normal 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>()
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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>>
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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?>
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
13
app/src/main/java/li/songe/gkd/db/ClickLogDb.kt
Normal file
13
app/src/main/java/li/songe/gkd/db/ClickLogDb.kt
Normal 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
|
||||
}
|
|
@ -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("创建数据库时获取订阅失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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 })
|
||||
|
||||
|
||||
|
||||
|
|
40
app/src/main/java/li/songe/gkd/service/AbState.kt
Normal file
40
app/src/main/java/li/songe/gkd/service/AbState.kt
Normal 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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 = "关于")
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
}
|
|
@ -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()
|
27
app/src/main/java/li/songe/gkd/ui/ClickLogVm.kt
Normal file
27
app/src/main/java/li/songe/gkd/ui/ClickLogVm.kt
Normal 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 }
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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, "")
|
||||
|
||||
}
|
245
app/src/main/java/li/songe/gkd/ui/DebugPage.kt
Normal file
245
app/src/main/java/li/songe/gkd/ui/DebugPage.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
|
|
13
app/src/main/java/li/songe/gkd/ui/HomePageVm.kt
Normal file
13
app/src/main/java/li/songe/gkd/ui/HomePageVm.kt
Normal 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)
|
||||
}
|
|
@ -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?,
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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 ?: "")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = "添加")
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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 = "问题反馈")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
|
||||
|
||||
}
|
36
app/src/main/java/li/songe/gkd/ui/component/AuthCard.kt
Normal file
36
app/src/main/java/li/songe/gkd/ui/component/AuthCard.kt
Normal 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 = "授权")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
|
|
11
app/src/main/java/li/songe/gkd/ui/component/UnDialog.kt
Normal file
11
app/src/main/java/li/songe/gkd/ui/component/UnDialog.kt
Normal 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,
|
||||
) {
|
||||
|
||||
}
|
|
@ -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(
|
||||
|
|
82
app/src/main/java/li/songe/gkd/util/AppInfoState.kt
Normal file
82
app/src/main/java/li/songe/gkd/util/AppInfoState.kt
Normal 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
|
||||
}
|
|
@ -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 = "无障碍服务"
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
15
app/src/main/java/li/songe/gkd/util/Import.kt
Normal file
15
app/src/main/java/li/songe/gkd/util/Import.kt
Normal 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
|
||||
}
|
|
@ -4,3 +4,5 @@ import com.blankj.utilcode.util.ProcessUtils
|
|||
|
||||
|
||||
val isMainProcess by lazy { ProcessUtils.isMainProcess() }
|
||||
|
||||
val currentProcessName by lazy { ProcessUtils.getCurrentProcessName() }
|
||||
|
|
38
app/src/main/java/li/songe/gkd/util/ProfileTransitions.kt
Normal file
38
app/src/main/java/li/songe/gkd/util/ProfileTransitions.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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() }
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
205
app/src/main/java/li/songe/gkd/util/SubsState.kt
Normal file
205
app/src/main/java/li/songe/gkd/util/SubsState.kt
Normal 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
|
||||
}
|
192
app/src/main/java/li/songe/gkd/util/Upgrade.kt
Normal file
192
app/src/main/java/li/songe/gkd/util/Upgrade.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
9
app/src/main/res/drawable/ic_info.xml
Normal file
9
app/src/main/res/drawable/ic_info.xml
Normal 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>
|
21
app/src/main/res/drawable/ic_launcher_circle.xml
Normal file
21
app/src/main/res/drawable/ic_launcher_circle.xml
Normal 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>
|
||||
|
|
@ -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>
|
|
@ -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>
|
|
@ -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" />
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
plugins {
|
||||
id("com.android.library")
|
||||
alias(libs.plugins.android.library)
|
||||
}
|
||||
|
||||
android {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user