feat: multiplatform

This commit is contained in:
lisonge 2023-07-10 11:25:17 +08:00
parent fd999da16f
commit 007655206d
180 changed files with 4300 additions and 3352 deletions

File diff suppressed because one or more lines are too long

View File

@ -1,12 +1,17 @@
import com.android.build.gradle.internal.cxx.json.jsonStringOf
import java.text.SimpleDateFormat
import java.util.Locale
plugins {
id("com.android.application")
id("kotlin-android")
id("kotlin-parcelize")
id("kotlin-kapt")
id("org.jetbrains.kotlin.plugin.serialization")
id("org.jetbrains.kotlin.android")
kotlin("android")
kotlin("plugin.serialization")
id("com.google.devtools.ksp")
id("dev.rikka.tools.refine")
}
@Suppress("UnstableApiUsage")
android {
namespace = "li.songe.gkd"
@ -26,12 +31,17 @@ android {
useSupportLibrary = true
}
kapt {
arguments {
// room 依赖每次构建的产物来执行自动迁移
arg("room.schemaLocation", "$projectDir/schemas")
javaCompileOptions {
annotationProcessorOptions {
arguments += mapOf(
"room.schemaLocation" to "$projectDir/schemas",
"room.incremental" to "true"
)
}
}
val nowTime = System.currentTimeMillis()
buildConfigField("Long", "BUILD_TIME", jsonStringOf(nowTime) + "L")
buildConfigField("String", "BUILD_DATE", jsonStringOf(SimpleDateFormat("yyyy-MM-dd HH:mm:ss ZZ", Locale.SIMPLIFIED_CHINESE).format(nowTime)))
}
lint {
@ -47,18 +57,8 @@ android {
}
}
kotlin {
sourceSets.debug {
kotlin.srcDir("build/generated/ksp/debug/kotlin")
}
sourceSets.release {
kotlin.srcDir("build/generated/ksp/release/kotlin")
}
}
buildTypes {
release {
manifestPlaceholders += mapOf()
isMinifyEnabled = false
setProguardFiles(
listOf(
@ -67,31 +67,31 @@ android {
)
)
signingConfig = signingConfigs.getByName("release")
manifestPlaceholders["appName"] = "搞快点"
manifestPlaceholders["appName"] = "GKD"
}
debug {
applicationIdSuffix = ".debug"
signingConfig = signingConfigs.getByName("release")
manifestPlaceholders["appName"] = "搞快点-dev"
manifestPlaceholders["appName"] = "GKD-debug"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "1.8"
freeCompilerArgs = freeCompilerArgs + "-opt-in=kotlin.RequiresOptIn"
jvmTarget = JavaVersion.VERSION_17.majorVersion
freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn"
}
buildFeatures {
buildConfig = true
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
kotlinCompilerExtensionVersion = libs.versions.compose.compilerVersion.get()
}
packagingOptions {
packaging {
resources {
// Due to https://github.com/Kotlin/kotlinx.coroutines/issues/2023
excludes += "META-INF/INDEX.LIST"
@ -106,16 +106,20 @@ android {
exclude("org.jetbrains.kotlinx", "kotlinx-coroutines-debug")
}
}
// ksp
sourceSets.configureEach {
kotlin.srcDir("$buildDir/generated/ksp/$name/kotlin/")
}
}
dependencies {
implementation(project(mapOf("path" to ":selector_core")))
implementation(project(mapOf("path" to ":router")))
dependencies {
implementation(project(mapOf("path" to ":selector")))
implementation(libs.androidx.appcompat)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.localbroadcastmanager)
implementation(libs.compose.ui)
implementation(libs.compose.material)
@ -128,15 +132,18 @@ dependencies {
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso)
compileOnly(project(mapOf("path" to ":hidden_api")))
implementation(libs.rikka.shizuku.api)
implementation(libs.rikka.shizuku.provider)
implementation(libs.lsposed.hiddenapibypass)
implementation(libs.tencent.bugly)
implementation(libs.tencent.mmkv)
implementation(libs.androidx.room.runtime)
kapt(libs.androidx.room.compiler)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.netty)
@ -144,12 +151,13 @@ dependencies {
implementation(libs.ktor.server.content.negotiation)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.android)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.google.accompanist.drawablepainter)
implementation(libs.google.accompanist.placeholder.material)
implementation(libs.google.accompanist.systemuicontroller)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.collections.immutable)
@ -160,4 +168,9 @@ dependencies {
implementation(libs.others.zxing.android.embedded)
implementation(libs.others.floating.bubble.view)
implementation(libs.destinations.core)
implementation(libs.destinations.animations)
ksp(libs.destinations.ksp)
}

View File

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "2083d8585fffd897fde3733958e356f8",
"identityHash": "f3feda76127233f3416d7570fca1615f",
"entities": [
{
"tableName": "subs_item",
@ -149,12 +149,116 @@
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "snapshot",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT, `activity_id` TEXT, `app_name` TEXT, `app_version_code` INTEGER, `app_version_name` TEXT, `screen_height` INTEGER NOT NULL, `screen_width` INTEGER NOT NULL, `is_landscape` INTEGER NOT NULL, `device` TEXT NOT NULL, `model` TEXT NOT NULL, `manufacturer` TEXT NOT NULL, `brand` TEXT NOT NULL, `sdk_int` INTEGER NOT NULL, `release` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "app_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "activityId",
"columnName": "activity_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "appName",
"columnName": "app_name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "appVersionCode",
"columnName": "app_version_code",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "appVersionName",
"columnName": "app_version_name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "screenHeight",
"columnName": "screen_height",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "screenWidth",
"columnName": "screen_width",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isLandscape",
"columnName": "is_landscape",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "device",
"columnName": "device",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "model",
"columnName": "model",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "manufacturer",
"columnName": "manufacturer",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "brand",
"columnName": "brand",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sdkInt",
"columnName": "sdk_int",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "release",
"columnName": "release",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2083d8585fffd897fde3733958e356f8')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f3feda76127233f3416d7570fca1615f')"
]
}
}

View File

@ -0,0 +1,124 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "296a7b78252c48246f24767e66441c22",
"entities": [
{
"tableName": "snapshot",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `app_id` TEXT, `activity_id` TEXT, `app_name` TEXT, `app_version_code` INTEGER, `app_version_name` TEXT, `screen_height` INTEGER NOT NULL, `screen_width` INTEGER NOT NULL, `is_landscape` INTEGER NOT NULL, `device` TEXT NOT NULL, `model` TEXT NOT NULL, `manufacturer` TEXT NOT NULL, `brand` TEXT NOT NULL, `sdk_int` INTEGER NOT NULL, `release` TEXT NOT NULL, `_1` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "app_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "activityId",
"columnName": "activity_id",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "appName",
"columnName": "app_name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "appVersionCode",
"columnName": "app_version_code",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "appVersionName",
"columnName": "app_version_name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "screenHeight",
"columnName": "screen_height",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "screenWidth",
"columnName": "screen_width",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isLandscape",
"columnName": "is_landscape",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "device",
"columnName": "device",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "model",
"columnName": "model",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "manufacturer",
"columnName": "manufacturer",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "brand",
"columnName": "brand",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sdkInt",
"columnName": "sdk_int",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "release",
"columnName": "release",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "nodes",
"columnName": "_1",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '296a7b78252c48246f24767e66441c22')"
]
}
}

View File

@ -0,0 +1,70 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "5ad1f90d8f2852410fde46463bf24322",
"entities": [
{
"tableName": "subs_config",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `mtime` INTEGER NOT NULL, `type` INTEGER NOT NULL, `enable` INTEGER NOT NULL, `subs_item_id` INTEGER NOT NULL, `app_id` TEXT NOT NULL, `group_key` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mtime",
"columnName": "mtime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "enable",
"columnName": "enable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "subsItemId",
"columnName": "subs_item_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "app_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "groupKey",
"columnName": "group_key",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5ad1f90d8f2852410fde46463bf24322')"
]
}
}

View File

@ -0,0 +1,88 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "b51332e64931ac0cef5774cb5df5b703",
"entities": [
{
"tableName": "subs_item",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `mtime` INTEGER NOT NULL, `enable` INTEGER NOT NULL, `enable_update` INTEGER NOT NULL, `order` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `version` INTEGER NOT NULL, `update_url` TEXT NOT NULL, `support_url` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mtime",
"columnName": "mtime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "enable",
"columnName": "enable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "enableUpdate",
"columnName": "enable_update",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "order",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updateUrl",
"columnName": "update_url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "supportUrl",
"columnName": "support_url",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b51332e64931ac0cef5774cb5df5b703')"
]
}
}

View File

@ -0,0 +1,34 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "e565cbca157f8ba6cecb6e7cd7cc6304",
"entities": [
{
"tableName": "trigger_log",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e565cbca157f8ba6cecb6e7cd7cc6304')"
]
}
}

View File

@ -17,16 +17,21 @@
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!--
APP 有两个进程, 主进程 + :remote 进程
主进程: 主要是 activity 的前端界面
remote进程: 主要是 service
优点: 在最近任务界面删除当前APP的窗口记录时,不会让 remote进程里的 service 停止
-->
<application
android:name="li.songe.gkd.App"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:icon="@drawable/ic_launcher"
android:label="${appName}"
android:roundIcon="@mipmap/ic_launcher_round"
android:roundIcon="@drawable/ic_launcher_round"
android:supportsRtl="false"
android:theme="@style/Theme.Gkd.NoActionBar">
android:theme="@style/AppTheme">
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
@ -36,8 +41,7 @@
<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:launchMode="singleInstance">
android:exported="true">
<!--
about android:configChanges
@ -63,29 +67,39 @@
<service
android:name=".accessibility.GkdAbService"
android:exported="false"
android:label="@string/accessibility_service_label"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
android:label="@string/ab_label"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
android:process=":remote">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_description" />
android:resource="@xml/ab_desc" />
</service>
<service
android:name=".debug.ScreenshotService"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
android:foregroundServiceType="mediaProjection"
android:process=":remote" />
<service
android:name=".debug.HttpService"
android:exported="false" />
android:exported="false"
android:process=":remote" />
<service
android:name=".debug.FloatingService"
android:exported="false" />
android:exported="false"
android:process=":remote" />
<service
android:name=".accessibility.KeepAliveService"
android:exported="false" /> <!-- This provider is required by Shizuku, remove this if your app only supports Sui -->
android:exported="false"
android:process=":remote" />
<service
android:name=".accessibility.ShizukuService"
android:exported="false"
android:process=":remote" />
<provider
android:name="rikka.shizuku.ShizukuProvider"
android:authorities="${applicationId}.shizuku"
@ -93,6 +107,17 @@
android:exported="true"
android:multiprocess="false"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@ -1,22 +1,35 @@
package li.songe.gkd
import android.app.Application
import android.content.Context
import android.os.Build
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import com.blankj.utilcode.util.LogUtils
import com.tencent.bugly.crashreport.CrashReport
import com.tencent.mmkv.MMKV
import li.songe.gkd.util.Storage
import li.songe.gkd.utils.Storage
import org.lsposed.hiddenapibypass.HiddenApiBypass
import rikka.shizuku.ShizukuProvider
class App : Application() {
companion object {
lateinit var context: Application
}
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
HiddenApiBypass.addHiddenApiExemptions("L")
}
}
override fun onCreate() {
super.onCreate()
context = this
MMKV.initialize(this)
LogUtils.d(Storage.settings)
if (!Storage.settings.enableConsoleLogOut){
if (!Storage.settings.enableConsoleLogOut) {
LogUtils.d("关闭日志控制台输出")
}
LogUtils.getConfig().apply {
@ -24,6 +37,7 @@ class App : Application() {
saveDays = 30
LogUtils.getConfig().setConsoleSwitch(Storage.settings.enableConsoleLogOut)
}
ShizukuProvider.enableMultiProcessSupport(true)
CrashReport.initCrashReport(applicationContext, "d0ce46b353", false)
}
}

View File

@ -1,18 +1,24 @@
package li.songe.gkd
import androidx.activity.compose.BackHandler
import android.os.Build
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.runtime.CompositionLocalProvider
import com.blankj.utilcode.util.LogUtils
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation.compose.rememberNavController
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat
import com.dylanc.activityresult.launcher.StartActivityLauncher
import com.ramcosta.composedestinations.DestinationsNavHost
import li.songe.gkd.composition.CompositionActivity
import li.songe.gkd.composition.CompositionExt.useLifeCycleLog
import li.songe.gkd.ui.home.HomePage
import li.songe.gkd.ui.theme.MainTheme
import li.songe.gkd.util.Ext.LocalLauncher
import li.songe.gkd.util.Storage
import li.songe.gkd.util.UseHook
import li.songe.router.RouterHost
import li.songe.gkd.ui.NavGraphs
import li.songe.gkd.ui.theme.AppTheme
import li.songe.gkd.utils.LocalLauncher
import li.songe.gkd.utils.LocalNavController
import li.songe.gkd.utils.StackCacheProvider
import li.songe.gkd.utils.Storage
class MainActivity : CompositionActivity({
@ -20,30 +26,63 @@ class MainActivity : CompositionActivity({
val launcher = StartActivityLauncher(this)
onFinish { fs ->
LogUtils.d(Storage.settings)
if (Storage.settings.excludeFromRecents) {
finishAndRemoveTask() // 会让miui桌面回退动画失效
} else {
fs()
}
}
onConfigurationChanged { newConfig ->
LogUtils.d(newConfig)
UseHook.update(newConfig)
// https://juejin.cn/post/7169147194400833572
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
window.attributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
// TextView[a==1||b==1||a==1||(a==1&&b==true)]
// 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)
// }
setContent {
BackHandler {
finish()
}
CompositionLocalProvider(LocalLauncher provides launcher) {
MainTheme(false) {
RouterHost(HomePage)
val navController = rememberNavController()
AppTheme(false) {
CompositionLocalProvider(
LocalLauncher provides launcher,
LocalNavController provides navController
) {
StackCacheProvider(navController = navController) {
DestinationsNavHost(
navGraph = NavGraphs.root,
navController = navController,
)
}
}
}
}
})

View File

@ -0,0 +1,128 @@
package li.songe.gkd.accessibility
import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.GestureDescription
import android.graphics.Path
import android.graphics.Rect
import android.view.accessibility.AccessibilityNodeInfo
import li.songe.selector.Transform
import li.songe.selector.Selector
fun AccessibilityNodeInfo.getIndex(): Int {
parent?.forEachIndexed { index, accessibilityNodeInfo ->
if (accessibilityNodeInfo == this) {
return index
}
}
return 0
}
inline fun AccessibilityNodeInfo.forEachIndexed(action: (index: Int, childNode: AccessibilityNodeInfo?) -> Unit) {
var index = 0
val childCount = this.childCount
while (index < childCount) {
val child: AccessibilityNodeInfo? = getChild(index)
action(index, child)
index += 1
}
}
fun AccessibilityNodeInfo.click(service: AccessibilityService) = when {
this.isClickable -> {
this.performAction(AccessibilityNodeInfo.ACTION_CLICK)
"self"
}
else -> {
val react = Rect()
this.getBoundsInScreen(react)
val x = react.left + 50f / 100f * (react.right - react.left)
val y = react.top + 50f / 100f * (react.bottom - react.top)
if (x >= 0 && y >= 0) {
val gestureDescription = GestureDescription.Builder()
val path = Path()
path.moveTo(x, y)
gestureDescription.addStroke(GestureDescription.StrokeDescription(path, 0, 300))
service.dispatchGesture(gestureDescription.build(), null, null)
"(50%, 50%)"
} else {
"($x, $y) no click"
}
}
}
fun AccessibilityNodeInfo.getDepth(): Int {
var p: AccessibilityNodeInfo? = this
var depth = 0
while (true) {
val p2 = p?.parent
if (p2 != null) {
p = p2
depth++
} else {
break
}
}
return depth
}
fun AccessibilityNodeInfo.querySelector(selector: Selector) =
abTransform.querySelector(this, selector)
fun AccessibilityNodeInfo.querySelectorAll(selector: Selector) =
abTransform.querySelectorAll(this, selector)
// 不可以在 多线程/不同协程作用域 里同时使用
private val tempRect = Rect()
private fun AccessibilityNodeInfo.getTempRect(): Rect {
getBoundsInScreen(tempRect)
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
"isEnabled" -> node.isEnabled
"isClickable" -> node.isClickable
"isChecked" -> node.isChecked
"isCheckable" -> node.isCheckable
"isFocused" -> node.isFocused
"isFocusable" -> node.isFocusable
"isVisibleToUser" -> node.isVisibleToUser
"left" -> node.getTempRect().left
"top" -> node.getTempRect().top
"right" -> node.getTempRect().right
"bottom" -> node.getTempRect().bottom
"width" -> node.getTempRect().width()
"height" -> node.getTempRect().height()
"index" -> node.getIndex()
"depth" -> node.getDepth()
else -> null
}
},
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 }
)

View File

@ -1,31 +1,40 @@
package li.songe.gkd.accessibility
import android.graphics.Bitmap
import android.os.Build
import android.view.Display
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import com.blankj.utilcode.util.LogUtils
import com.blankj.utilcode.util.NetworkUtils
import com.blankj.utilcode.util.ScreenUtils
import com.blankj.utilcode.util.ServiceUtils
import com.blankj.utilcode.util.ToastUtils
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.withContext
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.db.table.SubsItem
import li.songe.gkd.db.util.RoomX
import li.songe.gkd.debug.NodeSnapshot
import li.songe.gkd.selector.click
import li.songe.gkd.selector.querySelectorAll
import li.songe.gkd.util.Ext.buildRuleManager
import li.songe.gkd.util.Ext.getActivityIdByShizuku
import li.songe.gkd.util.Ext.getSubsFileLastModified
import li.songe.gkd.util.Ext.launchWhile
import li.songe.gkd.util.Singleton
import li.songe.gkd.util.Storage
import li.songe.selector_core.Selector
import java.io.File
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.utils.Singleton
import li.songe.gkd.utils.Storage
import li.songe.gkd.utils.launchTry
import li.songe.gkd.utils.launchWhile
import li.songe.gkd.utils.launchWhileTry
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class GkdAbService : CompositionAbService({
useLifeCycleLog()
@ -35,7 +44,11 @@ class GkdAbService : CompositionAbService({
val scope = useScope()
service = context
onDestroy { service = null }
onDestroy {
service = null
currentAppId = null
currentActivityId = null
}
KeepAliveService.start(context)
onDestroy {
@ -46,100 +59,98 @@ class GkdAbService : CompositionAbService({
onServiceConnected { serviceConnected = true }
onInterrupt { serviceConnected = false }
onAccessibilityEvent { event ->
val activityId = event?.className?.toString() ?: return@onAccessibilityEvent
val rootAppId = rootInActiveWindow?.packageName?.toString() ?: return@onAccessibilityEvent
when (event.eventType) {
onAccessibilityEvent { event -> // 根据事件获取 activityId, 概率不准确
when (event?.eventType) {
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED, AccessibilityEvent.TYPE_WINDOWS_CHANGED -> {
// 在桌面和应用之间来回切换, 大概率导致识别失败
if (!activityId.startsWith("android.") &&
!activityId.startsWith("androidx.") &&
!activityId.startsWith("com.android.")
val activityId = event.className?.toString() ?: return@onAccessibilityEvent
if (activityId == "com.miui.home.launcher.Launcher") { // 小米桌面 bug
val appId =
rootInActiveWindow?.packageName?.toString() ?: return@onAccessibilityEvent
if (appId != "com.miui.home") {
return@onAccessibilityEvent
}
}
if (activityId.startsWith("android.") ||
activityId.startsWith("androidx.") ||
activityId.startsWith("com.android.")
) {
if ((activityId == "com.miui.home.launcher.Launcher" && rootAppId != "com.miui.home")) {
// 小米手机 上滑手势, 导致 活动名 不属于包名
// 另外 微信扫码登录第三方网站 也会导致失败
} else {
if (activityId != nodeSnapshot.activityId) {
nodeSnapshot = nodeSnapshot.copy(
activityId = activityId
)
}
}
return@onAccessibilityEvent
}
currentActivityId = activityId
}
else -> {}
}
}
scope.launchWhile {
delay(300)
val activityId = getActivityIdByShizuku() ?: return@launchWhile
if (activityId != nodeSnapshot.activityId) {
nodeSnapshot = nodeSnapshot.copy(
activityId = activityId
)
onAccessibilityEvent { event -> // 小米手机监听截屏保存快照
if (!Storage.settings.enableCaptureSystemScreenshot) 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)
}
}
}
var subsFileLastModified = 0L
scope.launchWhile { // 根据本地文件最新写入时间 决定 是否 更新数据
val t = getSubsFileLastModified()
if (t > subsFileLastModified) {
subsFileLastModified = t
ruleManager = buildRuleManager()
LogUtils.d("读取本地规则")
}
delay(10_000)
}
scope.launchWhile {
delay(50)
scope.launchWhile { // 屏幕无障碍信息轮询
delay(200)
if (!serviceConnected) return@launchWhile
if (!Storage.settings.enableService || ScreenUtils.isScreenLock()) return@launchWhile
nodeSnapshot = nodeSnapshot.copy(
root = rootInActiveWindow,
)
val shot = nodeSnapshot
if (shot.root == null) return@launchWhile
for (rule in ruleManager.match(shot.appId, shot.activityId)) {
val target = rule.query(shot.root) ?: continue
currentAppId = rootInActiveWindow?.packageName?.toString()
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)
LogUtils.d(
*rule.matches.toTypedArray(),
NodeSnapshot.abNodeToNode(target),
clickResult
*rule.matches.toTypedArray(), NodeInfo.abNodeToNode(target), clickResult
)
}
delay(150)
delay(50)
currentAppId = rootInActiveWindow?.packageName?.toString()
if (tempRules != rules) {
tempRules = rules
i = 0
}
}
}
scope.launchWhile {
scope.launchWhile { // 自动从网络更新订阅文件
delay(5000)
RoomX.select<SubsItem>().map { subsItem ->
if (!NetworkUtils.isAvailable()) return@map
if (!NetworkUtils.isAvailable()) return@launchWhile
DbSet.subsItemDao.query().first().forEach { subsItem ->
try {
val text = Singleton.client.get(subsItem.updateUrl).bodyAsText()
val subscriptionRaw = SubscriptionRaw.parse5(text)
if (subscriptionRaw.version <= subsItem.version) {
return@map
return@forEach
}
val newItem = subsItem.copy(
updateUrl = subscriptionRaw.updateUrl
?: subsItem.updateUrl,
updateUrl = subscriptionRaw.updateUrl ?: subsItem.updateUrl,
name = subscriptionRaw.name,
mtime = System.currentTimeMillis()
)
RoomX.update(newItem)
File(newItem.filePath).writeText(
newItem.subsFile.writeText(
SubscriptionRaw.stringify(
subscriptionRaw
)
)
LogUtils.d("更新订阅文件:${subsItem.name}")
DbSet.subsItemDao.update(newItem)
LogUtils.d("更新磁盘订阅文件:${subsItem.name}")
} catch (e: Exception) {
e.printStackTrace()
}
@ -147,29 +158,96 @@ class GkdAbService : CompositionAbService({
delay(30 * 60_000)
}
}) {
private var nodeSnapshot = NodeSnapshot()
set(value) {
if (field.appId != value.appId || field.activityId != value.activityId) {
LogUtils.d(
value.appId,
value.activityId,
*ruleManager.match(value.appId, value.activityId).toList().toTypedArray()
)
scope.launchTry {
DbSet.subsItemDao.query().flowOn(IO).collect {
val subscriptionRawArray = withContext(IO) {
it.filter { s -> s.enable }
.mapNotNull { s -> s.subscriptionRaw }
}
ruleManager = RuleManager(*subscriptionRawArray.toTypedArray())
}
field = value
}
private var ruleManager = RuleManager()
scope.launchWhileTry(interval = 400) {
if (shizukuIsSafeOK()) {
val topActivity =
activityTaskManager.getTasks(1, false, true)?.firstOrNull()?.topActivity
if (topActivity != null) {
currentAppId = topActivity.packageName
currentActivityId = topActivity.className
}
}
}
}) {
companion object {
private var service: GkdAbService? = null
fun isRunning() = ServiceUtils.isServiceRunning(GkdAbService::class.java)
fun currentNodeSnapshot() = service?.nodeSnapshot
fun match(selector: String) {
val rootAbNode = service?.rootInActiveWindow ?: return
val list = rootAbNode.querySelectorAll(Selector.parse(selector)).map { it.value }.toList()
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.toTypedArray()
)
}
private var service: GkdAbService? = null
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
}
suspend fun currentScreenshot() = service?.run {
suspendCoroutine {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
takeScreenshot(Display.DEFAULT_DISPLAY,
application.mainExecutor,
object : TakeScreenshotCallback {
override fun onSuccess(screenshot: ScreenshotResult) {
it.resume(
Bitmap.wrapHardwareBuffer(
screenshot.hardwareBuffer, screenshot.colorSpace
)
)
}
override fun onFailure(errorCode: Int) = it.resume(null)
})
} else {
it.resume(null)
}
}
}
// fun match(selector: String) {
// val rootAbNode = service?.rootInActiveWindow ?: return
// val list =
// rootAbNode.querySelectorAll(Selector.parse(selector)).map { it.value }.toList()
// }
}
}

View File

@ -6,9 +6,8 @@ import kotlinx.coroutines.delay
import li.songe.gkd.App
import li.songe.gkd.composition.CompositionService
import li.songe.gkd.composition.CompositionExt.useScope
import li.songe.gkd.util.Ext.createNotificationChannel
import li.songe.gkd.util.Ext.launchWhile
import li.songe.gkd.utils.launchWhile
import li.songe.gkd.utils.Ext.createNotificationChannel
class KeepAliveService : CompositionService({
createNotificationChannel(this)

View File

@ -1,10 +0,0 @@
package li.songe.gkd.accessibility
import android.view.accessibility.AccessibilityNodeInfo
data class NodeSnapshot(
val root: AccessibilityNodeInfo? = null,
val activityId: String? = null,
) {
val appId by lazy { root?.packageName?.toString() }
}

View File

@ -0,0 +1,7 @@
package li.songe.gkd.accessibility
import li.songe.gkd.composition.CompositionService
class ShizukuService: CompositionService({
})

View File

@ -6,14 +6,13 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.blankj.utilcode.util.LogUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import li.songe.gkd.util.Singleton
import li.songe.gkd.utils.Singleton
import kotlin.coroutines.CoroutineContext
object CompositionExt {
@ -40,15 +39,14 @@ object CompositionExt {
}
val filter = IntentFilter(packageName)
val broadcastManager = LocalBroadcastManager.getInstance(this)
broadcastManager.registerReceiver(receiver, filter)
registerReceiver(receiver, filter)
val sendMessage: (InvokeMessage) -> Unit = { message ->
broadcastManager.sendBroadcast(Intent(packageName).apply {
sendBroadcast(Intent(packageName).apply {
putExtra("__invoke", Singleton.json.encodeToString(message))
})
}
onDestroy {
broadcastManager.unregisterReceiver(receiver)
unregisterReceiver(receiver)
}
val setter: ((InvokeMessage) -> Unit) -> Unit = { onMessage = it }
return (setter to sendMessage)

View File

@ -0,0 +1,35 @@
package li.songe.gkd.data
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import li.songe.gkd.App
import li.songe.gkd.utils.Ext.getApplicationInfoExt
data class AppInfo(
val id: String,
val name: String? = null,
val icon: Drawable? = null,
val installed: Boolean = true
)
private val appInfoCache = mutableMapOf<String, AppInfo>()
fun getAppInfo(id: String): AppInfo {
appInfoCache[id]?.let { return it }
val packageManager = App.context.packageManager
val info = try {
// 需要权限
val rawInfo = App.context.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
}

View File

@ -1,13 +1,13 @@
package li.songe.gkd.debug
package li.songe.gkd.data
import android.graphics.Rect
import android.view.accessibility.AccessibilityNodeInfo
import kotlinx.serialization.Serializable
import li.songe.gkd.selector.getDepth
import li.songe.gkd.selector.getIndex
import li.songe.gkd.accessibility.getDepth
import li.songe.gkd.accessibility.getIndex
@Serializable
data class AttrSnapshot(
data class AttrInfo(
val id: String? = null,
val name: String? = null,
val text: String? = null,
@ -30,9 +30,9 @@ data class AttrSnapshot(
private val rect = Rect()
fun info2data(
nodeInfo: AccessibilityNodeInfo,
): AttrSnapshot {
): AttrInfo {
nodeInfo.getBoundsInScreen(rect)
return AttrSnapshot(
return AttrInfo(
id = nodeInfo.viewIdResourceName,
name = nodeInfo.className?.toString(),
text = nodeInfo.text?.toString(),

View File

@ -1,25 +1,18 @@
package li.songe.gkd.debug
package li.songe.gkd.data
import android.os.Build
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class DeviceSnapshot(
@SerialName("device")
data class DeviceInfo(
val device: String = Build.DEVICE,
@SerialName("model")
val model: String = Build.MODEL,
@SerialName("manufacturer")
val manufacturer: String = Build.MANUFACTURER,
@SerialName("brand")
val brand: String = Build.BRAND,
@SerialName("sdkInt")
val sdkInt: Int = Build.VERSION.SDK_INT,
@SerialName("release")
val release: String = Build.VERSION.RELEASE,
){
companion object{
val instance by lazy { DeviceSnapshot() }
val instance by lazy { DeviceInfo() }
}
}

View File

@ -1,24 +1,17 @@
package li.songe.gkd.debug
package li.songe.gkd.data
import android.view.accessibility.AccessibilityNodeInfo
import kotlinx.serialization.Serializable
import li.songe.gkd.selector.forEachIndexed
import li.songe.gkd.accessibility.forEachIndexed
import java.util.ArrayDeque
/**
* api/node 返回列表
*/
@Serializable
data class NodeSnapshot(
val id: Int,
val pid: Int,
val index: Int,
data class NodeInfo(
val id: Int, val pid: Int, val index: Int,
/**
* null: when getChild(i) return null
*/
val attr: AttrSnapshot?
val attr: AttrInfo?
) {
companion object {
fun abNodeToNode(
@ -26,22 +19,17 @@ data class NodeSnapshot(
id: Int = 0,
pid: Int = -1,
index: Int = 0,
): NodeSnapshot {
return NodeSnapshot(
id,
pid,
index,
nodeInfo?.let { AttrSnapshot.info2data(nodeInfo) }
)
): NodeInfo {
return NodeInfo(id, pid, index, nodeInfo?.let { AttrInfo.info2data(nodeInfo) })
}
fun info2nodeList(nodeInfo: AccessibilityNodeInfo?): List<NodeSnapshot> {
fun info2nodeList(nodeInfo: AccessibilityNodeInfo?): List<NodeInfo> {
if (nodeInfo == null) {
return emptyList()
}
val stack = ArrayDeque<Pair<Int, AccessibilityNodeInfo?>>()
stack.push(0 to nodeInfo)
val list = mutableListOf<NodeSnapshot>()
val list = mutableListOf<NodeInfo>()
list.add(abNodeToNode(nodeInfo, index = 0))
while (stack.isNotEmpty()) {
val top = stack.pop()

View File

@ -1,4 +1,4 @@
package li.songe.gkd.debug
package li.songe.gkd.data
import kotlinx.serialization.Serializable
@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
data class RpcError(
override val message: String = "unknown error",
val code: Int = 0,
val X_Rpc_Result: Boolean = true
val X_Rpc_Result:String = "error"
) : Exception(message) {
companion object {
const val HeaderKey = "X_Rpc_Result"

View File

@ -1,8 +1,8 @@
package li.songe.gkd.data
import android.view.accessibility.AccessibilityNodeInfo
import li.songe.gkd.selector.querySelector
import li.songe.selector_core.Selector
import li.songe.gkd.accessibility.querySelector
import li.songe.selector.Selector
data class Rule(
/**

View File

@ -1,6 +1,6 @@
package li.songe.gkd.data
import li.songe.selector_core.Selector
import li.songe.selector.Selector
class RuleManager(vararg subscriptionRawArray: SubscriptionRaw) {
@ -88,24 +88,31 @@ class RuleManager(vararg subscriptionRawArray: SubscriptionRaw) {
}
}
fun match(appId: String? = null, activityId: String? = null) = sequence {
if (appId == null) return@sequence
val rules = appToRulesMap[appId] ?: return@sequence
rules.forEach { rule ->
if (!rule.active) return@forEach // 处于冷却时间
if (rule.excludeActivityIds.contains(activityId)) return@forEach // 是被排除的 界面 id
if (rule.preRules.isNotEmpty()) { // 需要提前触发某个规则
val record = triggerLogQueue.lastOrNull() ?: return@forEach
if (!rule.preRules.any { it == record.rule }) return@forEach // 上一个触发的规则不在当前需要触发的列表
if (activityId == null) {
yieldAll(rules)
return@sequence
}
rules.forEach { rule ->
if (rule.excludeActivityIds.any { activityId.startsWith(it) }) return@forEach // 是被排除的 界面 id
if (activityId == null || rule.matchAnyActivity // 全匹配
|| rule.activityIds.contains(activityId) // 在匹配列表
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
}
}

View File

@ -0,0 +1,82 @@
package li.songe.gkd.data
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.TypeConverters
import androidx.room.Update
import com.blankj.utilcode.util.AppUtils
import com.blankj.utilcode.util.ScreenUtils
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.Serializable
import li.songe.gkd.accessibility.GkdAbService
import li.songe.gkd.db.IgnoreConverters
import li.songe.gkd.utils.Ext
@TypeConverters(IgnoreConverters::class)
@Entity(
tableName = "snapshot",
)
@Serializable
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? = Ext.getAppName(appId),
@ColumnInfo(name = "app_version_code") val appVersionCode: Int? = appId?.let {
AppUtils.getAppVersionCode(
appId
)
},
@ColumnInfo(name = "app_version_name") val appVersionName: String? = appId?.let {
AppUtils.getAppVersionName(
appId
)
},
@ColumnInfo(name = "screen_height") val screenHeight: Int = ScreenUtils.getScreenHeight(),
@ColumnInfo(name = "screen_width") val screenWidth: Int = ScreenUtils.getScreenWidth(),
@ColumnInfo(name = "is_landscape") val isLandscape: Boolean = ScreenUtils.isLandscape(),
@ColumnInfo(name = "device") val device: String = DeviceInfo.instance.device,
@ColumnInfo(name = "model") val model: String = DeviceInfo.instance.model,
@ColumnInfo(name = "manufacturer") val manufacturer: String = DeviceInfo.instance.manufacturer,
@ColumnInfo(name = "brand") val brand: String = DeviceInfo.instance.brand,
@ColumnInfo(name = "sdk_int") val sdkInt: Int = DeviceInfo.instance.sdkInt,
@ColumnInfo(name = "release") val release: String = DeviceInfo.instance.release,
@ColumnInfo(name = "_1") val nodes: List<NodeInfo> = emptyList(),
) {
companion object {
fun current(includeNode: Boolean = true): Snapshot {
val currentAbNode = GkdAbService.currentAbNode
val appId = currentAbNode?.packageName?.toString()
val currentActivityId = GkdAbService.currentActivityId
return Snapshot(
appId = appId,
activityId = currentActivityId,
nodes = if (includeNode) NodeInfo.info2nodeList(currentAbNode) else emptyList()
)
}
}
@Dao
@TypeConverters(IgnoreConverters::class)
interface SnapshotDao {
@Update
suspend fun update(vararg objects: Snapshot): Int
@Insert
suspend fun insert(vararg users: Snapshot): List<Long>
@Delete
suspend fun delete(vararg users: Snapshot): Int
@Query("SELECT * FROM snapshot")
fun query(): Flow<List<Snapshot>>
}
}

View File

@ -0,0 +1,64 @@
package li.songe.gkd.data
import android.os.Parcelable
import androidx.room.ColumnInfo
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
import kotlinx.coroutines.flow.Flow
import kotlinx.parcelize.Parcelize
@Entity(
tableName = "subs_config",
)
@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 = "subs_item_id") val subsItemId: Long = -1,
@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
}
@Dao
interface SubsConfigDao {
@Update
suspend fun update(vararg objects: SubsConfig): Int
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg users: SubsConfig): List<Long>
@Delete
suspend fun delete(vararg users: SubsConfig): Int
@Query("DELETE FROM subs_config WHERE subs_item_id=:subsItemId")
suspend fun deleteSubs(subsItemId: Long): Int
@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")
suspend fun queryGroupTypeConfig(subsItemId: Long, appId: String): List<SubsConfig>
}
}

View File

@ -0,0 +1,79 @@
package li.songe.gkd.data
import android.os.Parcelable
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import li.songe.gkd.db.DbSet
import li.songe.gkd.utils.FolderExt
import java.io.File
@Entity(
tableName = "subs_item",
)
@Parcelize
data class SubsItem(
@PrimaryKey @ColumnInfo(name = "id") val id: 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 = 0,
// 订阅文件的根字段
@ColumnInfo(name = "name") val name: String = "",
@ColumnInfo(name = "author") val author: String = "",
@ColumnInfo(name = "version") val version: Int = 0,
@ColumnInfo(name = "update_url") val updateUrl: String = "",
@ColumnInfo(name = "support_url") val supportUrl: String = "",
) : Parcelable {
@IgnoredOnParcel
val subsFile by lazy {
File(FolderExt.subsFolder.absolutePath.plus("/${id}.json"))
}
@IgnoredOnParcel
val subscriptionRaw by lazy {
try {
SubscriptionRaw.parse5(subsFile.readText())
} catch (e: Exception) {
e.printStackTrace()
null
}
}
suspend fun removeAssets() {
DbSet.subsItemDao.delete(this)
withContext(IO) {
subsFile.exists() && subsFile.delete()
}
DbSet.subsConfigDao.deleteSubs(id)
}
@Dao
interface SubsItemDao {
@Update
suspend fun update(vararg objects: SubsItem): Int
@Insert
suspend fun insert(vararg users: SubsItem): List<Long>
@Delete
suspend fun delete(vararg users: SubsItem): Int
@Query("SELECT * FROM subs_item ORDER BY `order`")
fun query(): Flow<List<SubsItem>>
}
}

View File

@ -6,8 +6,8 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.*
import li.songe.gkd.util.Singleton
import li.songe.selector_core.Selector
import li.songe.gkd.utils.Singleton
import li.songe.selector.Selector
@Parcelize
@ -35,6 +35,7 @@ data class SubscriptionRaw(
@Serializable
data class GroupRaw(
@SerialName("name") val name: String? = null,
@SerialName("desc") val desc: String? = null,
@SerialName("key") val key: Int? = null,
@SerialName("cd") val cd: Long? = null,
@SerialName("activityIds") val activityIds: List<String>? = null,
@ -78,13 +79,13 @@ data class SubscriptionRaw(
JsonNull, null -> null
is JsonArray -> element.map {
when (it) {
is JsonObject, is JsonArray, JsonNull -> error("Element ${this::class} is not a int")
is JsonObject, is JsonArray, JsonNull -> error("Element $it is not a int")
is JsonPrimitive -> it.int
}
}
is JsonPrimitive -> listOf(element.int)
else -> error("")
else -> error("Element $element is not a Array")
}
}
@ -95,11 +96,11 @@ data class SubscriptionRaw(
if (p.isString) {
p.content
} else {
error("")
error("Element $p is not a string")
}
}
else -> error("")
else -> error("Element $p is not a string")
}
@Suppress("SameParameterValue")
@ -110,7 +111,7 @@ data class SubscriptionRaw(
p.long
}
else -> error("")
else -> error("Element $p is not a long")
}
private fun getInt(json: JsonObject? = null, key: String = ""): Int? =
@ -120,7 +121,7 @@ data class SubscriptionRaw(
p.int
}
else -> error("")
else -> error("Element $p is not a int")
}
private fun jsonToRuleRaw(rulesRawJson: JsonElement): RuleRaw {
@ -134,12 +135,10 @@ data class SubscriptionRaw(
excludeActivityIds = getStringIArray(rulesJson, "excludeActivityIds"),
cd = getLong(rulesJson, "cd"),
matches = (getStringIArray(
rulesJson,
"matches"
rulesJson, "matches"
) ?: emptyList()).onEach { Selector.parse(it) },
excludeMatches = (getStringIArray(
rulesJson,
"excludeMatches"
rulesJson, "excludeMatches"
) ?: emptyList()).onEach { Selector.parse(it) },
key = getInt(rulesJson, "key"),
name = getString(rulesJson, "name"),
@ -154,11 +153,11 @@ 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"),
key = getInt(groupsJson, "key"),
rules = when (val rulesJson = groupsJson["rules"]) {
null, JsonNull -> emptyList()
@ -166,13 +165,11 @@ data class SubscriptionRaw(
is JsonArray -> rulesJson
}.map {
jsonToRuleRaw(it)
}
)
})
}
private fun jsonToAppRaw(appsJson: JsonObject): AppRaw {
return AppRaw(
activityIds = getStringIArray(appsJson, "activityIds"),
return AppRaw(activityIds = getStringIArray(appsJson, "activityIds"),
excludeActivityIds = getStringIArray(appsJson, "excludeActivityIds"),
cd = getLong(appsJson, "cd"),
id = getString(appsJson, "id") ?: error(""),
@ -182,25 +179,22 @@ data class SubscriptionRaw(
is JsonArray -> groupsJson
}).map {
jsonToGroupRaw(it)
}
)
})
}
private fun jsonToSubscriptionRaw(rootJson: JsonObject): SubscriptionRaw {
return SubscriptionRaw(
name = getString(rootJson, "name") ?: error(""),
return SubscriptionRaw(name = getString(rootJson, "name") ?: error(""),
version = getInt(rootJson, "version") ?: error(""),
author = getString(rootJson, "author"),
updateUrl = getString(rootJson, "updateUrl"),
supportUrl = getString(rootJson, "supportUrl"),
apps = rootJson["apps"]?.jsonArray?.map { jsonToAppRaw(it.jsonObject) }
?: emptyList()
)
?: emptyList())
}
fun stringify(source: SubscriptionRaw) = Singleton.json.encodeToString(source)
fun parse(source: String): SubscriptionRaw {
private fun parse(source: String): SubscriptionRaw {
return jsonToSubscriptionRaw(Singleton.json.parseToJsonElement(source).jsonObject)
}

View File

@ -0,0 +1,54 @@
package li.songe.gkd.data
import android.os.Parcelable
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Update
import kotlinx.parcelize.Parcelize
import java.nio.channels.Selector
@Entity(
tableName = "trigger_log",
)
@Parcelize
data class TriggerLog(
/**
* id 与某个 snapshot id 一致, 表示 one to one
*/
@PrimaryKey @ColumnInfo(name = "id") val id: Long,
/**
* 订阅文件 id
*/
@ColumnInfo(name = "subs_id") val subsId: Long,
/**
* 触发的组 id
*/
@ColumnInfo(name = "group_key") val groupKey: Int,
/**
* 触发的选择器
*/
@ColumnInfo(name = "match") val match: String,
) : Parcelable {
@Dao
interface TriggerLogDao {
@Update
suspend fun update(vararg objects: TriggerLog): Int
@Insert
suspend fun insert(vararg users: TriggerLog): List<Long>
@Delete
suspend fun delete(vararg users: TriggerLog): Int
@Query("SELECT * FROM trigger_log")
suspend fun query(): List<TriggerLog>
}
}

View File

@ -1,42 +0,0 @@
package li.songe.gkd.db
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.blankj.utilcode.util.PathUtils
import li.songe.gkd.App
import li.songe.gkd.db.table.SubsConfig
import li.songe.gkd.db.table.SubsItem
import java.io.File
@Database(
version = 1,
entities = [SubsItem::class, SubsConfig::class],
autoMigrations = [
// AutoMigration(from = 1, to = 2),
// AutoMigration(from = 2, to = 3),
],
// 自动迁移 https://developer.android.com/training/data-storage/room/migrating-db-versions#automated
)
abstract class AppDatabase : RoomDatabase() {
abstract fun subsItemRoomDao(): SubsItem.RoomDao
abstract fun subsConfigRoomDao(): SubsConfig.RoomDao
companion object {
val db by lazy {
File(PathUtils.getExternalAppFilesPath().plus("/db/")).apply {
if (!exists()) {
mkdir()
}
}
val name = PathUtils.getExternalAppFilesPath().plus("/db/database.db")
Room.databaseBuilder(
App.context,
AppDatabase::class.java,
name
)
.fallbackToDestructiveMigration()
.build()
}
}
}

View File

@ -1,29 +0,0 @@
package li.songe.gkd.db
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.RawQuery
import androidx.room.Update
import androidx.sqlite.db.SupportSQLiteQuery
interface BaseDao<T : Any> {
@Insert
suspend fun insert(vararg objects: T): List<Long>
@Delete
suspend fun delete(vararg objects: T): Int
@Update
suspend fun update(vararg objects: T): Int
@RawQuery
suspend fun query(sqLiteQuery: SupportSQLiteQuery): List<T>
@RawQuery
suspend fun delete(sqLiteQuery: SupportSQLiteQuery): List<Int>
// https://developer.android.com/training/data-storage/room/async-queries#kotlin
// you must set observedEntities in sub interface
// @RawQuery
// fun queryFlow(sqLiteQuery: SupportSQLiteQuery): Flow<List<T>>
}

View File

@ -1,7 +0,0 @@
package li.songe.gkd.db
interface BaseTable {
val id: Long
val ctime: Long
val mtime: Long
}

View File

@ -0,0 +1,32 @@
package li.songe.gkd.db
import androidx.room.Room
import androidx.room.RoomDatabase
import com.blankj.utilcode.util.PathUtils
import li.songe.gkd.App
import li.songe.gkd.utils.FolderExt
import java.io.File
object DbSet {
private fun <T : RoomDatabase> getDb(
klass: Class<T>, name: String
): T {
return Room.databaseBuilder(
App.context, klass, FolderExt.dbFolder.absolutePath.plus("/${name}.db")
).fallbackToDestructiveMigration()
.enableMultiInstanceInvalidation()
.build()
}
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") }
private val triggerLogDb by lazy { getDb(TriggerLogDb::class.java, "triggerLog") }
val subsItemDao by lazy { subsItemDb.subsItemDao() }
val subsConfigDao by lazy { subsConfigDb.subsConfigDao() }
val snapshotDao by lazy { snapshotDb.snapshotDao() }
val triggerLogDao by lazy { triggerLogDb.triggerLogDao() }
}

View File

@ -0,0 +1,14 @@
package li.songe.gkd.db
import androidx.room.TypeConverter
import li.songe.gkd.data.NodeInfo
object IgnoreConverters {
@TypeConverter
@JvmStatic
fun listToCol(list: List<NodeInfo>): String? = null
@TypeConverter
@JvmStatic
fun colToList(value: String?): List<NodeInfo> = emptyList()
}

View File

@ -1,35 +0,0 @@
package li.songe.gkd.db
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.blankj.utilcode.util.PathUtils
import li.songe.gkd.App
import li.songe.gkd.db.table.TriggerLog
import java.io.File
@Database(
version = 1,
entities = [TriggerLog::class],
)
abstract class LogDatabase : RoomDatabase() {
abstract fun triggerLogRoomDao(): TriggerLog.RoomDao
companion object {
val logDb by lazy {
File(PathUtils.getExternalAppFilesPath().plus("/db/")).apply {
if (!exists()) {
mkdir()
}
}
val name = PathUtils.getExternalAppFilesPath().plus("/db/log.db")
Room.databaseBuilder(
App.context,
LogDatabase::class.java,
name
)
.fallbackToDestructiveMigration()
.build()
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,43 +0,0 @@
package li.songe.gkd.db.table
import android.os.Parcelable
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.parcelize.Parcelize
import li.songe.gkd.db.BaseDao
import li.songe.gkd.db.BaseTable
@Entity(
tableName = "subs_config",
)
@Parcelize
data class SubsConfig(
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") override val id: Long = 0,
@ColumnInfo(name = "ctime") override val ctime: Long = System.currentTimeMillis(),
@ColumnInfo(name = "mtime") override val mtime: Long = System.currentTimeMillis(),
/**
* 0 - app
* 1 - group
* 2 - rule
*/
@ColumnInfo(name = "type") val type: Int = 0,
@ColumnInfo(name = "enable") val enable: Boolean = true,
@ColumnInfo(name = "subs_item_id") val subsItemId: Long = -1,
@ColumnInfo(name = "app_id") val appId: String = "",
@ColumnInfo(name = "group_key") val groupKey: Int = -1,
@ColumnInfo(name = "rule_key") val ruleKey: Int = -1,
) : BaseTable, Parcelable {
companion object {
const val AppType = 0
const val GroupType = 1
const val RuleType = 2
}
@Dao
interface RoomDao : BaseDao<SubsConfig>
}

View File

@ -1,57 +0,0 @@
package li.songe.gkd.db.table
import android.os.Parcelable
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import kotlinx.parcelize.Parcelize
import li.songe.gkd.db.BaseDao
import li.songe.gkd.db.BaseTable
@Entity(
tableName = "subs_item",
indices = [Index(value = ["update_url"], unique = true)]
)
@Parcelize
data class SubsItem(
/**
* 当主键是0时,autoGenerate将覆盖此字段,插入数据库后 需要用返回值手动更新此字段
*/
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") override val id: Long = 0,
@ColumnInfo(name = "ctime") override val ctime: Long = System.currentTimeMillis(),
@ColumnInfo(name = "mtime") override val mtime: Long = System.currentTimeMillis(),
@ColumnInfo(name = "enable") val enable: Boolean = true,
/**
* 订阅文件 name 属性
*/
@ColumnInfo(name = "name") val name: String = "",
/**
* 订阅文件下载地址,也是更新链接
*/
@ColumnInfo(name = "update_url") val updateUrl: String = "",
/**
* 订阅文件下载地址,也是更新链接
*/
@ColumnInfo(name = "version") val version: Int = 0,
/**
* 订阅文件下载后存放的路径
*/
@ColumnInfo(name = "file_path") val filePath: String = "",
/**
* 顺序
*/
@ColumnInfo(name = "index") val index: Int = 0,
) : Parcelable, BaseTable {
@Dao
interface RoomDao : BaseDao<SubsItem>
}

View File

@ -1,26 +0,0 @@
package li.songe.gkd.db.table
import android.os.Parcelable
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.parcelize.Parcelize
import li.songe.gkd.db.BaseDao
import li.songe.gkd.db.BaseTable
@Entity(
tableName = "trigger_log",
)
@Parcelize
data class TriggerLog(
@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") override val id: Long = 0,
@ColumnInfo(name = "ctime") override val ctime: Long = System.currentTimeMillis(),
@ColumnInfo(name = "mtime") override val mtime: Long = System.currentTimeMillis(),
@ColumnInfo(name = "app_id") val appId: String? = null,
@ColumnInfo(name = "activity_id") val activityId: String? = null,
@ColumnInfo(name = "selector") val selector: String = ""
) : Parcelable, BaseTable {
@Dao
interface RoomDao : BaseDao<TriggerLog>
}

View File

@ -1,44 +0,0 @@
package li.songe.gkd.db.util
import android.database.DatabaseUtils
import kotlin.reflect.KClass
data class Expression<L : Any, R, T : Any>(
val left: L,
val operator: String,
val right: R,
val tableClass: KClass<T>
) {
fun stringify(): String {
val nameText = when (left) {
is String -> left.toString()
is Expression<*, *, *> -> left.stringify()
else -> throw Exception("not support type : $left")
}
val valueText = when (right) {
null -> "NULL"
is Boolean -> (if (right) 0 else 1).toString()
is String -> DatabaseUtils.sqlEscapeString(right.toString())
is Byte, is UByte, is Short, is UShort, is Int, is UInt, is Long, is ULong, is Float, is Double -> right.toString()
is List<*> -> "(" + right.joinToString(",\u0020") {
if (it is String) {
DatabaseUtils.sqlEscapeString(it)
} else {
it?.toString() ?: "NULL"
}
} + ")"
is GlobString -> right.stringify()
is LikeString -> right.stringify()
is Expression<*, *, *> -> "(${right.stringify()})"
else -> throw Exception("not support type : $right")
}
return "$nameText $operator $valueText"
}
infix fun <L2 : Any, R2> and(other: Expression<L2, R2, T>) =
Expression(this, "AND", other, tableClass)
infix fun <L2 : Any, R2> or(other: Expression<L2, R2, T>) =
Expression(this, "OR", other, tableClass)
}

View File

@ -1,22 +0,0 @@
package li.songe.gkd.db.util
import android.database.DatabaseUtils
data class GlobString(val sqlString: String = "") {
fun one() = GlobString("$sqlString?")
fun any() = GlobString("$sqlString*")
infix fun one(s: String) = GlobString("$sqlString?").str(s)
infix fun any(s: String) = GlobString("$sqlString*").str(s)
infix fun str(s: String) = GlobString(
sqlString + s.replace("\\", "\\\\")
.replace("*", "\\*")
.replace("?", "\\?")
)
fun stringify() = "${DatabaseUtils.sqlEscapeString(sqlString)} ESCAPE '\\'"
companion object {
fun globString(value: String = "") = GlobString().str(value)
}
}

View File

@ -1,22 +0,0 @@
package li.songe.gkd.db.util
import android.database.DatabaseUtils
data class LikeString (val sqlString: String = "") {
fun one() = LikeString("$sqlString?")
fun any() = LikeString("$sqlString*")
infix fun one(s: String) = LikeString("${sqlString}_").str(s)
infix fun any(s: String) = LikeString("$sqlString%").str(s)
infix fun str(s: String) = LikeString(
sqlString + s.replace("\\", "\\\\")
.replace("_", "\\_")
.replace("%", "\\%")
)
fun stringify() = "${DatabaseUtils.sqlEscapeString(sqlString)} ESCAPE '\\'"
companion object {
fun likeString(value: String = "") = LikeString().str(value)
}
}

View File

@ -1,66 +0,0 @@
package li.songe.gkd.db.util
import kotlin.reflect.KMutableProperty1
import kotlin.reflect.KProperty1
object Operator {
infix fun <L1 : Any, R1, L2 : Any, R2, T : Any> Expression<L1, R1, T>.and(other: Expression<L2, R2, T>) =
Expression(this, "AND", other, tableClass)
infix fun <L1 : Any, R1, L2 : Any, R2, T : Any> Expression<L1, R1, T>.or(other: Expression<L2, R2, T>) =
Expression(this, "OR", other, tableClass)
// TODO 当同时设置 Property1 时, 代码失效
// 还需要写 Int, Long, String, Boolean 等多种类型的重载, 这种重复性很高,工作量指数级增长的工作确实需要联合类型
inline fun <reified T : Any, V, V2> KMutableProperty1<T, V>.baseOperator(
value: V2,
operator: String,
) =
Expression(
RoomAnnotation.getColumnName(T::class, name),
operator,
value,
T::class
)
inline infix fun <reified T : Any, reified V> KProperty1<T, V>.eq(value: V) =
baseOperator(value, "==")
inline infix fun <reified T : Any, reified V> KProperty1<T, V>.neq(value: V) =
baseOperator(value, "!=")
inline infix fun <reified T : Any, reified V> KProperty1<T, V>.less(value: V) =
baseOperator(value, "<")
inline infix fun <reified T : Any, reified V> KProperty1<T, V>.lessEq(value: V) =
baseOperator(value, "<=")
inline infix fun <reified T : Any, reified V> KProperty1<T, V>.greater(value: V) =
baseOperator(value, ">")
inline infix fun <reified T : Any, reified V> KProperty1<T, V>.greaterEq(value: V) =
baseOperator(value, ">=")
inline infix fun <reified T : Any, reified V> KProperty1<T, V>.inList(value: List<V>) =
baseOperator(value, "IN")
inline infix fun <reified T : Any, reified V> KProperty1<T, V>.glob(value: GlobString) =
baseOperator(value, "GLOB")
inline infix fun <reified T : Any, reified V> KProperty1<T, V>.like(value: LikeString) =
baseOperator(value, "LIKE")
inline fun <reified T : Any, V, V2> KProperty1<T, V>.baseOperator(
value: V2,
operator: String,
) =
Expression(
RoomAnnotation.getColumnName(T::class, name),
operator,
value,
T::class
)
}

View File

@ -1,56 +0,0 @@
package li.songe.gkd.db.util
import li.songe.gkd.db.table.SubsConfig
import li.songe.gkd.db.table.SubsItem
import li.songe.gkd.db.table.TriggerLog
import kotlin.reflect.KClass
object RoomAnnotation {
fun getTableName(cls: KClass<*>): String = when (cls) {
SubsConfig::class -> "subs_config"
SubsItem::class -> "subs_item"
TriggerLog::class -> "trigger_log"
else -> throw Exception("""not found className : ${cls.qualifiedName}""")
}
fun getColumnName(cls: KClass<*>, propertyName: String): String = when (cls) {
SubsConfig::class -> when (propertyName) {
SubsConfig::id.name -> "id"
SubsConfig::ctime.name -> "ctime"
SubsConfig::mtime.name -> "mtime"
SubsConfig::type.name -> "type"
SubsConfig::enable.name -> "enable"
SubsConfig::subsItemId.name -> "subs_item_id"
SubsConfig::appId.name -> "app_id"
SubsConfig::groupKey.name -> "group_key"
SubsConfig::ruleKey.name -> "rule_key"
else -> error("""not found columnName : ${cls.qualifiedName}#$propertyName""")
}
SubsItem::class -> when (propertyName) {
SubsItem::id.name -> "id"
SubsItem::ctime.name -> "ctime"
SubsItem::mtime.name -> "mtime"
SubsItem::enable.name -> "enable"
SubsItem::name.name -> "name"
SubsItem::updateUrl.name -> "update_url"
SubsItem::filePath.name -> "file_path"
SubsItem::index.name -> "index"
else -> error("""not found columnName : ${cls.qualifiedName}#$propertyName""")
}
TriggerLog::class -> when (propertyName) {
TriggerLog::id.name -> "id"
TriggerLog::ctime.name -> "ctime"
TriggerLog::mtime.name -> "mtime"
TriggerLog::appId.name -> "app_id"
TriggerLog::activityId.name -> "activity_id"
TriggerLog::selector.name -> "selector"
else -> error("""not found columnName : ${cls.qualifiedName}#$propertyName""")
}
else -> error("""not found className : ${cls.qualifiedName}""")
}
}

View File

@ -1,84 +0,0 @@
package li.songe.gkd.db.util
import androidx.sqlite.db.SimpleSQLiteQuery
import li.songe.gkd.db.AppDatabase.Companion.db
import li.songe.gkd.db.BaseDao
import li.songe.gkd.db.LogDatabase.Companion.logDb
import li.songe.gkd.db.table.*
import kotlin.reflect.KClass
object RoomX {
// 把表类和具体数据库方法关联起来
@Suppress("UNCHECKED_CAST")
fun <T : Any> getBaseDao(cls: KClass<T>) = when (cls) {
SubsItem::class -> db.subsItemRoomDao()
SubsConfig::class -> db.subsConfigRoomDao()
TriggerLog::class -> logDb.triggerLogRoomDao()
else -> error("not found class dao : ${cls::class.java.name}")
} as BaseDao<T>
suspend inline fun <reified T : Any> update(vararg objects: T): Int {
return getBaseDao(T::class).update(*objects)
}
/**
* 插入成功后, 自动改变入参对象的 id
*/
suspend inline fun <reified T : Any> insert(vararg objects: T): List<Long> {
return getBaseDao(T::class).insert(*objects)
}
suspend inline fun <reified T : Any> delete(vararg objects: T) =
getBaseDao(T::class).delete(*objects)
suspend inline fun <reified T : Any> select(
limit: Int? = null,
offset: Int? = null,
noinline block: (() -> Expression<*, *, T>)? = null
): List<T> {
val expression = block?.invoke()
val tableName = RoomAnnotation.getTableName(T::class)
val sqlString = "SELECT * FROM $tableName" + (if (expression != null) {
" WHERE ${expression.stringify()}"
} else {
""
}) + (if (limit != null) {
" LIMIT $limit"
} else {
""
}) + (if (offset != null) {
" OFFSET $offset"
} else {
""
})
val baseDao = getBaseDao(T::class)
return baseDao.query(SimpleSQLiteQuery(sqlString))
}
suspend inline fun <reified T : Any> delete(
limit: Int? = null,
offset: Int? = null,
noinline block: (() -> Expression<*, *, T>)? = null
): List<Int> {
val expression = block?.invoke()
val tableName = RoomAnnotation.getTableName(T::class)
val sqlString = "DELETE FROM $tableName" + (if (expression != null) {
" WHERE ${expression.stringify()}"
} else {
""
}) + (if (limit != null) {
" LIMIT $limit"
} else {
""
}) + (if (offset != null) {
" OFFSET $offset"
} else {
""
})
val baseDao = getBaseDao(T::class)
return baseDao.delete(SimpleSQLiteQuery(sqlString))
}
}

View File

@ -8,11 +8,14 @@ import com.blankj.utilcode.util.ServiceUtils
import com.torrydo.floatingbubbleview.FloatingBubble
import li.songe.gkd.App
import li.songe.gkd.R
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.utils.SafeR
class FloatingService : CompositionFbService({
useLifeCycleLog()
val context = this
val (onMessage, sendMessage) = useMessage(this::class.simpleName)
@ -22,9 +25,8 @@ class FloatingService : CompositionFbService({
"removeBubbles" -> context.removeBubbles()
}
}
setupBubble { _, resolve ->
val builder = FloatingBubble.Builder(this).bubble(R.drawable.capture, 40, 40)
val builder = FloatingBubble.Builder(this).bubble(SafeR.capture, 40, 40)
.enableCloseBubble(false)
.addFloatingBubbleListener(object : FloatingBubble.Listener {
override fun onClick() {
@ -38,16 +40,16 @@ class FloatingService : CompositionFbService({
override fun setupNotificationBuilder(channelId: String): Notification {
return NotificationCompat.Builder(this, channelId)
.setOngoing(true)
.setSmallIcon(R.drawable.ic_app_2)
.setContentTitle("bubble is running")
.setContentText("click to do nothing")
.setSmallIcon(SafeR.ic_launcher)
.setContentTitle("搞快点")
.setContentText("正在显示悬浮窗按钮")
.setPriority(NotificationCompat.PRIORITY_MIN)
.setCategory(Notification.CATEGORY_SERVICE)
.build()
}
override fun channelId() = "your_channel_id"
override fun channelName() = "your_channel_name"
override fun channelId() = "service-floating"
override fun channelName() = "悬浮窗按钮服务"
override fun notificationId() = 69
companion object{

View File

@ -21,16 +21,24 @@ import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
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.db.DbSet
import li.songe.gkd.debug.SnapshotExt.captureSnapshot
import li.songe.gkd.util.Ext.getIpAddressInLocalNetwork
import li.songe.gkd.util.Storage
import li.songe.gkd.utils.Ext.getIpAddressInLocalNetwork
import li.songe.gkd.utils.Storage
import li.songe.gkd.utils.launchTry
import java.io.File
class HttpService : CompositionService({
@ -71,30 +79,16 @@ class HttpService : CompositionService({
routing {
route("/api") {
get("/device") { call.respond(DeviceSnapshot.instance) }
get("/snapshotIds") {
call.respond(SnapshotExt.getSnapshotIds())
}
get("/device") { call.respond(DeviceInfo.instance) }
get("/snapshot") {
val id = call.request.queryParameters["id"]?.toLongOrNull()
if (id != null) {
?: throw RpcError("miss id")
val fp = File(SnapshotExt.getSnapshotPath(id))
if (!fp.exists()) {
throw RpcError("对应快照不存在")
}
call.response.cacheControl(CacheControl.MaxAge(3600))
call.response.cacheControl(CacheControl.MaxAge(3600 * 24 * 7))
call.respondFile(fp)
} else {
removeBubbles()
delay(200)
try {
call.respond(captureSnapshot())
} catch (e: Exception) {
throw e
} finally {
showBubbles()
}
}
}
get("/screenshot") {
val id = call.request.queryParameters["id"]?.toLongOrNull()
@ -103,22 +97,35 @@ class HttpService : CompositionService({
if (!fp.exists()) {
throw RpcError("对应截图不存在")
}
call.response.cacheControl(CacheControl.MaxAge(3600))
call.response.cacheControl(CacheControl.MaxAge(3600 * 24 * 7))
call.respondFile(fp)
}
get("/captureSnapshot") {
removeBubbles()
delay(200)
val snapshot = try {
captureSnapshot()
} finally {
showBubbles()
}
call.respond(snapshot)
}
get("/snapshots") {
call.respond(DbSet.snapshotDao.query().first())
}
}
}
scope.launch {
}
scope.launchTry(Dispatchers.IO) {
LogUtils.d(*getIpAddressInLocalNetwork().map { host -> "http://${host}:${Storage.settings.httpServerPort}" }
.toList().toTypedArray())
server.start(true)
}
onDestroy {
scope.launch(Dispatchers.IO) {
server.stop(1000, 2000)
scope.cancel()
scope.launchTry(Dispatchers.IO) {
server.stop()
LogUtils.d("http server is stopped")
scope.cancel()
}
}
}) {

View File

@ -8,6 +8,7 @@ import io.ktor.server.application.hooks.CallFailed
import io.ktor.server.request.uri
import io.ktor.server.response.header
import io.ktor.server.response.respond
import li.songe.gkd.data.RpcError
val RpcErrorHeaderPlugin = createApplicationPlugin(name = "RpcErrorHeaderPlugin") {
onCall { call ->
@ -36,6 +37,7 @@ val RpcErrorHeaderPlugin = createApplicationPlugin(name = "RpcErrorHeaderPlugin"
}
onCallRespond { call, _ ->
call.response.header("Access-Control-Expose-Headers", "*")
call.response.header("Access-Control-Allow-Private-Network", "true")
val status = call.response.status() ?: HttpStatusCode.OK
if (status == HttpStatusCode.OK &&
!call.response.headers.contains(

View File

@ -6,11 +6,13 @@ import android.content.Intent
import com.blankj.utilcode.util.LogUtils
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.util.ScreenshotUtil
import li.songe.gkd.utils.Ext
import li.songe.gkd.utils.ScreenshotUtil
class ScreenshotService : CompositionService({
useLifeCycleLog()
Ext.createNotificationChannel(this, 110)
onStartCommand { intent, _, _ ->

View File

@ -1,32 +0,0 @@
package li.songe.gkd.debug
import com.blankj.utilcode.util.ScreenUtils
import kotlinx.serialization.Serializable
import li.songe.gkd.accessibility.GkdAbService
import li.songe.gkd.util.Ext
@Serializable
data class Snapshot(
val id: Long = System.currentTimeMillis(),
val device: DeviceSnapshot? = DeviceSnapshot.instance,
val screenHeight: Int = ScreenUtils.getScreenHeight(),
val screenWidth: Int = ScreenUtils.getScreenWidth(),
val appId: String? = null,
val appName: String? = null,
val activityId: String? = null,
val nodes: List<NodeSnapshot>? = null,
) {
companion object {
fun current(): Snapshot {
val shot = GkdAbService.currentNodeSnapshot()
return Snapshot(
appId = shot?.appId,
appName = if (shot?.appId != null) {
Ext.getAppName(shot.appId)
} else null,
activityId = shot?.activityId,
nodes = NodeSnapshot.info2nodeList(shot?.root),
)
}
}
}

View File

@ -2,13 +2,20 @@ package li.songe.gkd.debug
import android.graphics.Bitmap
import com.blankj.utilcode.util.LogUtils
import com.blankj.utilcode.util.ScreenUtils
import com.blankj.utilcode.util.ZipUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.encodeToString
import li.songe.gkd.App
import li.songe.gkd.accessibility.GkdAbService
import li.songe.gkd.util.Singleton
import li.songe.gkd.data.RpcError
import li.songe.gkd.data.Snapshot
import li.songe.gkd.db.DbSet
import li.songe.gkd.utils.Singleton
import java.io.File
object SnapshotExt {
@ -16,7 +23,15 @@ object SnapshotExt {
App.context.getExternalFilesDir("snapshot")!!.apply { if (!exists()) mkdir() }
}
private fun getSnapshotParentPath(snapshotId: Long) =
private val emptyBitmap by lazy {
Bitmap.createBitmap(
ScreenUtils.getScreenWidth(),
ScreenUtils.getScreenHeight(),
Bitmap.Config.ARGB_8888
)
}
fun getSnapshotParentPath(snapshotId: Long) =
"${snapshotDir.absolutePath}/${snapshotId}"
fun getSnapshotPath(snapshotId: Long) =
@ -30,21 +45,51 @@ object SnapshotExt {
?.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)
), file.absolutePath
)
}
}
return file
}
fun remove(id: Long) {
File(getSnapshotParentPath(id)).apply {
if (exists()) {
deleteRecursively()
}
}
}
suspend fun captureSnapshot(): Snapshot {
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("截屏不可用,即将使用空白图片")
}
val snapshot = Snapshot.current()
val bitmap = withTimeoutOrNull(3_000) {
ScreenshotService.screenshot()
} ?: Bitmap.createBitmap(
snapshot.screenWidth,
snapshot.screenHeight,
Bitmap.Config.ARGB_8888
)
}
}
val bitmap = bitmapDef.await()
val snapshot = snapshotDef.await()
withContext(Dispatchers.IO) {
File(getSnapshotParentPath(snapshot.id)).apply { if (!exists()) mkdirs() }
val stream =
@ -53,6 +98,7 @@ object SnapshotExt {
stream.close()
val text = Singleton.json.encodeToString(snapshot)
File(getSnapshotPath(snapshot.id)).writeText(text)
DbSet.snapshotDao.insert(snapshot)
}
return snapshot
}

View File

@ -0,0 +1,24 @@
package li.songe.gkd.icon
import androidx.compose.foundation.Image
import androidx.compose.material.icons.materialIcon
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.addPathNodes
import androidx.compose.ui.tooling.preview.Preview
// @DslMarker
// https://github.com/JetBrains/kotlin-wrappers/blob/master/kotlin-react/src/jsMain/kotlin/react/ChildrenBuilder.kt
val AddIcon = materialIcon(name = "add") {
addPath(
pathData = addPathNodes("M18,13h-5v5c0,0.55 -0.45,1 -1,1s-1,-0.45 -1,-1v-5H6c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1h5V6c0,-0.55 0.45,-1 1,-1s1,0.45 1,1v5h5c0.55,0 1,0.45 1,1s-0.45,1 -1,1z"),
fill = Brush.linearGradient(listOf(Color.Black, Color.Black))
)
}
@Preview
@Composable
fun PreviewIconAdd() {
Image(imageVector = AddIcon, contentDescription = null)
}

View File

@ -0,0 +1,31 @@
package li.songe.gkd.icon
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import androidx.compose.foundation.Image
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.google.accompanist.drawablepainter.rememberDrawablePainter
@Preview
@Composable
fun PreviewTestDsl() {
val vectorString = """
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8l8,8l1.41,-1.41L7.83,13H20v-2z" />
</vector>
""".trim()
val drawable = Drawable.createFromStream(vectorString.byteInputStream(), "ic_back")
if (drawable != null) {
Image(painter = rememberDrawablePainter(drawable = drawable), contentDescription = null)
} else {
Text(text = "null drawable")
}
}

View File

@ -1,41 +0,0 @@
package li.songe.gkd.selector
import android.view.accessibility.AccessibilityNodeInfo
import li.songe.selector_core.NodeExt
@JvmInline
value class AbNode(val value: AccessibilityNodeInfo) : NodeExt {
override val parent: NodeExt?
get() = value.parent?.let { AbNode(it) }
override val children: Sequence<NodeExt?>
get() = sequence {
repeat(value.childCount) { i ->
val child = value.getChild(i)
if (child != null) {
yield(AbNode(child))
} else {
yield(null)
}
}
}
override fun getChild(offset: Int) = value.getChild(offset)?.let { AbNode(it) }
override val name: CharSequence
get() = value.className
override fun attr(name: String): Any? = when (name) {
"id" -> value.viewIdResourceName
"name" -> value.className
"text" -> value.text
"textLen" -> value.text?.length
"desc" -> value.contentDescription
"descLen" -> value.contentDescription?.length
"isClickable" -> value.isClickable
"childCount" -> value.childCount
"index" -> value.getIndex()
"depth" -> value.getDepth()
else -> null
}
}

View File

@ -1,77 +0,0 @@
package li.songe.gkd.selector
import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.GestureDescription
import android.graphics.Path
import android.graphics.Rect
import android.view.accessibility.AccessibilityNodeInfo
import li.songe.selector_core.Selector
fun AccessibilityNodeInfo.getIndex(): Int {
parent?.forEachIndexed { index, accessibilityNodeInfo ->
if (accessibilityNodeInfo == this) {
return index
}
}
return 0
}
inline fun AccessibilityNodeInfo.forEachIndexed(action: (index: Int, childNode: AccessibilityNodeInfo?) -> Unit) {
var index = 0
val childCount = this.childCount
while (index < childCount) {
val child: AccessibilityNodeInfo? = getChild(index)
action(index, child)
index += 1
}
}
fun AccessibilityNodeInfo.querySelector(selector: Selector): AccessibilityNodeInfo? {
val ab = AbNode(this)
val result = (ab.querySelector(selector) as AbNode?) ?: return null
return result.value
}
fun AccessibilityNodeInfo.querySelectorAll(selector: Selector): Sequence<AbNode> {
val ab = AbNode(this)
return ab.querySelectorAll(selector) as Sequence<AbNode>
}
fun AccessibilityNodeInfo.click(service: AccessibilityService) = when {
this.isClickable -> {
this.performAction(AccessibilityNodeInfo.ACTION_CLICK)
"self"
}
else -> {
val react = Rect()
this.getBoundsInScreen(react)
val x = react.left + 50f / 100f * (react.right - react.left)
val y = react.top + 50f / 100f * (react.bottom - react.top)
if (x >= 0 && y >= 0) {
val gestureDescription = GestureDescription.Builder()
val path = Path()
path.moveTo(x, y)
gestureDescription.addStroke(GestureDescription.StrokeDescription(path, 0, 300))
service.dispatchGesture(gestureDescription.build(), null, null)
"(50%, 50%)"
} else {
"($x, $y) no click"
}
}
}
fun AccessibilityNodeInfo.getDepth(): Int {
var p: AccessibilityNodeInfo? = this
var depth = 0
while (true) {
val p2 = p?.parent
if (p2 != null) {
p = p2
depth++
} else {
break
}
}
return depth
}

View File

@ -11,27 +11,9 @@ class AutoStartReceiver : BroadcastReceiver() {
Shizuku.addBinderReceivedListenerSticky(oneShotBinderReceivedListener)
}
}
private val oneShotBinderReceivedListener = object : Shizuku.OnBinderReceivedListener {
override fun onBinderReceived() {
// AutomatorViewModel.get().run {
// app.openFileOutput("on_boot", Context.MODE_PRIVATE).bufferedWriter().apply {
// write("binder received")
// newLine()
// write("permission granted: ${Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED}")
// newLine()
// write("is_binding: ${isBinding.value}")
// newLine()
// write("is_running: ${isRunning.value}")
// newLine()
// if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED &&
// isBinding.value != true && isRunning.value != true
// ) {
// write("starting service...")
// toggleService()
// isAutoStarted.value = true
// }
// }
// }
Shizuku.removeBinderReceivedListener(this)
}
}

View File

@ -1,168 +0,0 @@
package li.songe.gkd.shizuku
import android.content.Context
import android.util.Log
import java.io.*
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
import java.security.DigestInputStream
import java.security.MessageDigest
import java.util.zip.CRC32
object IOUtils {
private const val TAG = "IOUtils"
@Throws(IOException::class)
fun copyStream(from: InputStream, to: OutputStream) {
val buf = ByteArray(1024 * 1024)
var len: Int
while (from.read(buf).also { len = it } > 0) {
to.write(buf, 0, len)
}
}
@Throws(IOException::class)
fun copyFile(original: File?, destination: File?) {
FileInputStream(original).use { inputStream ->
FileOutputStream(destination).use { outputStream ->
copyStream(
inputStream,
outputStream
)
}
}
}
@Throws(IOException::class)
fun copyFileFromAssets(context: Context, assetFileName: String?, destination: File?) {
context.assets.open(assetFileName!!).use { inputStream ->
FileOutputStream(destination).use { outputStream ->
copyStream(
inputStream,
outputStream
)
}
}
}
fun deleteRecursively(f: File) {
if (f.isDirectory) {
val files = f.listFiles()
if (files != null) {
for (child in files) deleteRecursively(child)
}
}
f.delete()
}
@Throws(IOException::class)
fun calculateFileCrc32(file: File?): Long {
return calculateCrc32(FileInputStream(file))
}
@Throws(IOException::class)
fun calculateBytesCrc32(bytes: ByteArray?): Long {
return calculateCrc32(ByteArrayInputStream(bytes))
}
@Throws(IOException::class)
fun calculateCrc32(inputStream: InputStream): Long {
inputStream.use { `in` ->
val crc32 = CRC32()
val buffer = ByteArray(1024 * 1024)
var read: Int
while (`in`.read(buffer).also { read = it } > 0) crc32.update(buffer, 0, read)
return crc32.value
}
}
fun writeStreamToStringBuilder(builder: StringBuilder, inputStream: InputStream?): Thread {
val t = Thread {
try {
val buf = CharArray(1024)
var len: Int
val reader =
BufferedReader(InputStreamReader(inputStream))
while (reader.read(buf).also { len = it } > 0) builder.append(buf, 0, len)
reader.close()
} catch (e: Exception) {
Log.wtf(TAG, e)
}
}
t.start()
return t
}
/**
* Read contents of input stream to a byte array and close it
*
* @param inputStream
* @return contents of input stream
* @throws IOException
*/
@Throws(IOException::class)
fun readStream(inputStream: InputStream): ByteArray {
inputStream.use { `in` -> return readStreamNoClose(`in`) }
}
@Throws(IOException::class)
fun readStream(inputStream: InputStream, charset: Charset?): String {
return String(readStream(inputStream), charset!!)
}
/**
* Read contents of input stream to a byte array, but don't close the stream
*
* @param inputStream
* @return contents of input stream
* @throws IOException
*/
@Throws(IOException::class)
fun readStreamNoClose(inputStream: InputStream): ByteArray {
val buffer = ByteArrayOutputStream()
copyStream(inputStream, buffer)
return buffer.toByteArray()
}
fun closeSilently(closeable: Closeable?) {
if (closeable == null) return
try {
closeable.close()
} catch (e: Exception) {
Log.w(TAG, String.format("Unable to close %s", closeable.javaClass.canonicalName), e)
}
}
/**
* Hashes stream content using passed [MessageDigest], closes the stream and returns digest bytes
*
* @param inputStream
* @param messageDigest
* @return
* @throws IOException
*/
@Throws(IOException::class)
fun hashStream(inputStream: InputStream?, messageDigest: MessageDigest): ByteArray {
DigestInputStream(inputStream, messageDigest).use { digestInputStream ->
val buffer = ByteArray(1024 * 64)
var read: Int
while (digestInputStream.read(buffer).also { read = it } > 0) {
//Do nothing
}
return messageDigest.digest()
}
}
@Throws(IOException::class)
fun hashString(s: String, messageDigest: MessageDigest): ByteArray {
return hashStream(
ByteArrayInputStream(s.toByteArray(StandardCharsets.UTF_8)),
messageDigest
)
}
@Throws(IOException::class)
fun readFile(file: File?): ByteArray {
FileInputStream(file).use { `in` -> return readStream(`in`) }
}
}

View File

@ -1,71 +0,0 @@
package li.songe.gkd.shizuku
import android.annotation.SuppressLint
import java.io.InputStream
import java.util.*
interface Shell {
val isAvailable: Boolean
fun exec(command: Command): Result
fun exec(command: Command, inputPipe: InputStream): Result
fun makeLiteral(arg: String): String
class Command(command: String, vararg args: String) {
private val mArgs = mutableListOf<String>()
fun toStringArray(): Array<String?> {
val array = arrayOfNulls<String>(mArgs.size)
for (i in mArgs.indices) array[i] = mArgs[i]
return array
}
override fun toString(): String {
val sb = StringBuilder()
for (i in mArgs.indices) {
val arg = mArgs[i]
sb.append(arg)
if (i < mArgs.size - 1) sb.append(" ")
}
return sb.toString()
}
class Builder(command: String, vararg args: String) {
private val mCommand: Command = Command(command, *args)
fun addArg(argument: String): Builder {
mCommand.mArgs.add(argument)
return this
}
fun build(): Command {
return mCommand
}
}
init {
mArgs.add(command)
mArgs.addAll(args)
}
}
open class Result(
var cmd: Command,
var exitCode: Int,
var out: String,
var err: String
) {
val isSuccessful: Boolean
get() = exitCode == 0
@SuppressLint("DefaultLocale")
override fun toString(): String {
return String.format(
"Command: %s\nExit code: %d\nOut:\n%s\n=============\nErr:\n%s",
cmd,
exitCode,
out,
err
)
}
}
}

View File

@ -0,0 +1,12 @@
package li.songe.gkd.shizuku
import android.content.pm.PackageManager
import rikka.shizuku.Shizuku
fun shizukuIsSafeOK(): Boolean {
return try {
Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
} catch (_: Exception) {
false
}
}

View File

@ -1,89 +0,0 @@
package li.songe.gkd.shizuku
import android.os.Build
import android.util.Log
import rikka.shizuku.Shizuku
import java.io.InputStream
/**
* https://github.com/Aefyr/SAI/blob/master/app/src/main/java/com/aefyr/sai/shell/ShizukuShell.java
*/
class ShizukuShell private constructor() : Shell {
override val isAvailable: Boolean
get() = if (!Shizuku.pingBinder()) false else try {
exec(Shell.Command("echo", "test")).isSuccessful
} catch (e: Exception) {
Log.w(TAG, "Unable to access shizuku: ")
Log.w(TAG, e)
false
}
override fun exec(command: Shell.Command): Shell.Result {
return execInternal(command, null)
}
override fun exec(command: Shell.Command, inputPipe: InputStream): Shell.Result {
return execInternal(command, inputPipe)
}
override fun makeLiteral(arg: String): String {
return "'" + arg.replace("'", "'\\''") + "'"
}
private fun execInternal(command: Shell.Command, inputPipe: InputStream?): Shell.Result {
val stdOutSb = StringBuilder()
val stdErrSb = StringBuilder()
return try {
val shCommand = Shell.Command.Builder("sh", "-c", command.toString())
val process = Shizuku.newProcess(shCommand.build().toStringArray(), null, null)
val stdOutD: Thread = IOUtils.writeStreamToStringBuilder(stdOutSb, process.inputStream)
val stdErrD: Thread = IOUtils.writeStreamToStringBuilder(stdErrSb, process.errorStream)
if (inputPipe != null) {
try {
process.outputStream.use { outputStream ->
inputPipe.use { inputStream ->
IOUtils.copyStream(
inputStream,
outputStream
)
}
}
} catch (e: Exception) {
stdOutD.interrupt()
stdErrD.interrupt()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
process.destroyForcibly()
} else {
process.destroy()
}
throw RuntimeException(e)
}
}
process.waitFor()
stdOutD.join()
stdErrD.join()
Shell.Result(
command,
process.exitValue(),
stdOutSb.toString().trim { it <= ' ' },
stdErrSb.toString().trim { it <= ' ' })
} catch (e: Exception) {
Log.w(TAG, "Unable execute command: ")
Log.w(TAG, e)
Shell.Result(
command, -1, stdOutSb.toString().trim { it <= ' ' },
"""$stdErrSb
<!> SAI ShizukuShell Java exception: ${Utils.throwableToString(e)}"""
)
}
}
companion object {
private const val TAG = "ShizukuShell"
val instance by lazy { ShizukuShell() }
}
}

View File

@ -0,0 +1,19 @@
package li.songe.gkd.shizuku
import android.app.IActivityTaskManager
import android.content.pm.IPackageManager
import rikka.shizuku.ShizukuBinderWrapper
import rikka.shizuku.SystemServiceHelper
val activityTaskManager: IActivityTaskManager by lazy {
SystemServiceHelper.getSystemService("activity_task")
.let(::ShizukuBinderWrapper)
.let(IActivityTaskManager.Stub::asInterface)
}
val iPackageManager: IPackageManager by lazy {
SystemServiceHelper.getSystemService("package")
.let(::ShizukuBinderWrapper)
.let(IPackageManager.Stub::asInterface)
}

View File

@ -1,17 +0,0 @@
package li.songe.gkd.shizuku
import java.io.PrintWriter
import java.io.StringWriter
object Utils {
fun throwableToString(throwable: Throwable): String {
val sw = StringWriter(1024)
val pw = PrintWriter(sw)
throwable.printStackTrace(pw)
pw.close()
return sw.toString()
}
}

View File

@ -0,0 +1,86 @@
package li.songe.gkd.ui
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.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.Icon
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import li.songe.gkd.BuildConfig
import li.songe.gkd.utils.SafeR
@RootNavGraph
@Destination
@Composable
fun AboutPage(navigator: DestinationsNavigator) {
// val systemUiController = rememberSystemUiController()
// val context = LocalContext.current as ComponentActivity
// DisposableEffect(systemUiController) {
// val oldVisible = systemUiController.isStatusBarVisible
// systemUiController.isStatusBarVisible = false
// WindowCompat.setDecorFitsSystemWindows(context.window, false)
// onDispose {
// systemUiController.isStatusBarVisible = oldVisible
// WindowCompat.setDecorFitsSystemWindows(context.window, true)
// }
// }
Scaffold(
topBar = {
TopAppBar(
backgroundColor = Color(0xfff8f9f9),
navigationIcon = {
Column(
modifier = Modifier
.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
painter = painterResource(id = SafeR.ic_back),
contentDescription = null,
modifier = Modifier
.size(30.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false),
) {
navigator.popBackStack()
}
)
}
},
title = { Text(text = "关于") }
)
},
content = { contentPadding ->
Column(
Modifier
.padding(contentPadding)
.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(text = "版本代码: " + BuildConfig.VERSION_CODE)
Text(text = "版本名称: " + BuildConfig.VERSION_NAME)
Text(text = "构建时间: " + BuildConfig.BUILD_DATE)
Text(text = "构建类型: " + BuildConfig.BUILD_TYPE)
}
}
)
}

View File

@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.GenericShape
import androidx.compose.material.Switch
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -30,82 +31,69 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.google.accompanist.placeholder.PlaceholderHighlight
import com.google.accompanist.placeholder.material.fade
import com.google.accompanist.placeholder.material.placeholder
import kotlinx.coroutines.delay
import com.ramcosta.composedestinations.annotation.Destination
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.db.table.SubsConfig
import li.songe.gkd.db.util.Operator.eq
import li.songe.gkd.db.util.RoomX
import li.songe.gkd.ui.component.StatusBar
import li.songe.gkd.util.ThrottleState
import li.songe.router.LocalRoute
import li.songe.router.LocalRouter
import li.songe.router.Page
data class AppItemPageParams(
val subsApp: SubscriptionRaw.AppRaw,
val subsConfig: SubsConfig,
val appName: String,
)
val AppItemPage = Page {
// https://developer.android.com/jetpack/compose/modifiers-list
val router = LocalRouter.current
val params = LocalRoute.current.data as AppItemPageParams
import li.songe.gkd.data.getAppInfo
import li.songe.gkd.db.DbSet
import li.songe.gkd.utils.Singleton
import li.songe.gkd.utils.launchAsFn
@RootNavGraph
@Destination
@Composable
fun AppItemPage(
subsApp: SubscriptionRaw.AppRaw,
subsConfig: SubsConfig,
) {
val scope = rememberCoroutineScope()
// val context = LocalContext.current
var subsConfigList: List<SubsConfig?>? by remember { mutableStateOf(null) }
val changeItemThrottle = ThrottleState.use(scope)
var subsConfigs: List<SubsConfig?>? by remember { mutableStateOf(null) }
LaunchedEffect(Unit) {
delay(400)
val config = params.subsConfig
val mutableSet =
RoomX.select { (SubsConfig::type eq SubsConfig.GroupType) and (SubsConfig::subsItemId eq config.subsItemId) and (SubsConfig::appId eq config.appId) }
.toMutableSet()
val mutableSet = DbSet.subsConfigDao.queryGroupTypeConfig(subsConfig.subsItemId, subsApp.id)
val list = mutableListOf<SubsConfig?>()
params.subsApp.groups.forEach { group ->
subsApp.groups.forEach { group ->
if (group.key == null) {
list.add(null)
} else {
val item = mutableSet.find { s -> s.groupKey == group.key } ?: SubsConfig(
subsItemId = config.subsItemId,
appId = config.appId,
val item = mutableSet.find { s -> s.groupKey == group.key }
?: SubsConfig(
subsItemId = subsConfig.subsItemId,
appId = subsConfig.appId,
groupKey = group.key,
type = SubsConfig.GroupType
)
list.add(item)
}
}
subsConfigList = list
subsConfigs = list
}
var showGroupItem: SubscriptionRaw.GroupRaw? by remember { mutableStateOf(null) }
LazyColumn(modifier = Modifier.fillMaxSize()) {
item {
Column {
StatusBar()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp, 0.dp)
) {
Text(
text = params.appName,
text = getAppInfo(subsApp.id).name ?: "-",
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = params.subsApp.id,
text = subsApp.id,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
@ -113,20 +101,13 @@ val AppItemPage = Page {
}
Spacer(modifier = Modifier.height(10.dp))
}
}
items(params.subsApp.groups.size) { i ->
val group = params.subsApp.groups[i]
items(subsApp.groups.size) { i ->
val group = subsApp.groups[i]
Row(
modifier = Modifier
.clickable {
// router.navigate(
// GroupItemPage, GroupItemPage.Params(
// group = group,
// subsConfig = subsConfigList?.get(i),
// appName = params.appName
// )
// )
showGroupItem = group
}
.padding(10.dp, 6.dp)
.fillMaxWidth()
@ -149,7 +130,7 @@ val AppItemPage = Page {
.fillMaxWidth()
)
Text(
text = group.activityIds?.joinToString() ?: "",
text = group.desc ?: "-",
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
@ -163,10 +144,10 @@ val AppItemPage = Page {
if (group.key != null) {
val crPx = with(LocalDensity.current) { 4.dp.toPx() }
Switch(
checked = subsConfigList?.get(i)?.enable ?: true,
checked = subsConfigs?.get(i)?.enable != false,
modifier = Modifier
.placeholder(
subsConfigList == null,
subsConfigs == null,
highlight = PlaceholderHighlight.fade(),
shape = GenericShape { size, _ ->
val cr = CornerRadius(crPx, crPx)
@ -184,16 +165,12 @@ val AppItemPage = Page {
)
}
),
// 当 onCheckedChange 是 null 时, size 是长方形, 反之是 正方形
onCheckedChange = changeItemThrottle.invoke { enable ->
val list = subsConfigList ?: return@invoke
val newItem = list[i]?.copy(enable = enable) ?: return@invoke
if (newItem.id == 0L) {
RoomX.insert(newItem)
} else {
RoomX.update(newItem)
}
subsConfigList = list.toMutableList().apply {
onCheckedChange = scope.launchAsFn { enable ->
val subsConfigsVal = subsConfigs ?: return@launchAsFn
val newItem =
subsConfigsVal[i]?.copy(enable = enable) ?: return@launchAsFn
DbSet.subsConfigDao.insert(newItem)
subsConfigs = subsConfigsVal.toMutableList().apply {
set(i, newItem)
}
}
@ -210,5 +187,15 @@ val AppItemPage = Page {
}
}
}
showGroupItem?.let { showGroupItemVal ->
Dialog(onDismissRequest = { showGroupItem = null }) {
Text(
text = Singleton.json.encodeToString(showGroupItemVal),
modifier = Modifier.width(400.dp)
)
}
}
}

View File

@ -1,151 +0,0 @@
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.provider.Settings
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Text
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.blankj.utilcode.util.ToastUtils
import com.dylanc.activityresult.launcher.launchForResult
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import li.songe.gkd.accessibility.GkdAbService
import li.songe.gkd.debug.FloatingService
import li.songe.gkd.debug.ScreenshotService
import li.songe.gkd.debug.HttpService
import li.songe.gkd.ui.component.StatusBar
import li.songe.gkd.ui.component.TextSwitch
import li.songe.gkd.util.Ext
import li.songe.gkd.util.Ext.LocalLauncher
import li.songe.gkd.util.Ext.usePollState
import li.songe.gkd.util.Storage
import li.songe.router.Page
val DebugPage = Page {
val context = LocalContext.current as ComponentActivity
val launcher = LocalLauncher.current
val scope = rememberCoroutineScope()
val httpServerRunning by usePollState { HttpService.isRunning() }
val screenshotRunning by usePollState { ScreenshotService.isRunning() }
val gkdAccessRunning by usePollState { GkdAbService.isRunning() }
val floatingRunning by usePollState {
FloatingService.isRunning() && Settings.canDrawOverlays(
context
)
}
val debugAvailable by remember {
derivedStateOf { httpServerRunning }
}
val serverUrl by remember {
derivedStateOf {
if (debugAvailable) {
Ext.getIpAddressInLocalNetwork()
.map { host -> "http://${host}:${Storage.settings.httpServerPort}" }
.joinToString("\n")
} else {
null
}
}
}
Column(
modifier = Modifier
.verticalScroll(
state = rememberScrollState()
)
.padding(20.dp)
) {
StatusBar()
Text("调试模式需要WIFI和另一台设备\n您可以一台设备开热点,另一台设备连入\n满足以上外部条件后, 本机需要开启以下服务")
TextSwitch("HTTP服务(需WIFI)", httpServerRunning) {
if (it) {
HttpService.start()
} else {
HttpService.stop()
}
}
TextSwitch("截屏服务", screenshotRunning) {
if (it) {
scope.launch {
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()
}
}
TextSwitch("无障碍服务", gkdAccessRunning) {
if (it) {
scope.launch {
ToastUtils.showShort("请先启动无障碍服务")
delay(500)
val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)
}
} else {
ToastUtils.showLong("无障碍服务不可在调试模式中关闭")
}
}
TextSwitch("悬浮窗服务", 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)
}
}
if (debugAvailable && serverUrl != null) {
Text("调试模式可用, 请使用同一局域网的另一台设备打开链接")
SelectionContainer {
Text("长按可复制: " + serverUrl!!)
}
}
}
}

View File

@ -0,0 +1,50 @@
package li.songe.gkd.ui
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.activity.ComponentActivity
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
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.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import com.blankj.utilcode.util.ToastUtils
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.withContext
import li.songe.gkd.utils.LaunchedEffectTry
@RootNavGraph
@Destination
@Composable
fun ImagePreviewPage(
filePath: String?
) {
val context = LocalContext.current as ComponentActivity
val scope = rememberCoroutineScope()
var bitmap by remember {
mutableStateOf<Bitmap?>(null)
}
LaunchedEffectTry {
if (filePath != null) {
bitmap = withContext(IO) { BitmapFactory.decodeFile(filePath) }
} else {
ToastUtils.showShort("图片路径缺失")
}
}
bitmap?.let { bitmapVal ->
Image(
bitmap = bitmapVal.asImageBitmap(),
contentDescription = null,
Modifier.fillMaxWidth()
)
}
}

View File

@ -0,0 +1,13 @@
package li.songe.gkd.ui
import androidx.compose.runtime.Composable
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import li.songe.gkd.db.DbSet
@RootNavGraph
@Destination
@Composable
fun RecordPage() {
DbSet.triggerLogDao
}

View File

@ -0,0 +1,163 @@
package li.songe.gkd.ui
import android.content.Intent
import androidx.activity.ComponentActivity
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.content.FileProvider
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import li.songe.gkd.data.Snapshot
import li.songe.gkd.db.DbSet
import li.songe.gkd.debug.SnapshotExt
import li.songe.gkd.ui.component.StatusBar
import li.songe.gkd.utils.launchAsFn
import li.songe.gkd.utils.Singleton
@RootNavGraph
@Destination
@Composable
fun SnapshotPage() {
val context = LocalContext.current as ComponentActivity
val scope = rememberCoroutineScope()
var snapshots by remember {
mutableStateOf(listOf<Snapshot>())
}
var selectedSnapshot by remember {
mutableStateOf<Snapshot?>(null)
}
LaunchedEffect(Unit) {
DbSet.snapshotDao.query().flowOn(Dispatchers.IO).collect {
snapshots = it.reversed()
}
}
LazyColumn(
modifier = Modifier.padding(10.dp, 0.dp, 10.dp, 0.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item {
Text(text = "存在 ${snapshots.size} 条快照记录")
}
items(snapshots.size) { i ->
Column(
modifier = Modifier
.fillMaxWidth()
.border(BorderStroke(1.dp, Color.Black))
.clickable {
selectedSnapshot = snapshots[i]
}
) {
Row {
Text(
text = Singleton.simpleDateFormat.format(snapshots[i].id),
fontFamily = FontFamily.Monospace
)
Spacer(modifier = Modifier.width(10.dp))
Text(text = snapshots[i].appName ?: "")
}
Spacer(modifier = Modifier.width(10.dp))
Text(text = snapshots[i].appId ?: "")
Spacer(modifier = Modifier.width(10.dp))
Text(text = snapshots[i].activityId ?: "")
}
}
item {
Spacer(modifier = Modifier.height(10.dp))
}
}
selectedSnapshot?.let { snapshot ->
Dialog(
onDismissRequest = { selectedSnapshot = null }
) {
Box(
Modifier
.width(200.dp)
.background(Color.White)
.padding(8.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
val modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
Text(
text = "查看", modifier = Modifier
.clickable(onClick = scope.launchAsFn {
// router.navigate(
// ImagePreviewPage,
// SnapshotExt.getScreenshotPath(snapshot.id)
// )
selectedSnapshot = null
})
.then(modifier)
)
Text(
text = "分享", modifier = Modifier
.clickable(onClick = scope.launchAsFn {
val zipFile = SnapshotExt.getSnapshotZipFile(snapshot.id)
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
zipFile
)
val intent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_STREAM, uri)
type = "application/zip"
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(Intent.createChooser(intent, "分享zip文件"))
})
.then(modifier)
)
Text(
text = "删除", modifier = Modifier
.clickable(onClick = scope.launchAsFn {
DbSet.snapshotDao.delete(snapshot)
withContext(IO) {
SnapshotExt.remove(snapshot.id)
}
selectedSnapshot = null
})
.then(modifier)
)
}
}
}
}
}

View File

@ -1,162 +1,96 @@
package li.songe.gkd.ui
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import androidx.compose.animation.AnimatedVisibility
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Composable
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.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.content.res.ResourcesCompat
import com.google.accompanist.placeholder.PlaceholderHighlight
import com.google.accompanist.placeholder.material.fade
import com.google.accompanist.placeholder.material.placeholder
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import com.ramcosta.composedestinations.navigation.navigate
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.cancellable
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.withContext
import li.songe.gkd.R
import li.songe.gkd.data.SubsConfig
import li.songe.gkd.data.SubsItem
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.db.table.SubsConfig
import li.songe.gkd.db.table.SubsItem
import li.songe.gkd.db.util.Operator.eq
import li.songe.gkd.db.util.RoomX
import li.songe.gkd.ui.component.StatusBar
import li.songe.gkd.data.getAppInfo
import li.songe.gkd.db.DbSet
import li.songe.gkd.ui.component.SubsAppCard
import li.songe.gkd.ui.component.SubsAppCardData
import li.songe.gkd.util.Ext.getApplicationInfoExt
import li.songe.gkd.util.Status
import li.songe.gkd.util.ThrottleState
import li.songe.router.LocalRoute
import li.songe.router.LocalRouter
import li.songe.router.Page
import java.io.File
val SubsPage = Page {
val router = LocalRouter.current
val subsItem = LocalRoute.current.data as SubsItem
import li.songe.gkd.ui.destinations.AppItemPageDestination
import li.songe.gkd.utils.LaunchedEffectTry
import li.songe.gkd.utils.LocalNavController
import li.songe.gkd.utils.launchAsFn
import li.songe.gkd.utils.rememberCache
import li.songe.gkd.utils.useTask
@RootNavGraph
@Destination
@Composable
fun SubsPage(
subsItem: SubsItem
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val navController = LocalNavController.current
var sub: SubscriptionRaw? by remember { mutableStateOf(null) }
var subStatus: Status<SubscriptionRaw> by remember { mutableStateOf(Status.Progress()) }
var subsAppCardDataList: List<SubsAppCardData>? by remember { mutableStateOf(null) }
val placeholderList: List<SubsAppCardData> = remember {
mutableListOf<SubsAppCardData>().apply {
repeat(5) {
add(
SubsAppCardData(
appName = "" + it,
icon = ResourcesCompat.getDrawable(
context.resources,
R.drawable.ic_app_2,
context.theme
)!!,
subsConfig = SubsConfig(
subsItemId = it.toLong(),
appId = "" + it
)
)
)
}
}
}
var subsAppCardDataListStatus: Status<List<SubsAppCardData>> by remember {
mutableStateOf(Status.Progress())
}
var sub: SubscriptionRaw? by rememberCache { mutableStateOf(null) }
var subsAppCards: List<SubsAppCardData>? by rememberCache { mutableStateOf(null) }
val changeItemThrottle = ThrottleState.use(scope)
LaunchedEffect(Unit) {
val st = System.currentTimeMillis()
val file = File(subsItem.filePath)
if (!(file.exists() && file.isFile)) {
subStatus = Status.Error("在本地存储没有找到订阅文件")
return@LaunchedEffect
LaunchedEffectTry(Unit) {
scope.launchAsFn { }
val newSub = if (sub === null) {
SubscriptionRaw.parse5(subsItem.subsFile.readText()).apply {
withContext(IO) {
apps.forEach {
getAppInfo(it.id)
}
val rawText = try {
withContext(IO) { file.readText() }
} catch (e: Exception) {
subStatus = Status.Error("读取文件失败:$e")
return@LaunchedEffect
}
val newSub = try {
SubscriptionRaw.parse5(rawText)
} catch (e: Exception) {
subStatus = Status.Error("序列化失败:$e")
return@LaunchedEffect
}
subStatus = Status.Success(newSub)
val mutableSet =
RoomX.select { (SubsConfig::type eq SubsConfig.AppType) and (SubsConfig::subsItemId eq subsItem.id) }
.toMutableSet()
val packageManager = context.packageManager
val defaultIcon = ResourcesCompat.getDrawable(
context.resources,
R.drawable.ic_app_2,
context.theme
)!!
val defaultName = "-"
val newSubsAppCardDataList = (subStatus as Status.Success).value.apps.map { appRaw ->
} else {
sub!!
}
sub = newSub
DbSet.subsConfigDao.queryAppTypeConfig(subsItem.id).flowOn(IO).cancellable().collect {
val mutableSet = it.toMutableSet()
val newSubsAppCards = newSub.apps.map { appRaw ->
mutableSet.firstOrNull { v ->
v.appId == appRaw.id
}.apply {
if (this != null) {
mutableSet.remove(this)
}
} ?: SubsConfig(
subsItemId = subsItem.id,
appId = appRaw.id,
type = SubsConfig.AppType
)
}.map { subsConfig ->
async(IO) {
val info: ApplicationInfo = try {
packageManager.getApplicationInfoExt(
subsConfig.appId,
PackageManager.GET_META_DATA
)
} catch (e: Exception) {
return@async SubsAppCardData(
defaultName,
defaultIcon,
subsConfig
}.mapIndexed { index, subsConfig ->
SubsAppCardData(
subsConfig,
newSub.apps[index]
)
}
return@async SubsAppCardData(
packageManager.getApplicationLabel(info).toString(),
packageManager.getApplicationIcon(info),
subsConfig
)
subsAppCards = newSubsAppCards
}
}.awaitAll()
subsAppCardDataListStatus = Status.Success(newSubsAppCardDataList)
delay(400 - (System.currentTimeMillis() - st))
sub = newSub
subsAppCardDataList = newSubsAppCardDataList
}
val openAppPage = scope.useTask().launchAsFn<SubsAppCardData> {
navController.navigate(AppItemPageDestination(it.appRaw, it.subsConfig))
}
LazyColumn(
@ -165,8 +99,6 @@ val SubsPage = Page {
.fillMaxSize()
) {
item {
Column {
StatusBar()
val textModifier = Modifier
.fillMaxWidth()
.placeholder(visible = sub == null, highlight = PlaceholderHighlight.fade())
@ -188,90 +120,37 @@ val SubsPage = Page {
)
}
}
}
subsAppCardDataList?.let { cardDataList ->
items(cardDataList.size) { i ->
AnimatedVisibility(visible = true) {
Box(modifier = Modifier
.wrapContentSize()
.clickable {
router.navigate(
AppItemPage,
AppItemPageParams(
sub?.apps?.get(i)!!,
cardDataList[i].subsConfig,
cardDataList[i].appName
)
)
}) {
subsAppCards?.let { subsAppCardsVal ->
items(subsAppCardsVal.size) { i ->
SubsAppCard(
sub = cardDataList[i],
onValueChange = changeItemThrottle.invoke { enable ->
val newItem = cardDataList[i].subsConfig.copy(
sub = subsAppCardsVal[i],
onClick = {
openAppPage(subsAppCardsVal[i])
},
onValueChange = scope.launchAsFn { enable ->
val newItem = subsAppCardsVal[i].subsConfig.copy(
enable = enable
)
if (newItem.id == 0L) {
RoomX.insert(newItem)
} else {
RoomX.update(newItem)
}
subsAppCardDataList = cardDataList.toMutableList().apply {
set(i, cardDataList[i].copy(subsConfig = newItem))
}
DbSet.subsConfigDao.insert(newItem)
}
)
}
}
}
}
if (subsAppCardDataList == null) {
items(placeholderList.size) { i ->
Box(
modifier = Modifier
.wrapContentSize()
) {
SubsAppCard(loading = true, sub = placeholderList[i])
Text(text = "")
}
}
}
// if (subsAppCards == null) {
// items(placeholderList.size) { i ->
// Box(
// modifier = Modifier
// .wrapContentSize()
// ) {
// SubsAppCard(loading = true, sub = placeholderList[i])
// Text(text = "")
// }
// }
// }
item(true) {
Spacer(modifier = Modifier.height(10.dp))
}
}
if (subStatus !is Status.Success || subsAppCardDataListStatus !is Status.Success) {
when (val s = subStatus) {
is Status.Success -> {
when (val s2 = subsAppCardDataListStatus) {
is Status.Error -> {
Dialog(onDismissRequest = { router.back() }) {
Text(text = s2.value.toString())
}
}
is Status.Progress -> {
}
else -> {}
}
}
is Status.Error -> {
Dialog(onDismissRequest = { router.back() }) {
Text(text = s.value.toString())
}
}
is Status.Progress -> {
}
else -> {}
}
}
}

View File

@ -0,0 +1,32 @@
package li.songe.gkd.ui.component
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun SnapshotCard() {
Row {
Text(text = "06-02 20:47:48")
Spacer(modifier = Modifier.size(10.dp))
Text(text = "酷安")
Spacer(modifier = Modifier.size(10.dp))
Text(text = "查看")
Spacer(modifier = Modifier.size(10.dp))
Text(text = "分享")
Spacer(modifier = Modifier.size(10.dp))
Text(text = "删除")
}
}
@Preview
@Composable
fun PreviewSnapshotCard() {
SnapshotCard()
}

View File

@ -12,7 +12,7 @@ import com.blankj.utilcode.util.BarUtils
import com.blankj.utilcode.util.ConvertUtils
@Composable
fun StatusBar(color: Color = Color.White) {
fun StatusBar(color: Color = Color.Transparent) {
Spacer(
modifier = Modifier
.height(statusBarHeight)

View File

@ -2,6 +2,7 @@ package li.songe.gkd.ui.component
import android.graphics.drawable.Drawable
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -18,31 +19,43 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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 com.google.accompanist.placeholder.PlaceholderHighlight
import com.google.accompanist.placeholder.material.fade
import com.google.accompanist.placeholder.material.placeholder
import li.songe.gkd.db.table.SubsConfig
import li.songe.gkd.R
import li.songe.gkd.data.SubsConfig
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.data.getAppInfo
import li.songe.gkd.utils.SafeR
@Composable
fun SubsAppCard(
loading: Boolean = false,
sub: SubsAppCardData,
onClick: (() -> Unit)? = null,
onValueChange: ((Boolean) -> Unit)? = null
) {
val info = getAppInfo(sub.appRaw.id)
Row(
modifier = Modifier
.height(60.dp)
.clickable {
onClick?.invoke()
}
.padding(4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = rememberDrawablePainter(sub.icon),
painter = if (info.icon != null) rememberDrawablePainter(info.icon) else painterResource(
SafeR.ic_app_2
),
contentDescription = null,
modifier = Modifier
.fillMaxHeight()
@ -60,7 +73,7 @@ fun SubsAppCard(
verticalArrangement = Arrangement.SpaceBetween
) {
Text(
text = sub.appName, maxLines = 1,
text = info.name ?: "-", maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
@ -68,7 +81,7 @@ fun SubsAppCard(
.placeholder(loading, highlight = PlaceholderHighlight.fade())
)
Text(
text = sub.subsConfig.appId, maxLines = 1,
text = sub.appRaw.groups.size.toString() + "组规则", maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
@ -88,8 +101,7 @@ fun SubsAppCard(
}
data class SubsAppCardData(
val appName: String,
val icon: Drawable,
val subsConfig: SubsConfig,
val appRaw: SubscriptionRaw.AppRaw
)

View File

@ -21,15 +21,14 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import li.songe.gkd.R
import li.songe.gkd.db.table.SubsItem
import li.songe.gkd.util.Singleton
import li.songe.gkd.data.SubsItem
import li.songe.gkd.utils.SafeR
import li.songe.gkd.utils.Singleton
@Composable
fun SubsItemCard(
subsItem: SubsItem,
onShareClick: (() -> Unit)? = null,
onEditClick: (() -> Unit)? = null,
onDelClick: (() -> Unit)? = null,
onRefreshClick: (() -> Unit)? = null,
) {
@ -49,16 +48,25 @@ fun SubsItemCard(
softWrap = false,
overflow = TextOverflow.Ellipsis
)
Row {
Text(
text = dateStr,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = "版本:" + subsItem.version,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis
)
}
}
Spacer(modifier = Modifier.width(5.dp))
Image(
painter = painterResource(R.drawable.ic_refresh),
painter = painterResource(SafeR.ic_refresh),
contentDescription = "refresh",
modifier = Modifier
.clickable {
@ -69,7 +77,7 @@ fun SubsItemCard(
)
Spacer(modifier = Modifier.width(5.dp))
Image(
painter = painterResource(R.drawable.ic_share),
painter = painterResource(SafeR.ic_share),
contentDescription = "share",
modifier = Modifier
.clickable {
@ -80,18 +88,7 @@ fun SubsItemCard(
)
Spacer(modifier = Modifier.width(5.dp))
Image(
painter = painterResource(R.drawable.ic_create_round),
contentDescription = "edit",
modifier = Modifier
.clickable {
onEditClick?.invoke()
}
.padding(4.dp)
.size(20.dp)
)
Spacer(modifier = Modifier.width(5.dp))
Image(
painter = painterResource(R.drawable.ic_del),
painter = painterResource(SafeR.ic_del),
contentDescription = "edit",
modifier = Modifier
.clickable {
@ -109,7 +106,6 @@ fun PreviewSubscriptionItemCard() {
Surface(modifier = Modifier.width(300.dp)) {
SubsItemCard(
SubsItem(
filePath = "filepath",
updateUrl = "https://raw.githubusercontents.com/lisonge/gkd-subscription/main/src/ad-startup.gkd.json",
name = "APP工具箱"
)

View File

@ -1,33 +1,41 @@
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.width
import androidx.compose.material.Surface
import androidx.compose.material.Switch
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun TextSwitch(
text: String,
checked: Boolean,
name: String = "",
desc: String = "",
checked: Boolean = true,
onCheckedChange: ((Boolean) -> Unit)? = null,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
val animatedColor = (
Color(
0,
0,
0,
(0xFF * (if (checked) 1f else .3f)).toInt()
)
)
Column(modifier = Modifier.weight(1f)) {
Text(
text,
color = animatedColor
name,
fontSize = 18.sp
)
Spacer(modifier = Modifier.height(2.dp))
Text(
desc,
fontSize = 14.sp
)
}
Spacer(modifier = Modifier.width(10.dp))
Switch(
checked,
onCheckedChange,
@ -38,7 +46,7 @@ fun TextSwitch(
@Preview
@Composable
fun PreviewTextSwitch() {
Surface {
TextSwitch("text", true)
Surface(modifier = Modifier.width(300.dp)) {
TextSwitch("隐藏后台", "在最近任务列表中隐藏", true)
}
}

View File

@ -2,6 +2,7 @@ package li.songe.gkd.ui.home
import androidx.annotation.DrawableRes
import li.songe.gkd.R
import li.songe.gkd.utils.SafeR
data class BottomNavItem(
val label: String,
@ -13,12 +14,12 @@ data class BottomNavItem(
val BottomNavItems = listOf(
BottomNavItem(
label = "订阅",
icon = R.drawable.ic_link,
icon = SafeR.ic_link,
route = "subscription"
),
BottomNavItem(
label = "设置",
icon = R.drawable.ic_cog,
icon = SafeR.ic_cog,
route = "settings"
),
)

View File

@ -1,47 +1,33 @@
package li.songe.gkd.ui.home
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.draw.alpha
import androidx.compose.ui.zIndex
import li.songe.gkd.ui.component.StatusBar
import li.songe.gkd.util.ModifierExt.noRippleClickable
import li.songe.router.Page
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import li.songe.gkd.utils.LocalStateCache
import li.songe.gkd.utils.StateCache
import li.songe.gkd.utils.rememberCache
val HomePage = Page {
var tabInt by remember { mutableStateOf(0) }
Column(modifier = Modifier.fillMaxSize()) {
StatusBar()
Scaffold(
bottomBar = { BottomNavigationBar(tabInt) { tabInt = it } },
content = { padding ->
@RootNavGraph(start = true)
@Destination
@Composable
fun HomePage() {
var tabIndex by rememberCache { mutableStateOf(0) }
val subsStateCache = rememberCache { StateCache() }
val settingStateCache = rememberCache { StateCache() }
Scaffold(bottomBar = { BottomNavigationBar(tabIndex) { tabIndex = it } }, content = { padding ->
Box(modifier = Modifier.padding(padding)) {
Box(
modifier = Modifier
.fillMaxSize()
.alpha(if (tabInt == 0) 1f else 0f)
.zIndex(if (tabInt == 0) 1f else 0f)
.noRippleClickable { }) {
SubscriptionManagePage()
}
Box(
modifier = Modifier
.fillMaxSize()
.alpha(if (tabInt == 1) 1f else 0f)
.zIndex(if (tabInt == 1) 1f else 0f)
.noRippleClickable { }) {
SettingsPage()
when (tabIndex) {
0 -> CompositionLocalProvider(LocalStateCache provides subsStateCache) { SubscriptionManagePage() }
1 -> CompositionLocalProvider(LocalStateCache provides settingStateCache) { SettingsPage() }
}
}
}
)
}
})
}

View File

@ -17,6 +17,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import li.songe.gkd.R
import li.songe.gkd.utils.SafeR
@Composable
fun NativePage() {
@ -28,7 +29,7 @@ fun NativePage() {
modifier = Modifier.height(40.dp)
) {
Image(
painter = painterResource(R.drawable.ic_app_2),
painter = painterResource(SafeR.ic_app_2),
contentDescription = "",
modifier = Modifier
.fillMaxHeight()

View File

@ -1,5 +1,6 @@
package li.songe.gkd.ui.home
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.material.BottomNavigation
import androidx.compose.material.BottomNavigationItem
@ -43,11 +44,13 @@ import androidx.compose.ui.unit.dp
@Composable
fun BottomNavigationBar(tabInt: Int, onTabChange: ((Int) -> Unit)? = null) {
BottomNavigation(
backgroundColor = Color.White,
backgroundColor = Color.Transparent,
elevation = 0.dp
) {
BottomNavItems.forEachIndexed { i, navItem ->
BottomNavigationItem(
selected = i == tabInt,
modifier = Modifier.background(Color.Transparent),
onClick = {
onTabChange?.invoke(i)
},

View File

@ -1,6 +1,16 @@
package li.songe.gkd.ui.home
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.media.projection.MediaProjectionManager
import android.net.Uri
import android.provider.Settings
import androidx.activity.ComponentActivity
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.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@ -10,21 +20,43 @@ import androidx.compose.runtime.Composable
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.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.blankj.utilcode.util.LogUtils
import com.blankj.utilcode.util.ToastUtils
import com.dylanc.activityresult.launcher.launchForResult
import kotlinx.coroutines.delay
import li.songe.gkd.MainActivity
import li.songe.gkd.R
import li.songe.gkd.ui.DebugPage
import li.songe.gkd.accessibility.GkdAbService
import li.songe.gkd.debug.FloatingService
import li.songe.gkd.debug.HttpService
import li.songe.gkd.debug.ScreenshotService
import li.songe.gkd.ui.component.TextSwitch
import li.songe.gkd.util.LocaleString.Companion.localeString
import li.songe.gkd.util.Storage
import li.songe.router.LocalRouter
import li.songe.gkd.utils.Ext
import li.songe.gkd.utils.LocalLauncher
import li.songe.gkd.utils.LocalNavController
import li.songe.gkd.utils.Storage
import li.songe.gkd.utils.launchAsFn
import li.songe.gkd.utils.usePollState
import li.songe.gkd.utils.useTask
import rikka.shizuku.Shizuku
import com.ramcosta.composedestinations.navigation.navigate
import li.songe.gkd.shizuku.shizukuIsSafeOK
import li.songe.gkd.ui.destinations.AboutPageDestination
import li.songe.gkd.ui.destinations.SnapshotPageDestination
@Composable
fun SettingsPage() {
val context = LocalContext.current as MainActivity
val launcher = LocalLauncher.current
val scope = rememberCoroutineScope()
val navController = LocalNavController.current
Column(
modifier = Modifier
.verticalScroll(
@ -32,37 +64,154 @@ fun SettingsPage() {
)
.padding(20.dp, 0.dp)
) {
val gkdAccessRunning by usePollState { GkdAbService.isRunning() }
TextSwitch("无障碍授权",
"用于获取屏幕信息,点击屏幕上的控件",
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)
})
val context = LocalContext.current as MainActivity
// val composableScope = rememberCoroutineScope()
val router = LocalRouter.current
val shizukuIsOk by usePollState { shizukuIsSafeOK() }
TextSwitch("Shizuku授权",
"高级运行模式,能更准确识别界面活动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("悬浮窗授权",
"用于后台提示,主动保存快照等功能",
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)
}
}
})
Spacer(modifier = Modifier.height(15.dp))
val httpServerRunning by usePollState { HttpService.isRunning() }
TextSwitch("HTTP服务",
"开启HTTP服务, 以便在同一局域网下传递数据" + if (httpServerRunning) "\n${
Ext.getIpAddressInLocalNetwork()
.map { host -> "http://${host}:${Storage.settings.httpServerPort}" }
.joinToString(",")
}" else "\n暂无地址",
httpServerRunning) {
if (it) {
HttpService.start()
} else {
HttpService.stop()
}
}
Spacer(modifier = Modifier.height(5.dp))
val screenshotRunning by usePollState { ScreenshotService.isRunning() }
TextSwitch("截屏服务",
"生成快照需要截取屏幕,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()
}
})
Spacer(modifier = Modifier.height(5.dp))
val floatingRunning by usePollState {
FloatingService.isRunning()
}
TextSwitch("悬浮窗服务", "便于用户主动保存快照", 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)
}
}
Spacer(modifier = Modifier.height(15.dp))
var enableService by remember { mutableStateOf(Storage.settings.enableService) }
TextSwitch(
text = "保持服务${(if (enableService) "开启" else "关闭")}",
Spacer(modifier = Modifier.height(5.dp))
TextSwitch(name = "服务开启",
desc = "保持服务开启,根据订阅规则匹配屏幕目标节点",
checked = enableService,
onCheckedChange = {
enableService = it
Storage.settings.commit {
this.enableService = it
}
}
)
})
Spacer(modifier = Modifier.height(5.dp))
var excludeFromRecents by remember { mutableStateOf(Storage.settings.excludeFromRecents) }
TextSwitch(
text = "在[最近任务]界面中隐藏本应用",
TextSwitch(name = "隐藏后台",
desc = "在[最近任务]界面中隐藏本应用",
checked = excludeFromRecents,
onCheckedChange = {
excludeFromRecents = it
Storage.settings.commit {
this.excludeFromRecents = it
}
}
)
})
Spacer(modifier = Modifier.height(5.dp))
var enableConsoleLogOut by remember { mutableStateOf(Storage.settings.enableConsoleLogOut) }
TextSwitch(
text = "保持日志输出到控制台",
TextSwitch(name = "日志输出",
desc = "保持日志输出到控制台",
checked = enableConsoleLogOut,
onCheckedChange = {
enableConsoleLogOut = it
@ -70,24 +219,49 @@ fun SettingsPage() {
Storage.settings.commit {
this.enableConsoleLogOut = it
}
}
)
})
Spacer(modifier = Modifier.height(5.dp))
var notificationVisible by remember { mutableStateOf(Storage.settings.notificationVisible) }
TextSwitch(text = "通知栏显示", checked = notificationVisible,
TextSwitch(name = "通知栏显示",
desc = "通知栏显示可以降低系统杀后台的概率",
checked = notificationVisible,
onCheckedChange = {
notificationVisible = it
Storage.settings.commit {
this.notificationVisible = it
}
})
Spacer(modifier = Modifier.height(5.dp))
Button(onClick = {
router.navigate(DebugPage)
}) {
Text(text = "调试模式")
var enableScreenshot by remember {
mutableStateOf(Storage.settings.enableCaptureSystemScreenshot)
}
Spacer(modifier = Modifier.height(5.dp))
TextSwitch(
"自动快照", "当用户截屏时,自动保存当前界面的快照,目前仅支持miui", enableScreenshot
) {
enableScreenshot = it
Storage.settings.commit {
enableCaptureSystemScreenshot = it
}
}
Spacer(modifier = Modifier.height(5.dp))
Button(onClick = scope.useTask().launchAsFn {
navController.navigate(SnapshotPageDestination)
}) {
Text(text = "查看快照记录")
}
Spacer(modifier = Modifier.height(5.dp))
Button(onClick = scope.useTask().launchAsFn {
navController.navigate(AboutPageDestination)
}) {
Text(text = "查看关于")
}
Text(text = "多语言自动切换:" + localeString(R.string.app_name))
}
}

View File

@ -1,5 +1,6 @@
package li.songe.gkd.ui.home
import android.webkit.URLUtil
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@ -19,59 +20,127 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.blankj.utilcode.util.ClipboardUtils
import com.blankj.utilcode.util.PathUtils
import com.blankj.utilcode.util.ToastUtils
import com.google.zxing.BarcodeFormat
import com.ramcosta.composedestinations.navigation.navigate
import io.ktor.client.request.*
import io.ktor.client.statement.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import li.songe.gkd.R
import li.songe.gkd.data.SubsItem
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.db.table.SubsConfig
import li.songe.gkd.db.table.SubsItem
import li.songe.gkd.db.util.Operator.eq
import li.songe.gkd.db.util.RoomX
import li.songe.gkd.hooks.useNavigateForQrcodeResult
import li.songe.gkd.ui.SubsPage
import li.songe.gkd.db.DbSet
import li.songe.gkd.ui.component.SubsItemCard
import li.songe.gkd.util.Ext.launchTry
import li.songe.gkd.util.Singleton
import li.songe.gkd.util.ThrottleState
import li.songe.router.LocalRouter
import java.io.File
import li.songe.gkd.ui.destinations.SubsPageDestination
import li.songe.gkd.utils.LaunchedEffectTry
import li.songe.gkd.utils.LocalNavController
import li.songe.gkd.utils.SafeR
import li.songe.gkd.utils.Singleton
import li.songe.gkd.utils.launchAsFn
import li.songe.gkd.utils.rememberCache
import li.songe.gkd.utils.useNavigateForQrcodeResult
import li.songe.gkd.utils.useTask
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SubscriptionManagePage() {
val scope = rememberCoroutineScope()
val router = LocalRouter.current
val navController = LocalNavController.current
var subItemList by remember { mutableStateOf(listOf<SubsItem>()) }
var shareSubItem: SubsItem? by remember { mutableStateOf(null) }
var shareQrcode: ImageBitmap? by remember { mutableStateOf(null) }
var deleteSubItem: SubsItem? by remember { mutableStateOf(null) }
var subItems by rememberCache { mutableStateOf(listOf<SubsItem>()) }
var shareSubItem: SubsItem? by rememberCache { mutableStateOf(null) }
var shareQrcode: ImageBitmap? by rememberCache { mutableStateOf(null) }
var deleteSubItem: SubsItem? by rememberCache { mutableStateOf(null) }
var moveSubItem: SubsItem? by rememberCache { mutableStateOf(null) }
var showAddDialog by rememberCache { mutableStateOf(false) }
var showLinkDialog by rememberCache { mutableStateOf(false) }
var link by rememberCache { mutableStateOf("") }
var showAddDialog by remember { mutableStateOf(false) }
var showLinkInputDialog by remember { mutableStateOf(false) }
val viewSubItemThrottle = ThrottleState.use(scope)
val editSubItemThrottle = ThrottleState.use(scope)
val refreshSubItemThrottle = ThrottleState.use(scope, 250)
val navigateForQrcodeResult = useNavigateForQrcodeResult()
var linkText by remember {
mutableStateOf("")
LaunchedEffectTry(Unit) {
DbSet.subsItemDao.query().flowOn(IO).collect {
subItems = it
}
}
LaunchedEffect(Unit) {
subItemList = RoomX.select<SubsItem>().sortedBy { it.index }
val addSubs = scope.useTask(dialog = true).launchAsFn<List<String>> { urls ->
val safeUrls = urls.filter { url ->
URLUtil.isNetworkUrl(url) && subItems.all { it.updateUrl != url }
}
if (safeUrls.isEmpty()) return@launchAsFn
onChangeLoading(true)
val newItems = safeUrls.mapIndexedNotNull { index, url ->
try {
val text = Singleton.client.get(url).bodyAsText()
val subscriptionRaw = SubscriptionRaw.parse5(text)
val newItem = SubsItem(
updateUrl = subscriptionRaw.updateUrl ?: url,
name = subscriptionRaw.name,
version = subscriptionRaw.version,
order = index + 1 + subItems.size
)
withContext(IO) {
newItem.subsFile.writeText(text)
}
newItem
} catch (e: Exception) {
null
}
}
if (newItems.isNotEmpty()) {
DbSet.subsItemDao.insert(*newItems.toTypedArray())
ToastUtils.showShort("成功添加 ${newItems.size} 条订阅")
} else {
ToastUtils.showShort("添加失败")
}
}
val updateSubs = scope.useTask(dialog = true).launchAsFn<List<SubsItem>> { oldItems ->
if (oldItems.isEmpty()) return@launchAsFn
onChangeLoading(true)
val newItems = oldItems.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
)
)
}
newItem
} catch (e: Exception) {
ToastUtils.showShort(e.message)
null
}
}
if (newItems.isEmpty()) {
ToastUtils.showShort("暂无更新")
} else {
DbSet.subsItemDao.update(*newItems.toTypedArray())
ToastUtils.showShort("更新 ${newItems.size} 条订阅")
}
}
LazyColumn(
verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxHeight()
) {
item(subItemList) {
item(subItems) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
@ -79,11 +148,17 @@ fun SubscriptionManagePage() {
.fillMaxWidth()
.padding(10.dp, 0.dp)
) {
if (subItems.isEmpty()) {
Text(
text = "共有${subItemList.size}条订阅,激活:${subItemList.count { it.enable }},禁用:${subItemList.count { !it.enable }}",
text = "暂无订阅",
)
} else {
Text(
text = "共有${subItems.size}条订阅,激活:${subItems.count { it.enable }},禁用:${subItems.count { !it.enable }}",
)
}
Row {
Image(painter = painterResource(R.drawable.ic_add),
Image(painter = painterResource(SafeR.ic_add),
contentDescription = "",
modifier = Modifier
.clickable {
@ -91,98 +166,51 @@ fun SubscriptionManagePage() {
}
.padding(4.dp)
.size(25.dp))
Image(painter = painterResource(R.drawable.ic_refresh),
Image(
painter = painterResource(SafeR.ic_refresh),
contentDescription = "",
modifier = Modifier
.clickable {
scope.launchTry {
subItemList.mapIndexed { i, oldItem ->
val subscriptionRaw = SubscriptionRaw.parse5(
Singleton.client
.get(oldItem.updateUrl)
.bodyAsText()
)
if (subscriptionRaw.version <= oldItem.version) {
ToastUtils.showShort("暂无更新:${oldItem.name}")
return@mapIndexed
}
val newItem = oldItem.copy(
updateUrl = subscriptionRaw.updateUrl
?: oldItem.updateUrl,
name = subscriptionRaw.name,
mtime = System.currentTimeMillis(),
version = subscriptionRaw.version
)
RoomX.update(newItem)
File(newItem.filePath).writeText(
SubscriptionRaw.stringify(
subscriptionRaw
)
)
ToastUtils.showShort("更新成功:${newItem.name}")
subItemList = subItemList
.toMutableList()
.also {
it[i] = newItem
}
}
}
}
.clickable(onClick = {
updateSubs(subItems)
})
.padding(4.dp)
.size(25.dp))
.size(25.dp)
)
}
}
}
items(subItemList.size) { i ->
items(subItems.size) { i ->
Card(
modifier = Modifier
.animateItemPlacement()
.padding(vertical = 3.dp, horizontal = 8.dp)
.clickable(onClick = { router.navigate(SubsPage, subItemList[i]) }),
.combinedClickable(
onClick = scope
.useTask()
.launchAsFn {
navController.navigate(SubsPageDestination(subItems[i]))
}, onLongClick = {
if (subItems.size > 1) {
moveSubItem = subItems[i]
}
}),
elevation = 0.dp,
border = BorderStroke(1.dp, Color(0xfff6f6f6)),
shape = RoundedCornerShape(8.dp),
) {
SubsItemCard(subItemList[i], onShareClick = {
shareSubItem = subItemList[i]
}, onEditClick = editSubItemThrottle.invoke {
SubsItemCard(subItems[i], onShareClick = {
shareSubItem = subItems[i]
}, onDelClick = {
deleteSubItem = subItemList[i]
}, onRefreshClick = refreshSubItemThrottle.invoke {
val oldItem = subItemList[i]
val subscriptionRaw = SubscriptionRaw.parse5(
Singleton.client.get(oldItem.updateUrl).bodyAsText()
)
if (subscriptionRaw.version <= oldItem.version) {
ToastUtils.showShort("暂无更新:${oldItem.name}")
return@invoke
}
val newItem = oldItem.copy(
updateUrl = subscriptionRaw.updateUrl
?: oldItem.updateUrl,
name = subscriptionRaw.name,
mtime = System.currentTimeMillis(),
version = subscriptionRaw.version
)
RoomX.update(newItem)
withContext(IO) {
File(newItem.filePath).writeText(SubscriptionRaw.stringify(subscriptionRaw))
}
subItemList = subItemList.toMutableList().also {
it[i] = newItem
}
ToastUtils.showShort("更新成功:${newItem.name}")
}.catch {
if (!it.message.isNullOrEmpty()) {
ToastUtils.showShort(it.message)
}
deleteSubItem = subItems[i]
}, onRefreshClick = {
updateSubs(listOf(subItems[i]))
})
}
}
}
shareSubItem?.let { _shareSubItem ->
shareSubItem?.let { shareSubItemVal ->
Dialog(onDismissRequest = { shareSubItem = null }) {
Box(
Modifier
@ -191,26 +219,22 @@ fun SubscriptionManagePage() {
.padding(8.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = "二维码",
modifier = Modifier
Text(text = "二维码", modifier = Modifier
.clickable {
shareQrcode = Singleton.barcodeEncoder
.encodeBitmap(
_shareSubItem.updateUrl,
BarcodeFormat.QR_CODE,
500,
500
shareSubItemVal.updateUrl, BarcodeFormat.QR_CODE, 500, 500
)
.asImageBitmap()
shareSubItem = null
}
.fillMaxWidth()
.padding(8.dp))
Text(text = "导出至剪切板",
modifier = Modifier
Text(text = "导出至剪切板", modifier = Modifier
.clickable {
ClipboardUtils.copyText(_shareSubItem.updateUrl)
ClipboardUtils.copyText(shareSubItemVal.updateUrl)
shareSubItem = null
ToastUtils.showShort("复制成功")
}
.fillMaxWidth()
.padding(8.dp))
@ -219,36 +243,85 @@ fun SubscriptionManagePage() {
}
}
shareQrcode?.let { _shareQrcode ->
shareQrcode?.let { shareQrcodeVal ->
Dialog(onDismissRequest = { shareQrcode = null }) {
Image(
bitmap = _shareQrcode,
bitmap = shareQrcodeVal,
contentDescription = "qrcode",
modifier = Modifier.size(400.dp)
)
}
}
moveSubItem?.let { moveSubItemVal ->
Dialog(onDismissRequest = { moveSubItem = null }) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.width(200.dp)
.wrapContentHeight()
.background(Color.White)
.padding(8.dp)
) {
if (subItems.firstOrNull() != moveSubItemVal) {
Text(
text = "上移",
modifier = Modifier
.clickable(
onClick = scope
.useTask()
.launchAsFn {
val lastItem =
subItems[subItems.indexOf(moveSubItemVal) - 1]
DbSet.subsItemDao.update(
lastItem.copy(
order = moveSubItemVal.order
),
moveSubItemVal.copy(
order = lastItem.order
)
)
moveSubItem = null
})
.fillMaxWidth()
.padding(8.dp)
)
}
if (subItems.lastOrNull() != moveSubItemVal) {
Text(
text = "下移",
modifier = Modifier
.clickable(
onClick = scope
.useTask()
.launchAsFn {
val nextItem =
subItems[subItems.indexOf(moveSubItemVal) + 1]
DbSet.subsItemDao.update(
nextItem.copy(
order = moveSubItemVal.order
),
moveSubItemVal.copy(
order = nextItem.order
)
)
moveSubItem = null
})
.fillMaxWidth()
.padding(8.dp)
)
}
}
}
}
val delSubItemThrottle = ThrottleState.use(scope)
if (deleteSubItem != null) {
deleteSubItem?.let { deleteSubItemVal ->
AlertDialog(onDismissRequest = { deleteSubItem = null },
title = { Text(text = "是否删除该项") },
confirmButton = {
Button(onClick = delSubItemThrottle.invoke {
if (deleteSubItem == null) return@invoke
deleteSubItem?.let {
RoomX.delete(it)
RoomX.delete { SubsConfig::subsItemId eq it.id }
}
withContext(IO) {
try {
File(deleteSubItem!!.filePath).delete()
} catch (e: Exception) {
e.printStackTrace()
}
}
subItemList = subItemList.toMutableList().also { it.remove(deleteSubItem) }
Button(onClick = scope.launchAsFn {
deleteSubItemVal.removeAssets()
deleteSubItem = null
}) {
Text("")
@ -262,25 +335,40 @@ fun SubscriptionManagePage() {
}
})
}
if (showAddDialog) {
val clickQrcodeThrottle = ThrottleState.use(scope)
Dialog(onDismissRequest = { showAddDialog = false }) {
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(onClick = {
showAddDialog = false
addSubs(
listOf(
"https://cdn.lisonge.com/startup_ad.json",
"https://cdn.lisonge.com/internal_ad.json",
"https://cdn.lisonge.com/quick_util.json",
)
)
})
.fillMaxWidth()
.padding(8.dp)
)
Text(
text = "二维码", modifier = Modifier
.clickable(onClick = clickQrcodeThrottle.invoke {
.clickable(onClick = scope.launchAsFn {
showAddDialog = false
val qrCode = navigateForQrcodeResult()
val contents = qrCode.contents
if (contents != null) {
showLinkInputDialog = true
linkText = contents
showLinkDialog = true
link = contents
}
})
.fillMaxWidth()
@ -288,7 +376,7 @@ fun SubscriptionManagePage() {
)
Text(text = "链接", modifier = Modifier
.clickable {
showLinkInputDialog = true
showLinkDialog = true
showAddDialog = false
}
.fillMaxWidth()
@ -296,64 +384,30 @@ fun SubscriptionManagePage() {
}
}
}
LaunchedEffect(showLinkDialog) {
if (!showLinkDialog) {
link = ""
}
if (showLinkInputDialog) {
Dialog(onDismissRequest = { showLinkInputDialog = false;linkText = "" }) {
}
if (showLinkDialog) {
Dialog(onDismissRequest = { showLinkDialog = false }) {
Box(
Modifier
.width(250.dp)
.width(300.dp)
.background(Color.White)
.padding(8.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = "请输入订阅链接")
TextField(
value = linkText,
onValueChange = { linkText = it },
singleLine = true
value = link, onValueChange = { link = it.trim() }, singleLine = true
)
Button(onClick = {
showLinkInputDialog = false
if (subItemList.any { it.updateUrl == linkText }) {
ToastUtils.showShort("该链接已经添加过")
return@Button
}
scope.launch {
try {
val text = Singleton.client.get(linkText).bodyAsText()
val subscriptionRaw = SubscriptionRaw.parse5(text)
File(
PathUtils.getExternalAppFilesPath()
.plus("/subscription/")
).apply {
if (!exists()) {
mkdir()
}
}
val file = File(
PathUtils.getExternalAppFilesPath()
.plus("/subscription/")
.plus(System.currentTimeMillis())
.plus(".json")
)
withContext(IO) {
file.writeText(text)
}
val tempItem = SubsItem(
updateUrl = subscriptionRaw.updateUrl ?: linkText,
filePath = file.absolutePath,
name = subscriptionRaw.name,
version = subscriptionRaw.version
)
val newItem = tempItem.copy(
id = RoomX.insert(tempItem)[0]
)
subItemList = subItemList.toMutableList().apply { add(newItem) }
} catch (e: Exception) {
e.printStackTrace()
ToastUtils.showShort(e.message ?: "")
}
}
addSubs(listOf(link))
showLinkDialog = false
}) {
Text(text = "添加")
}

View File

@ -2,7 +2,7 @@ package li.songe.gkd.ui.theme
import androidx.compose.ui.graphics.Color
val Purple200 = Color(0xFFBB86FC)
val Purple500 = Color(0xFF6200EE)
val Purple700 = Color(0xFF3700B3)
val Purple200 = Color(0xFFf8f9f9)
val Purple500 = Color(0xFFf2f3f4)
val Purple700 = Color(0xFFe5e7e9)
val Teal200 = Color(0xFF03DAC5)

View File

@ -6,29 +6,12 @@ import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
private val DarkColorPalette = darkColors(
primary = Purple200,
primaryVariant = Purple700,
secondary = Teal200
)
private val DarkColorPalette = darkColors()
private val LightColorPalette = lightColors(
primary = Purple500,
primaryVariant = Purple700,
secondary = Teal200
/* Other default colors to override
background = Color.White,
surface = Color.White,
onPrimary = Color.White,
onSecondary = Color.Black,
onBackground = Color.Black,
onSurface = Color.Black,
*/
)
private val LightColorPalette = lightColors()
@Composable
fun MainTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
val colors = if (darkTheme) {
DarkColorPalette
} else {

View File

@ -1,42 +0,0 @@
package li.songe.gkd.util
import android.content.res.Configuration
import android.os.LocaleList
import androidx.annotation.StringRes
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import li.songe.gkd.App
import java.util.*
class LocaleString(private vararg val localeList: Locale) : (Int) -> String {
private val languageContext by lazy {
App.context.createConfigurationContext(Configuration(App.context.resources.configuration).apply {
if (this@LocaleString.localeList.isNotEmpty()) {
setLocales(LocaleList(*this@LocaleString.localeList))
} else {
setLocales(App.context.resources.configuration.locales)
}
})
}
override fun invoke(@StringRes resId: Int) = languageContext.getString(resId)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as LocaleString
if (!localeList.contentEquals(other.localeList)) return false
return true
}
override fun hashCode(): Int {
return localeList.contentHashCode()
}
companion object {
var localeString by mutableStateOf(LocaleString())
}
}

View File

@ -1,16 +0,0 @@
package li.songe.gkd.util
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
object ModifierExt {
inline fun Modifier.noRippleClickable(crossinline onClick: () -> Unit): Modifier = composed {
clickable(indication = null,
interactionSource = remember { MutableInteractionSource() }) {
onClick()
}
}
}

View File

@ -1,22 +0,0 @@
package li.songe.gkd.util
sealed class Status<out T> {
object Empty : Status<Nothing>()
/**
* @param value 0f to 1f
*/
class Progress(val value: Float = 0f) : Status<Nothing>()
class Success<T>(val value: T) : Status<T>()
class Error(val value: Any?) : Status<Nothing>() {
// override fun toString(): String {
// val nullMsg = "未知错误"
// return when (value) {
// null -> nullMsg
// is String -> value
// is Exception -> value.message ?: nullMsg
// else -> value.toString()
// }
// }
}
}

View File

@ -1,109 +0,0 @@
package li.songe.gkd.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class ThrottleState(
private val scope: CoroutineScope,
private val miniAwaitTime: Long = 200L,
val loading: Boolean = false,
private val onChangeLoading: (value: Boolean) -> Unit = {},
) {
companion object {
private lateinit var defaultFalseInstance: ThrottleState
@Composable
fun use(scope: CoroutineScope, miniAwaitTime: Long = 0): ThrottleState {
var loading by remember { mutableStateOf(false) }
if (loading) {
if (!::defaultFalseInstance.isInitialized) {
defaultFalseInstance = ThrottleState(scope, miniAwaitTime, loading = true)
}
return defaultFalseInstance
}
return ThrottleState(scope, miniAwaitTime, loading = false) {
loading = it
}
}
}
class CatchInvoke(
private val onChangeCatch: (catchFn: ((e: Exception) -> Unit)) -> Unit,
private val fn: () -> Unit,
) : () -> Unit {
override fun invoke() {
fn()
}
fun catch(catchFn: ((e: Exception) -> Unit)): CatchInvoke {
onChangeCatch(catchFn)
return this
}
}
class CatchInvoke1<T>(
private val onChangeCatch: (catchFn: ((e: Exception) -> Unit)) -> Unit,
private val fn: (T) -> Unit,
) : (T) -> Unit {
override fun invoke(t: T) {
fn(t)
}
fun catch(catchFn: ((e: Exception) -> Unit)): CatchInvoke1<T> {
onChangeCatch(catchFn)
return this
}
}
fun invoke(
miniAwaitTime: Long = this.miniAwaitTime,
fn: suspend () -> Unit,
): CatchInvoke {
var catchFn = { e: Exception -> e.printStackTrace() }
return CatchInvoke({ catchFn = it }) fnWrapper@{
if (loading) return@fnWrapper
onChangeLoading(true)
scope.launch {
try {
fn()
} catch (e: Exception) {
catchFn(e)
} finally {
delay(miniAwaitTime)
onChangeLoading(false)
}
}
}
}
fun <T> invoke(
miniAwaitTime: Long = this.miniAwaitTime,
fn: suspend (T) -> Unit,
): CatchInvoke1<T> {
var catchFn = { e: Exception -> e.printStackTrace() }
return CatchInvoke1({ catchFn = it }) fnWrapper@{ t ->
if (loading) return@fnWrapper
onChangeLoading(true)
scope.launch {
try {
fn(t)
} catch (e: Exception) {
catchFn(e)
} finally {
delay(miniAwaitTime)
onChangeLoading(false)
}
}
}
}
}

View File

@ -1,20 +0,0 @@
package li.songe.gkd.util
import android.content.res.Configuration
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.blankj.utilcode.util.ScreenUtils
object UseHook {
var screenWidth by mutableStateOf(ScreenUtils.getAppScreenWidth())
var screenHeight by mutableStateOf(ScreenUtils.getAppScreenHeight())
var screenOrientationIsLandscape by mutableStateOf(ScreenUtils.isLandscape())
// var locale by mutableStateOf(LanguageUtils.getSystemLanguage())
fun update(newConfig: Configuration) {
screenHeight = ScreenUtils.getAppScreenHeight()
screenWidth = ScreenUtils.getAppScreenWidth()
screenOrientationIsLandscape = ScreenUtils.isLandscape()
}
}

View File

@ -1,6 +1,8 @@
package li.songe.gkd.util
package li.songe.gkd.utils
import android.os.Parcelable
import androidx.compose.runtime.collectAsState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.parcelize.Parcelize
/**
@ -8,24 +10,24 @@ import kotlinx.parcelize.Parcelize
*/
@Parcelize
data class AppSettings(
var ctime: Long = System.currentTimeMillis(),
var mtime: Long = System.currentTimeMillis(),
var enableService: Boolean = true,
var excludeFromRecents: Boolean = true,
var notificationVisible: Boolean = true,
var enableDebugServer: Boolean = false,
var httpServerPort: Int = 8888,
var enableConsoleLogOut: Boolean = true,
var enableCaptureSystemScreenshot: Boolean = true,
var httpServerPort: Int = 8888,
) : Parcelable {
fun commit(block: AppSettings.() -> Unit) {
val backup = copy()
block.invoke(this)
if (this != backup) {
mtime = System.currentTimeMillis()
Storage.kv.encode(saveKey, this)
}
}
companion object {
const val saveKey = "settings-v1"
const val saveKey = "settings-v2"
}
}
val appSettingsFlow by lazy { MutableStateFlow(AppSettings()) }

View File

@ -0,0 +1,50 @@
package li.songe.gkd.utils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import com.blankj.utilcode.util.ToastUtils
import com.dylanc.activityresult.launcher.StartActivityLauncher
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
val LocalLauncher =
compositionLocalOf<StartActivityLauncher> { error("not found StartActivityLauncher") }
@Composable
fun <T> usePollState(interval: Long = 1000L, getter: () -> T): MutableState<T> {
val mutableState = remember { mutableStateOf(getter()) }
LaunchedEffect(Unit) {
while (isActive) {
delay(interval)
mutableState.value = getter()
}
}
return mutableState
}
@Composable
fun LaunchedEffectTry(
key1: Any? = null,
block: suspend CoroutineScope.() -> Unit
) {
LaunchedEffect(key1) {
try {
block()
} catch (e: CancellationException) {
e.printStackTrace()
} catch (e: Exception) {
e.printStackTrace()
ToastUtils.showShort(e.message ?: "")
}
}
}

View File

@ -0,0 +1,100 @@
package li.songe.gkd.utils
import com.blankj.utilcode.util.LogUtils
import com.blankj.utilcode.util.ToastUtils
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.coroutineContext
fun CoroutineScope.launchWhile(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit,
) = launch(context, start) {
while (isActive) {
block()
}
}
fun CoroutineScope.launchWhileTry(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
interval: Long = 0,
block: suspend CoroutineScope.() -> Unit,
) = launch(context, start) {
while (isActive) {
try {
block()
} catch (e: Exception) {
e.printStackTrace()
}
delay(interval)
}
}
fun CoroutineScope.launchTry(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit,
) = launch(context, start) {
try {
block()
} catch (e: CancellationException) {
e.printStackTrace()
} catch (e: Exception) {
e.printStackTrace()
ToastUtils.showShort(e.message)
}
}
fun CoroutineScope.launchAsFn(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit,
): () -> Unit {
return {
launch(context, start) {
try {
block()
} catch (e: CancellationException) {
e.printStackTrace()
} catch (e: Exception) {
e.printStackTrace()
ToastUtils.showShort(e.message)
}
}
}
}
fun <T> CoroutineScope.launchAsFn(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.(T) -> Unit,
): (T) -> Unit {
return {
launch(context, start) {
try {
block(it)
} catch (e: Exception) {
e.printStackTrace()
ToastUtils.showShort(e.message)
}
}
}
}
suspend fun stopJob(): Nothing {
coroutineContext[Job]?.cancel()
delay(1)
error("stop failed")
}

View File

@ -0,0 +1,79 @@
package li.songe.gkd.utils
import android.graphics.Path
import android.graphics.drawable.ShapeDrawable
import android.graphics.drawable.shapes.RectShape
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.addPathNodes
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
fun createDrawable(block: () -> Unit) {
val s = materialIcon(name = "xx") {
addPath(addPathNodes(""))
}
}
fun createVectorDrawable(block: () -> Unit) {
val path = Path().apply {
addPathNodes("").forEach {
it.isQuad
}
}
val shapeDrawable = ShapeDrawable(RectShape())
shapeDrawable.apply {
addPathNodes("")[0].isCurve
}
// val r = Resources()
// Drawable.createFromXml()
}
val x = createDrawable {
// val p =
vector {
width = 24.dp
height = 24.dp
viewportWidth = 24F
viewportHeight = 24F
path {
width
fillColor = Color(0xFF000000)
pathData = "M20,11H7.83l5.59,-5.59L12,4l-8,8l8,8l1.41,-1.41L7.83,13H20v-2z"
}
}
}
interface VectorType {
var width: Dp
var height: Dp
var viewportWidth: Float
var viewportHeight: Float
}
fun vector(block: VectorType.() -> Unit) {}
interface PathType {
var fillColor: Color
var pathData: String
}
fun path(block: PathType.() -> Unit) {}
fun testDrawable() {
val s2 = vector {
width = 24.dp
height = 24.dp
viewportWidth = 24F
viewportHeight = 24F
path {
width
fillColor = Color(0xFF000000)
pathData = "M20,11H7.83l5.59,-5.59L12,4l-8,8l8,8l1.41,-1.41L7.83,13H20v-2z"
}
}
}

View File

@ -1,4 +1,4 @@
package li.songe.gkd.util
package li.songe.gkd.utils
import android.app.NotificationChannel
import android.app.NotificationManager
@ -16,32 +16,18 @@ import android.os.Looper
import androidx.compose.runtime.*
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.blankj.utilcode.util.ToastUtils
import com.dylanc.activityresult.launcher.StartActivityLauncher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import androidx.core.graphics.drawable.IconCompat
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import li.songe.gkd.App
import li.songe.gkd.MainActivity
import li.songe.gkd.R
import li.songe.gkd.data.RuleManager
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.db.table.SubsItem
import li.songe.gkd.db.util.RoomX
import li.songe.gkd.shizuku.Shell
import li.songe.gkd.shizuku.ShizukuShell
import java.io.File
import li.songe.gkd.db.DbSet
import li.songe.gkd.icon.AddIcon
import java.net.NetworkInterface
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.resume
object Ext {
fun PackageManager.getApplicationInfoExt(
packageName: String,
@ -120,7 +106,7 @@ object Ext {
)
val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_app_2)
.setSmallIcon(SafeR.ic_launcher)
.setContentTitle("调试模式")
.setContentText("正在录制您的屏幕内容")
.setContentIntent(pendingIntent)
@ -160,90 +146,14 @@ object Ext {
}
suspend fun getSubsFileLastModified(): Long {
return RoomX.select<SubsItem>().map { File(it.filePath) }
return DbSet.subsItemDao.query().first().map { it.subsFile }
.filter { it.isFile && it.exists() }
.maxOfOrNull { it.lastModified() } ?: -1L
}
suspend fun buildRuleManager(): RuleManager {
return RuleManager(*RoomX.select<SubsItem>().sortedBy { it.index }.map { subsItem ->
if (!subsItem.enable) return@map null
try {
val file = File(subsItem.filePath)
if (file.isFile && file.exists()) {
return@map SubscriptionRaw.parse5(file.readText())
}
} catch (e: Exception) {
e.printStackTrace()
}
return@map null
}.filterNotNull().toTypedArray())
}
suspend fun getActivityIdByShizuku(): String? {
if (!ShizukuShell.instance.isAvailable) return null
val result = withTimeoutOrNull(250) {
withContext(Dispatchers.IO) {
ShizukuShell.instance.exec(Shell.Command("dumpsys activity activities | grep mResumedActivity"))
}
} ?: return null
val strList = result.out.split("\u0020")
if (!result.isSuccessful || strList.size < 4 || !strList[3].contains('/')) {
return null
}
var (appId, activityId) = strList[3].split('/')
if (activityId.startsWith('.')) {
activityId = appId + activityId
}
return activityId
}
fun CoroutineScope.launchWhile(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit,
) = launch(context, start) {
while (isActive) {
block()
}
}
fun CoroutineScope.launchTry(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit,
) = launch(context, start) {
try {
block()
} catch (e: Exception) {
e.printStackTrace()
ToastUtils.showShort(e.message)
}
}
@SuppressWarnings("fallthrough")
fun createNotificationChannel(context: Service) {
val channelId = "channel_service_ab"
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_app_2)
.setContentTitle("调试模式2")
.setContentText("测试后台任务")
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setOngoing(true)
.setAutoCancel(false)
val channelId = "无障碍后台服务"
val name = "无障碍服务"
val descriptionText = "无障碍服务保持活跃"
val importance = NotificationManager.IMPORTANCE_DEFAULT
@ -252,31 +162,40 @@ object Ext {
}
val notificationManager = NotificationManagerCompat.from(context)
notificationManager.createNotificationChannel(channel)
val serviceId = 100
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(SafeR.ic_add)
.setContentTitle("搞快点")
.setContentText("无障碍正在运行")
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setOngoing(true)
.setAutoCancel(false)
val notification = builder.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
context.startForeground(
110,
serviceId,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST
)
} else {
context.startForeground(110, notification)
context.startForeground(serviceId, notification)
}
}
val LocalLauncher =
compositionLocalOf<StartActivityLauncher> { error("not found StartActivityLauncher") }
@Composable
fun <T> usePollState(interval: Long = 400L, getter: () -> T): MutableState<T> {
val mutableState = remember { mutableStateOf(getter()) }
LaunchedEffect(Unit) {
while (isActive) {
delay(interval)
mutableState.value = getter()
}
}
return mutableState
}
}

View File

@ -0,0 +1,18 @@
package li.songe.gkd.utils
import com.blankj.utilcode.util.PathUtils
import java.io.File
object FolderExt {
private fun createFolder(name: String): File {
return File(PathUtils.getExternalAppFilesPath().plus("/$name")).apply {
if (!exists()) {
mkdirs()
}
}
}
val dbFolder by lazy { createFolder("db") }
val subsFolder by lazy { createFolder("subscription") }
val snapshotFolder by lazy { createFolder("snapshot") }
}

View File

@ -1,23 +1,12 @@
package li.songe.gkd.hooks
package li.songe.gkd.utils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.runtime.Composable
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 com.blankj.utilcode.util.LogUtils
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanIntentResult
import com.journeyapps.barcodescanner.ScanOptions
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import kotlinx.coroutines.delay
import li.songe.gkd.data.SubscriptionRaw
import li.songe.gkd.data.Value
import li.songe.gkd.util.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@ -43,14 +32,3 @@ fun useNavigateForQrcodeResult(): suspend () -> ScanIntentResult {
}
}
@Composable
fun useFetchSubs(): suspend (String) -> String {
val scope = rememberCoroutineScope()
var loading by remember { mutableStateOf(false) }
return remember {
{ url ->
loading
Singleton.client.get(url).bodyAsText()
}
}
}

View File

@ -0,0 +1,14 @@
package li.songe.gkd.utils
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
inline fun Modifier.noRippleClickable(crossinline onClick: () -> Unit): Modifier = composed {
clickable(indication = null,
interactionSource = remember { MutableInteractionSource() }) {
onClick()
}
}

View File

@ -0,0 +1,8 @@
package li.songe.gkd.utils
import androidx.compose.runtime.compositionLocalOf
import androidx.navigation.NavHostController
val LocalNavController =
compositionLocalOf<NavHostController> { error("not found DestinationsNavigator") }

View File

@ -0,0 +1,28 @@
package li.songe.gkd.utils
import li.songe.gkd.R
/**
* ![image](https://github.com/lisonge/gkd/assets/38517192/545c4fce-77b2-4003-8e22-a21b48ef3d98)
*/
@Suppress("UNRESOLVED_REFERENCE")
object SafeR {
val capture: Int = R.drawable.capture
val ic_add: Int = R.drawable.ic_add
val ic_app_2: Int = R.drawable.ic_app_2
val ic_apps: Int = R.drawable.ic_apps
val ic_back: Int = R.drawable.ic_back
val ic_chart_bar: Int = R.drawable.ic_chart_bar
val ic_cog: Int = R.drawable.ic_cog
val ic_create_round: Int = R.drawable.ic_create_round
val ic_database_set: Int = R.drawable.ic_database_set
val ic_del: Int = R.drawable.ic_del
val ic_launcher: Int = R.drawable.ic_launcher
val ic_launcher_background: Int = R.drawable.ic_launcher_background
val ic_launcher_round: Int = R.drawable.ic_launcher_round
val ic_link: Int = R.drawable.ic_link
val ic_menu: Int = R.drawable.ic_menu
val ic_refresh: Int = R.drawable.ic_refresh
val ic_share: Int = R.drawable.ic_share
}

View File

@ -1,4 +1,4 @@
package li.songe.gkd.util
package li.songe.gkd.utils
import android.annotation.SuppressLint
import android.app.Activity
@ -16,7 +16,7 @@ import android.media.projection.MediaProjectionManager
import android.os.Handler
import android.os.Looper
import com.blankj.utilcode.util.ScreenUtils
import li.songe.gkd.util.Ext.isEmptyBitmap
import li.songe.gkd.utils.Ext.isEmptyBitmap
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine

Some files were not shown because too many files have changed in this diff Show More