feat: 提供调试功能

This commit is contained in:
lisonge 2023-09-02 18:09:08 +08:00
parent 8f2c30584a
commit 43b0166a67
23 changed files with 320 additions and 119 deletions

View File

@ -1,10 +1,22 @@
# gkd
基于 *无障碍* + *高级选择器* + *订阅规则* 的自定义屏幕点击 APP
基于 **无障碍** + **高级选择器** + **订阅规则** 的自定义屏幕点击 APP
## feature
## 功能
根据 [高级选择器](https://github.com/gkd-kit/selector) + [订阅规则](https://github.com/gkd-kit/subscription), 它可以实现
根据 [高级选择器](https://github.com/gkd-kit/selector) + [订阅规则](https://github.com/gkd-kit/subscription),
它可以实现
- 点击跳过任意开屏广告/点击关闭应用内部任意弹窗广告, 如关闭百度贴吧帖子广告卡片/知乎回答底部推荐广告卡片
- 一些快捷操作, 如微信电脑登录自动同意/微信扫描登录自动同意/微信抢红包
| | | | |
| --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| ![image](https://github.com/gkd-kit/gkd/assets/38517192/ad1d9ec6-2694-4072-b56b-f031baaad89e) | ![image](https://github.com/gkd-kit/gkd/assets/38517192/cbc5815a-f07a-4aa7-8805-d0d17e6b5818) | ![image](https://github.com/gkd-kit/gkd/assets/38517192/cacd9bbf-5bc4-4345-84d8-97731c911cbd) | ![image](https://github.com/gkd-kit/gkd/assets/38517192/1b598dcb-1ad9-4ae5-9a6a-3a1b11492bfa) |
| ![image](https://github.com/gkd-kit/gkd/assets/38517192/8fa19f46-6757-4025-83f7-9eaee6ead52a) | ![image](https://github.com/gkd-kit/gkd/assets/38517192/ba30ef9a-83ae-4be2-8802-e150aacaf140) | ![image](https://github.com/gkd-kit/gkd/assets/38517192/e324eba1-af45-490b-bffe-d42585e11d17) | ![image](https://github.com/gkd-kit/gkd/assets/38517192/ca3323c2-436d-4571-bc5d-e72e5b7fcf6f) |
## 效果
| | | |
| ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| ![image](https://github.com/gkd-kit/gkd/assets/38517192/ec0e9465-13d2-422b-97e2-644357ea564b) 关闭微信朋友圈广告 | ![image](https://github.com/gkd-kit/gkd/assets/38517192/cd4554f3-dd9f-431b-8e6d-6cfe7ac430ec) 关闭酷安字节 SDK 广告 | ![image](https://github.com/gkd-kit/gkd/assets/38517192/576a7a6d-5196-4184-8b24-980434dfb15a) 关闭贴吧开屏广告及内部广告 |

View File

@ -149,7 +149,6 @@ dependencies {
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.netty)
implementation(libs.ktor.server.cors)
implementation(libs.ktor.server.content.negotiation)
implementation(libs.ktor.client.core)

View File

@ -9,6 +9,7 @@ import com.tencent.bugly.crashreport.CrashReport
import com.tencent.mmkv.MMKV
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.MainScope
import li.songe.gkd.debug.clearHttpSubs
import li.songe.gkd.notif.initChannel
import li.songe.gkd.util.initAppState
import li.songe.gkd.util.initStore
@ -52,6 +53,7 @@ class App : Application() {
initAppState()
initSubsState()
initUpgrade()
clearHttpSubs()
}
}
}

View File

@ -7,6 +7,7 @@ import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Update
@ -31,8 +32,7 @@ data class SubsItem(
@ColumnInfo(name = "enable") val enable: Boolean = true,
@ColumnInfo(name = "enable_update") val enableUpdate: Boolean = true,
@ColumnInfo(name = "order") val order: Int,
@ColumnInfo(name = "update_url") val updateUrl: String,
@ColumnInfo(name = "update_url") val updateUrl: String? = null,
) : Parcelable {
@ -55,10 +55,10 @@ data class SubsItem(
suspend fun removeAssets() {
DbSet.subsItemDao.delete(this)
DbSet.subsConfigDao.deleteSubs(id)
withContext(IO) {
subsFile.exists() && subsFile.delete()
}
DbSet.subsConfigDao.deleteSubs(id)
}
companion object {
@ -83,7 +83,7 @@ data class SubsItem(
@Update
suspend fun update(vararg objects: SubsItem): Int
@Insert
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg users: SubsItem): List<Long>
@Delete

View File

@ -1,6 +1,7 @@
package li.songe.gkd.data
import android.os.Parcelable
import blue.endless.jankson.Jankson
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
@ -19,7 +20,7 @@ data class SubscriptionRaw(
@SerialName("version") val version: Int,
@SerialName("author") val author: String? = null,
@SerialName("updateUrl") val updateUrl: String? = null,
@SerialName("supportUrl") val supportUrl: String? = null,
@SerialName("supportUri") val supportUri: String? = null,
@SerialName("apps") val apps: List<AppRaw> = emptyList(),
) : Parcelable {
@ -200,6 +201,7 @@ data class SubscriptionRaw(
else -> error("Element $p is not a int")
}
@Suppress("SameParameterValue")
private fun getBoolean(json: JsonObject? = null, key: String): Boolean? =
when (val p = json?.get(key)) {
JsonNull, null -> null
@ -251,7 +253,7 @@ data class SubscriptionRaw(
cd = getLong(groupsJson, "cd"),
name = getString(groupsJson, "name"),
desc = getString(groupsJson, "desc"),
enable = getBoolean(groupsJson, "boolean"),
enable = getBoolean(groupsJson, "enable"),
key = getInt(groupsJson, "key") ?: groupIndex,
rules = when (val rulesJson = groupsJson["rules"]) {
null, JsonNull -> emptyList()
@ -297,7 +299,7 @@ data class SubscriptionRaw(
version = getInt(rootJson, "version") ?: error("miss subscription.version"),
author = getString(rootJson, "author"),
updateUrl = getString(rootJson, "updateUrl"),
supportUrl = getString(rootJson, "supportUrl"),
supportUri = getString(rootJson, "supportUrl"),
apps = rootJson["apps"]?.jsonArray?.mapIndexed { index, jsonElement ->
jsonToAppRaw(
jsonElement.jsonObject, index
@ -334,7 +336,7 @@ data class SubscriptionRaw(
fun parse5(source: String): SubscriptionRaw {
return parse(
Singleton.json5.load(source).toJson()
Jankson.builder().build().load(source).toJson()
)
}
}

View File

@ -52,6 +52,7 @@ object DbSet {
)
db.insert("subs_item", SQLiteDatabase.CONFLICT_IGNORE, subsItem.toContentValues())
appScope.launch(Dispatchers.IO) {
if (subsItem.updateUrl == null) return@launch
try {
val s = System.currentTimeMillis()
val newSubsRaw = withTimeout(3000) {

View File

@ -10,9 +10,10 @@ import io.ktor.server.application.call
import io.ktor.server.application.install
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.netty.NettyApplicationEngine
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.request.receiveText
import io.ktor.server.response.cacheControl
import io.ktor.server.response.respond
import io.ktor.server.response.respondFile
@ -23,27 +24,47 @@ 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.flow.map
import kotlinx.serialization.Serializable
import li.songe.gkd.app
import li.songe.gkd.appScope
import li.songe.gkd.composition.CompositionService
import li.songe.gkd.data.DeviceInfo
import li.songe.gkd.data.RpcError
import li.songe.gkd.data.SubsItem
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.db.DbSet
import li.songe.gkd.debug.SnapshotExt.captureSnapshot
import li.songe.gkd.notif.createNotif
import li.songe.gkd.notif.httpChannel
import li.songe.gkd.notif.httpNotif
import li.songe.gkd.service.GkdAbService
import li.songe.gkd.util.Ext.getIpAddressInLocalNetwork
import li.songe.gkd.util.launchTry
import li.songe.gkd.util.storeFlow
import li.songe.gkd.util.subsItemsFlow
import java.io.File
class HttpService : CompositionService({
val context = this
val scope = CoroutineScope(Dispatchers.IO)
subsFlow.value = null
val server =
embeddedServer(Netty, storeFlow.value.httpServerPort, configure = { tcpKeepAlive = true }) {
install(CORS) { anyHost() }
install(RpcErrorHeaderPlugin)
val httpSubsItem = SubsItem(
id = -1L,
order = -1,
enableUpdate = false,
)
val httpSubsRawFlow = MutableStateFlow<SubscriptionRaw?>(null)
fun createServer(port: Int): NettyApplicationEngine {
return embeddedServer(Netty, port, configure = { tcpKeepAlive = true }) {
install(KtorCorsPlugin)
install(KtorErrorPlugin)
install(ContentNegotiation) { json() }
routing {
@ -77,40 +98,60 @@ class HttpService : CompositionService({
call.respond(DbSet.snapshotDao.query().first())
}
get("/subsApps") {
call.respond(subsFlow.value?.apps ?: emptyList())
call.respond(httpSubsRawFlow.value?.apps ?: emptyList())
}
post("/updateSubsApps") {
val subsStr =
"""{"name":"GKD-内存订阅","id":-1,"version":0,"author":"@gkd-kit/inspect","apps":${call.receive<String>()}}"""
"""{"name":"GKD-内存订阅","id":-1,"version":0,"author":"@gkd-kit/inspect","apps":${call.receiveText()}}"""
try {
subsFlow.value = SubscriptionRaw.parse(subsStr)
val httpSubsRaw = SubscriptionRaw.parse(subsStr)
httpSubsItem.subsFile.writeText(SubscriptionRaw.stringify(httpSubsRaw))
DbSet.subsItemDao.insert((subsItemsFlow.value.find { s -> s.id == httpSubsItem.id }
?: httpSubsItem).copy(mtime = System.currentTimeMillis()))
} catch (e: Exception) {
throw RpcError(e.message ?: "未知")
}
call.respond("")
call.respond(RpcOk())
}
post("/execSelector") {
if (!GkdAbService.isRunning()) {
throw RpcError("无障碍没有运行")
}
val text = call.receive<Value<String>>().value
call.respond(RpcOk(GkdAbService.click(text)))
}
}
}
}
}
var server: NettyApplicationEngine? = null
scope.launchTry(Dispatchers.IO) {
LogUtils.d(*getIpAddressInLocalNetwork().map { host -> "http://${host}:${storeFlow.value.httpServerPort}" }
storeFlow.map { s -> s.httpServerPort }.collect { port ->
server?.stop()
server = createServer(port).apply { start() }
createNotif(
context, httpChannel.id, httpNotif.copy(text = "HTTP服务正在运行-端口$port")
)
LogUtils.d(*getIpAddressInLocalNetwork().map { host -> "http://${host}:${port}" }
.toList().toTypedArray())
server.start(true)
}
}
onDestroy {
subsFlow.value = null
httpSubsRawFlow.value = null
scope.launchTry(Dispatchers.IO) {
server.stop()
LogUtils.d("http server is stopped")
server?.stop()
httpSubsItem.removeAssets()
delay(3000)
scope.cancel()
}
}
}) {
companion object {
val subsFlow by lazy { MutableStateFlow<SubscriptionRaw?>(null) }
fun isRunning() = ServiceUtils.isServiceRunning(HttpService::class.java)
fun stop(context: Context = app) {
if (isRunning()) {
@ -119,8 +160,29 @@ class HttpService : CompositionService({
}
fun start(context: Context = app) {
context.startService(Intent(context, HttpService::class.java))
context.startForegroundService(Intent(context, HttpService::class.java))
}
}
}
@Serializable
data class RpcOk(
val message: String? = null,
)
@Serializable
data class Value<T>(val value: T)
fun clearHttpSubs() {
// 如果 app 被直接在任务列表划掉, HTTP订阅会没有清除, 所以在后续的第一次启动时清除
if (HttpService.isRunning()) return
appScope.launchTry(Dispatchers.IO) {
delay(1000)
SubsItem(
id = -1L,
order = -1,
enableUpdate = false,
).removeAssets()
}
}

View File

@ -0,0 +1,24 @@
package li.songe.gkd.debug
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.server.application.createApplicationPlugin
import io.ktor.server.request.httpMethod
import io.ktor.server.response.header
import io.ktor.server.response.respond
// allow all cors
val KtorCorsPlugin = createApplicationPlugin(name = "KtorCorsPlugin") {
onCallRespond { call, _ ->
call.response.header(HttpHeaders.AccessControlAllowOrigin, "*")
call.response.header(HttpHeaders.AccessControlAllowMethods, "*")
call.response.header(HttpHeaders.AccessControlAllowHeaders, "*")
call.response.header(HttpHeaders.AccessControlExposeHeaders, "*")
call.response.header("Access-Control-Allow-Private-Network", "true")
}
onCall { call ->
if (call.request.httpMethod == HttpMethod.Options) {
call.respond("all-cors-ok")
}
}
}

View File

@ -10,7 +10,7 @@ import io.ktor.server.response.header
import io.ktor.server.response.respond
import li.songe.gkd.data.RpcError
val RpcErrorHeaderPlugin = createApplicationPlugin(name = "RpcErrorHeaderPlugin") {
val KtorErrorPlugin = createApplicationPlugin(name = "KtorErrorPlugin") {
onCall { call ->
Log.d("Ktor", "onCall: ${call.request.uri}")
}
@ -34,8 +34,4 @@ val RpcErrorHeaderPlugin = createApplicationPlugin(name = "RpcErrorHeaderPlugin"
}
}
}
onCallRespond { call, _ ->
call.response.header("Access-Control-Expose-Headers", "*")
call.response.header("Access-Control-Allow-Private-Network", "true")
}
}

View File

@ -43,3 +43,13 @@ val floatingNotif by lazy {
autoCancel = false
)
}
val httpNotif by lazy {
Notif(
id = 103,
icon = SafeR.ic_launcher,
title = "搞快点",
text = "HTTP服务正在运行",
ongoing = true,
autoCancel = false
)
}

View File

@ -24,9 +24,15 @@ val screenshotChannel by lazy {
id = "screenshot", name = "截屏服务", desc = "用于捕获屏幕截屏生成快照"
)
}
val httpChannel by lazy {
NotifChannel(
id = "http", name = "HTTP服务", desc = "用于连接Web端工具调试"
)
}
fun initChannel() {
createChannel(app, defaultChannel)
createChannel(app, floatingChannel)
createChannel(app, screenshotChannel)
createChannel(app, httpChannel)
}

View File

@ -17,13 +17,15 @@ import io.ktor.client.statement.bodyAsText
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
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.SubscriptionRaw
import li.songe.gkd.data.ClickLog
import li.songe.gkd.data.NodeInfo
import li.songe.gkd.data.RpcError
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.db.DbSet
import li.songe.gkd.debug.SnapshotExt
import li.songe.gkd.shizuku.activityTaskManager
@ -36,6 +38,7 @@ 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 li.songe.selector.Selector
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@ -153,15 +156,33 @@ class GkdAbService : CompositionAbService({
}
}
scope.launchTry {
storeFlow.map { s -> s.updateSubsInterval }.collect { updateSubsInterval ->
if (updateSubsInterval <= 0) {
// clear
} else {
// new task
updateSubsInterval.coerceAtLeast(60 * 60_000)
}
}
}
var lastUpdateSubsTime = System.currentTimeMillis()
scope.launchWhile(IO) { // 自动从网络更新订阅文件
delay(storeFlow.value.autoUpdateSubsIntervalTimeMillis.coerceAtLeast(30 * 60_000))
if (!NetworkUtils.isAvailable()) return@launchWhile
if (!storeFlow.value.autoUpdateSubs) return@launchWhile
delay(60_000)
if (!NetworkUtils.isAvailable() || storeFlow.value.updateSubsInterval <= 0) return@launchWhile
if (System.currentTimeMillis() - lastUpdateSubsTime < storeFlow.value.updateSubsInterval.coerceAtLeast(
60 * 60_000
)
) {
return@launchWhile
}
subsItemsFlow.value.forEach { subsItem ->
if (subsItem.updateUrl == null) return@forEach
try {
val text = Singleton.client.get(subsItem.updateUrl).bodyAsText()
val newSubsRaw = SubscriptionRaw.parse5(text)
val newSubsRaw =
SubscriptionRaw.parse5(Singleton.client.get(subsItem.updateUrl).bodyAsText())
if (newSubsRaw.id != subsItem.id) {
return@forEach
}
@ -184,6 +205,7 @@ class GkdAbService : CompositionAbService({
e.printStackTrace()
}
}
lastUpdateSubsTime = System.currentTimeMillis()
}
scope.launch {
@ -205,6 +227,16 @@ class GkdAbService : CompositionAbService({
private var service: GkdAbService? = null
fun isRunning() = ServiceUtils.isServiceRunning(GkdAbService::class.java)
fun click(source: String): String? {
val serviceVal = service ?: throw RpcError("无障碍没有运行")
val selector = try {
Selector.parse(source)
} catch (e: Exception) {
throw RpcError("非法选择器")
}
return currentAbNode?.querySelector(selector)?.click(serviceVal)
}
val currentAbNode: AccessibilityNodeInfo?
get() {

View File

@ -14,6 +14,7 @@ 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.material.AlertDialog
import androidx.compose.material.Scaffold
import androidx.compose.material.Switch
import androidx.compose.material.Text
@ -149,12 +150,11 @@ fun AppItemPage(
showGroupItem?.let { showGroupItemVal ->
Dialog(onDismissRequest = { showGroupItem = null }) {
Text(
text = Singleton.json.encodeToString(showGroupItemVal),
modifier = Modifier.width(400.dp)
)
}
AlertDialog(onDismissRequest = { showGroupItem = null }, title = {
Text(text = showGroupItemVal.name ?: "-")
}, text = {
Text(text = showGroupItemVal.desc ?: "-")
}, buttons = { })
}
}

View File

@ -46,7 +46,8 @@ fun ControlPage() {
val context = LocalContext.current as MainActivity
val navController = LocalNavController.current
val vm = hiltViewModel<ControlVm>()
val latestRecordGroup by vm.latestRecordGroup.collectAsState()
val latestRecordDesc by vm.latestRecordDescFlow.collectAsState()
val subsStatus by vm.subsStatusFlow.collectAsState()
val store by storeFlow.collectAsState()
@ -106,8 +107,10 @@ fun ControlPage() {
text = subsStatus, fontSize = 18.sp
)
Spacer(modifier = Modifier.height(2.dp))
Text(text = latestRecordGroup?.name?.let { "最近点击: $it" } ?: "暂无记录",
fontSize = 14.sp)
Text(
text = if (latestRecordDesc != null) "最近点击: $latestRecordDesc" else "暂无记录",
fontSize = 14.sp
)
}
Icon(imageVector = Icons.Default.KeyboardArrowRight, contentDescription = null)
}

View File

@ -8,8 +8,8 @@ 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.appInfoCacheFlow
import li.songe.gkd.util.clickCountFlow
import li.songe.gkd.util.storeFlow
import li.songe.gkd.util.subsIdToRawFlow
import javax.inject.Inject
@ -18,9 +18,23 @@ class ControlVm @Inject constructor() : ViewModel() {
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 }
val latestRecordDescFlow = combine(
latestRecordFlow, subsIdToRawFlow, appInfoCacheFlow
) { latestRecord, subsIdToRaw, appInfoCache ->
if (latestRecord == null) return@combine null
val groupName =
subsIdToRaw[latestRecord?.subsId]?.apps?.find { a -> a.id == latestRecord?.appId }?.groups?.find { g -> g.key == latestRecord?.groupKey }?.name
val appName = appInfoCache[latestRecord?.appId]?.name
val appShowName = appName ?: latestRecord?.appId ?: ""
if (groupName != null) {
if (groupName.contains(appShowName)) {
groupName
} else {
"$appShowName-$groupName"
}
} else {
appShowName
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
val subsStatusFlow = combine(appIdToRulesFlow, clickCountFlow) { appIdToRules, clickCount ->

View File

@ -143,7 +143,7 @@ fun DebugPage() {
// Build.VERSION.SDK_INT < Build.VERSION_CODES.R
val screenshotRunning by usePollState { ScreenshotService.isRunning() }
TextSwitch(name = "截屏服务",
desc = "生成快照需要获取屏幕截图",
desc = "生成快照需要获取屏幕截图,Android11无需开启",
screenshotRunning,
appScope.launchAsFn<Boolean> {
if (it) {

View File

@ -1,74 +1,53 @@
package li.songe.gkd.ui
import android.app.Activity
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.compose.foundation.layout.Arrangement
import androidx.compose.foundation.clickable
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.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.selection.selectable
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.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.RadioButton
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
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.material.icons.filled.KeyboardArrowRight
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.updateStorage
import li.songe.gkd.util.usePollState
import rikka.shizuku.Shizuku
val settingsNav = BottomNavItem(
label = "设置", icon = SafeR.ic_cog, route = "settings"
@ -77,12 +56,10 @@ val settingsNav = BottomNavItem(
@Composable
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 {
var showSubsIntervalDlg by remember {
mutableStateOf(false)
}
@ -114,17 +91,26 @@ fun SettingsPage() {
})
Divider()
Spacer(modifier = Modifier.height(5.dp))
TextSwitch(name = "自动更新订阅",
desc = "每隔一段时间自动更新订阅规则文件",
checked = store.autoUpdateSubs,
onCheckedChange = {
updateStorage(
storeFlow, store.copy(
autoUpdateSubs = it
Row(modifier = Modifier
.clickable {
showSubsIntervalDlg = true
}
.padding(10.dp, 15.dp), verticalAlignment = Alignment.CenterVertically) {
Text(
modifier = Modifier.weight(1f), text = "自动更新订阅", fontSize = 18.sp
)
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = radioOptions.find { it.second == store.updateSubsInterval }?.first
?: store.updateSubsInterval.toString(), fontSize = 14.sp
)
})
Icon(
imageVector = Icons.Default.KeyboardArrowRight, contentDescription = "more"
)
}
}
Divider()
TextSwitch(name = "自动更新应用",
@ -151,7 +137,7 @@ fun SettingsPage() {
SettingItem(title = "问题反馈", onClick = {
context.startActivity(
Intent(
Intent.ACTION_VIEW, Uri.parse("https://github.com/gkd-kit/subscription")
Intent.ACTION_VIEW, Uri.parse("https://github.com/gkd-kit/gkd")
)
)
})
@ -169,4 +155,52 @@ fun SettingsPage() {
}
})
if (showSubsIntervalDlg) {
Dialog(onDismissRequest = { showSubsIntervalDlg = false }) {
Column(
modifier = Modifier
) {
radioOptions.forEach { option ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.selectable(selected = (option.second == store.updateSubsInterval),
onClick = {
updateStorage(
storeFlow,
storeFlow.value.copy(updateSubsInterval = option.second)
)
})
.padding(horizontal = 16.dp)
) {
RadioButton(
selected = (option.second == store.updateSubsInterval),
onClick = {
updateStorage(
storeFlow,
storeFlow.value.copy(updateSubsInterval = option.second)
)
})
Text(
text = option.first,
style = MaterialTheme.typography.body1.merge(),
modifier = Modifier.padding(start = 16.dp)
)
}
}
}
}
}
}
val radioOptions = listOf(
"暂停" to -1L,
"每小时" to 60 * 60_000L,
"每12小时" to 12 * 60 * 60_000L,
"每天" to 24 * 60 * 60_000L
)
//data class UpdateInterval(
// val timeMillis: Long,
// val desc: String,
//)

View File

@ -273,6 +273,7 @@ fun SubsManagePage() {
.background(Color.White)
.padding(8.dp)
) {
if (menuSubItemVal.updateUrl != null) {
Text(text = "分享", modifier = Modifier
.clickable {
shareSubItem = menuSubItemVal
@ -280,6 +281,7 @@ fun SubsManagePage() {
}
.fillMaxWidth()
.padding(8.dp))
}
Text(text = "删除", modifier = Modifier
.clickable {
@ -324,7 +326,7 @@ fun SubsManagePage() {
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (subItems.all { it.id != 0L }) {
Text(text = "默认订阅", modifier = Modifier
Text(text = "导入默认订阅", modifier = Modifier
.clickable {
showAddDialog = false
vm.addSubsFromUrl("https://registry.npmmirror.com/@gkd-kit/subscription/latest/files")
@ -334,7 +336,7 @@ fun SubsManagePage() {
}
Text(
text = "二维码", modifier = Modifier
text = "扫描二维码导入", modifier = Modifier
.clickable(onClick = scope.launchAsFn {
showAddDialog = false
val qrCode = navigateForQrcodeResult()
@ -347,7 +349,7 @@ fun SubsManagePage() {
.fillMaxWidth()
.padding(8.dp)
)
Text(text = "链接", modifier = Modifier
Text(text = "输入链接导入", modifier = Modifier
.clickable {
showAddDialog = false
showAddLinkDialog = true

View File

@ -84,6 +84,7 @@ class SubsManageVm @Inject constructor() : ViewModel() {
var errorNum = 0
val oldSubItems = subsItemsFlow.value
val newSubsItems = oldSubItems.mapNotNull { oldItem ->
if (oldItem.updateUrl == null) return@mapNotNull null
val oldSubsRaw = subsIdToRawFlow.value[oldItem.id]
try {
val newSubsRaw = SubscriptionRaw.parse5(

View File

@ -148,11 +148,11 @@ fun SubsPage(
Text(text = "更新: " + formatTimeAgo(subsItem!!.mtime))
}
}, confirmButton = {
if (subsRaw.supportUrl != null) {
if (subsRaw.supportUri != null) {
TextButton(onClick = {
context.startActivity(
Intent(
Intent.ACTION_VIEW, Uri.parse(subsRaw.supportUrl)
Intent.ACTION_VIEW, Uri.parse(subsRaw.supportUri)
)
)
}) {

View File

@ -21,7 +21,6 @@ object Singleton {
encodeDefaults = true
}
}
val json5: Jankson by lazy { Jankson.builder().build() }
val client by lazy {
HttpClient(OkHttp) {

View File

@ -72,8 +72,7 @@ data class Store(
val excludeFromRecents: Boolean = false,
val captureScreenshot: Boolean = false,
val httpServerPort: Int = 8888,
val autoUpdateSubsIntervalTimeMillis: Long = 60 * 60_000,
val autoUpdateSubs: Boolean = false,
val updateSubsInterval: Long = 60 * 60_000,
val captureVolumeKey: Boolean = false,
val autoCheckAppUpdate: Boolean = true,
) : Parcelable

View File

@ -1,10 +1,12 @@
package li.songe.gkd.util
import kotlinx.coroutines.Dispatchers
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 kotlinx.coroutines.launch
import li.songe.gkd.appScope
import li.songe.gkd.data.DeviceInfo
import li.songe.gkd.data.Rule
@ -12,6 +14,7 @@ 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.gkd.debug.HttpService
import li.songe.selector.Selector