refactor: permission, refresh appList, divider

This commit is contained in:
lisonge 2024-04-29 22:05:53 +08:00
parent 015af353d6
commit 6a9f413cd5
23 changed files with 831 additions and 619 deletions

View File

@ -226,4 +226,5 @@ dependencies {
implementation(libs.exp4j)
implementation(libs.toaster)
implementation(libs.permissions)
}

View File

@ -3,17 +3,20 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!-- 低版本Android截屏 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <!-- com.tencent.bugly -->
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" /> <!-- packageManager.getApplicationInfo -->
<!-- 国产ROM-获取应用列表权限 -->
<uses-permission android:name="com.android.permission.GET_INSTALLED_APPS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
@ -87,8 +90,7 @@
android:name=".service.GkdAbService"
android:exported="false"
android:label="@string/app_name"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
android:stopWithTask="false">
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
@ -97,38 +99,40 @@
android:name="android.accessibilityservice"
android:resource="@xml/ab_desc" />
</service>
<service
android:name=".service.ManageService"
android:exported="false"
android:foregroundServiceType="dataSync"
android:stopWithTask="false" />
<service
android:name=".debug.ScreenshotService"
android:exported="false"
android:foregroundServiceType="mediaProjection"
android:stopWithTask="false" />
android:foregroundServiceType="mediaProjection" />
<service
android:name=".service.ManageService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Display the running state of the application" />
</service>
<service
android:name=".debug.HttpService"
android:exported="false"
android:foregroundServiceType="dataSync"
android:stopWithTask="false" />
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Enable the HTTP server to provide external browser connections and debugging" />
</service>
<service
android:name=".debug.FloatingService"
android:exported="false"
android:foregroundServiceType="dataSync"
android:stopWithTask="false" />
<service
android:name=".service.ShizukuService"
android:exported="false"
android:foregroundServiceType="dataSync"
android:stopWithTask="false" />
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Display a screenshot button for users to actively save screen information." />
</service>
<service
android:name=".debug.SnapshotTileService"
android:exported="true"
android:icon="@drawable/ic_capture"
android:label="@string/capture_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:stopWithTask="false">
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />

View File

@ -8,7 +8,6 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController
import com.dylanc.activityresult.launcher.PickContentLauncher
import com.dylanc.activityresult.launcher.RequestPermissionLauncher
import com.dylanc.activityresult.launcher.StartActivityLauncher
import com.ramcosta.composedestinations.DestinationsNavHost
import dagger.hilt.android.AndroidEntryPoint
@ -18,18 +17,18 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import li.songe.gkd.composition.CompositionActivity
import li.songe.gkd.composition.CompositionExt.useLifeCycleLog
import li.songe.gkd.permission.AuthDialog
import li.songe.gkd.permission.updatePermissionState
import li.songe.gkd.service.ManageService
import li.songe.gkd.service.updateLauncherAppId
import li.songe.gkd.ui.NavGraphs
import li.songe.gkd.ui.component.ConfirmDialog
import li.songe.gkd.ui.theme.AppTheme
import li.songe.gkd.util.AuthDialog
import li.songe.gkd.util.LocalLauncher
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.LocalPickContentLauncher
import li.songe.gkd.util.LocalRequestPermissionLauncher
import li.songe.gkd.util.UpgradeDialog
import li.songe.gkd.util.appInfoCacheFlow
import li.songe.gkd.util.initOrResetAppInfoCache
import li.songe.gkd.util.initFolder
import li.songe.gkd.util.map
import li.songe.gkd.util.storeFlow
@ -39,7 +38,6 @@ class MainActivity : CompositionActivity({
useLifeCycleLog()
val launcher = StartActivityLauncher(this)
val pickContentLauncher = PickContentLauncher(this)
val requestPermissionLauncher = RequestPermissionLauncher(this)
lifecycleScope.launch {
storeFlow.map(lifecycleScope) { s -> s.excludeFromRecents }.collect {
@ -56,12 +54,10 @@ class MainActivity : CompositionActivity({
setContent {
val navController = rememberNavController()
AppTheme {
CompositionLocalProvider(
LocalLauncher provides launcher,
LocalPickContentLauncher provides pickContentLauncher,
LocalRequestPermissionLauncher provides requestPermissionLauncher,
LocalNavController provides navController
) {
DestinationsNavHost(
@ -85,13 +81,15 @@ class MainActivity : CompositionActivity({
override fun onResume() {
super.onResume()
// https://github.com/gkd-kit/gkd/issues/543
// ContextCompat.checkSelfPermission(app, Manifest.permission.QUERY_ALL_PACKAGES) always is GRANTED
if (appInfoCacheFlow.value.count { e -> !e.value.isSystem && !e.value.hidden } < 16) {
lifecycleScope.launch(Dispatchers.IO) {
initOrResetAppInfoCache()
}
// 每次切换页面更新记录桌面 appId
updateLauncherAppId()
// 在某些机型由于未知原因创建失败, 在此保证每次界面切换都能重新检测创建
appScope.launch(Dispatchers.IO) {
initFolder()
}
updatePermissionState()
}
override fun onStop() {

View File

@ -2,15 +2,14 @@ package li.songe.gkd
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.blankj.utilcode.util.LogUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.SubsItem
import li.songe.gkd.db.DbSet
import li.songe.gkd.service.updateLauncherAppId
import li.songe.gkd.util.authActionFlow
import li.songe.gkd.permission.authReasonFlow
import li.songe.gkd.util.checkUpdate
import li.songe.gkd.util.initFolder
import li.songe.gkd.util.launchTry
import li.songe.gkd.util.logZipDir
import li.songe.gkd.util.newVersionApkDir
@ -20,12 +19,6 @@ import li.songe.gkd.util.updateSubscription
class MainViewModel : ViewModel() {
init {
// 在某些机型由于未知原因创建失败
// 在此保证每次重新打开APP都能重新检测创建
initFolder()
// 每次打开页面更新记录桌面 appId
updateLauncherAppId()
val localSubsItem = SubsItem(
id = -2, order = -2, mtime = System.currentTimeMillis()
@ -63,6 +56,7 @@ class MainViewModel : ViewModel() {
checkUpdate()
} catch (e: Exception) {
e.printStackTrace()
LogUtils.d(e)
}
}
}
@ -71,6 +65,6 @@ class MainViewModel : ViewModel() {
override fun onCleared() {
super.onCleared()
authActionFlow.value = null
authReasonFlow.value = null
}
}

View File

@ -30,7 +30,7 @@ data class SubsItem(
) {
val isSafeRemote by lazy {
private val isSafeRemote by lazy {
if (updateUrl != null) {
isSafeUrl(updateUrl)
} else {
@ -38,6 +38,16 @@ data class SubsItem(
}
}
val sourceText by lazy {
if (id < 0) {
"本地来源"
} else if (isSafeRemote) {
"可信来源"
} else {
"未知来源"
}
}
suspend fun removeAssets() {
deleteSubscription(id)
DbSet.subsItemDao.delete(this)

View File

@ -0,0 +1,97 @@
package li.songe.gkd.permission
import android.app.Activity
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import com.hjq.permissions.OnPermissionCallback
import com.hjq.permissions.XXPermissions
import kotlinx.coroutines.flow.MutableStateFlow
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
data class AuthReason(
val text: String,
val confirm: () -> Unit
)
val authReasonFlow = MutableStateFlow<AuthReason?>(null)
@Composable
fun AuthDialog() {
val authAction = authReasonFlow.collectAsState().value
if (authAction != null) {
AlertDialog(
title = {
Text(text = "权限请求")
},
text = {
Text(text = authAction.text)
},
onDismissRequest = { authReasonFlow.value = null },
confirmButton = {
TextButton(onClick = {
authReasonFlow.value = null
authAction.confirm()
}) {
Text(text = "确认")
}
},
dismissButton = {
TextButton(onClick = { authReasonFlow.value = null }) {
Text(text = "取消")
}
}
)
}
}
sealed class PermissionResult {
data object Granted : PermissionResult()
data class Denied(val doNotAskAgain: Boolean) : PermissionResult()
}
suspend fun asyncRequestPermission(
context: Activity,
permission: String,
): PermissionResult {
if (XXPermissions.isGranted(context, permission)) {
return PermissionResult.Granted
}
return suspendCoroutine { continuation ->
XXPermissions.with(context)
.unchecked()
.permission(permission)
.request(object : OnPermissionCallback {
override fun onGranted(permissions: MutableList<String>, allGranted: Boolean) {
if (allGranted) {
continuation.resume(PermissionResult.Granted)
} else {
continuation.resume(PermissionResult.Denied(false))
}
}
override fun onDenied(permissions: MutableList<String>, doNotAskAgain: Boolean) {
continuation.resume(PermissionResult.Denied(doNotAskAgain))
}
})
}
}
suspend fun checkOrRequestPermission(
context: Activity,
permissionState: PermissionState
): Boolean {
if (!permissionState.updateAndGet()) {
val result = permissionState.request?.let { it(context) } ?: return false
if (result is PermissionResult.Denied) {
if (result.doNotAskAgain) {
authReasonFlow.value = permissionState.reason
}
return false
}
}
return true
}

View File

@ -0,0 +1,144 @@
package li.songe.gkd.permission
import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import android.os.Build
import android.provider.Settings
import androidx.core.content.ContextCompat
import com.hjq.permissions.Permission
import com.hjq.permissions.XXPermissions
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.updateAndGet
import li.songe.gkd.app
import li.songe.gkd.appScope
import li.songe.gkd.shizuku.shizukuIsSafeOK
import li.songe.gkd.util.appInfoCacheFlow
import li.songe.gkd.util.initOrResetAppInfoCache
import li.songe.gkd.util.launchTry
class PermissionState(
val check: () -> Boolean,
val request: (suspend (context: Activity) -> PermissionResult)? = null,
/**
* show it when user doNotAskAgain
*/
val reason: AuthReason? = null,
) {
val stateFlow = MutableStateFlow(check())
fun updateAndGet(): Boolean {
return stateFlow.updateAndGet { check() }
}
}
private fun checkSelfPermission(permission: String): Boolean {
return ContextCompat.checkSelfPermission(
app,
permission
) == PackageManager.PERMISSION_GRANTED
}
val notificationState by lazy {
PermissionState(
check = {
checkSelfPermission(Permission.POST_NOTIFICATIONS)
},
request = {
asyncRequestPermission(it, Permission.POST_NOTIFICATIONS)
},
reason = AuthReason(
text = "当前操作需要[通知权限]\n\n您需要前往应用权限设置打开此权限",
confirm = {
XXPermissions.startPermissionActivity(app, Permission.POST_NOTIFICATIONS)
}
),
)
}
val canQueryPkgState by lazy {
PermissionState(
check = {
XXPermissions.isGranted(app, Permission.GET_INSTALLED_APPS)
},
request = {
asyncRequestPermission(it, Permission.GET_INSTALLED_APPS)
},
reason = AuthReason(
text = "当前操作需要[读取应用列表权限]\n\n您需要前往应用权限设置打开此权限",
confirm = {
XXPermissions.startPermissionActivity(app, Permission.GET_INSTALLED_APPS)
}
),
)
}
val canDrawOverlaysState by lazy {
PermissionState(
check = {
Settings.canDrawOverlays(app)
},
request = {
// 无法直接请求悬浮窗权限
if (!Settings.canDrawOverlays(app)) {
PermissionResult.Denied(true)
} else {
PermissionResult.Granted
}
},
reason = AuthReason(
text = "当前操作需要[悬浮窗权限]\n\n您需要前往应用权限设置打开此权限",
confirm = {
XXPermissions.startPermissionActivity(app, Permission.SYSTEM_ALERT_WINDOW)
}
),
)
}
val canSaveToAlbumState by lazy {
PermissionState(
check = {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
} else {
true
}
},
request = {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
asyncRequestPermission(it, Permission.WRITE_EXTERNAL_STORAGE)
} else {
PermissionResult.Granted
}
},
reason = AuthReason(
text = "当前操作需要[写入外部存储权限]\n\n您需要前往应用权限设置打开此权限",
confirm = {
XXPermissions.startPermissionActivity(app, Permission.SYSTEM_ALERT_WINDOW)
}
),
)
}
val shizukuOkState by lazy {
PermissionState(
check = { shizukuIsSafeOK() },
)
}
fun updatePermissionState() {
arrayOf(
notificationState,
canDrawOverlaysState,
canSaveToAlbumState,
shizukuOkState
).forEach { it.updateAndGet() }
if (canQueryPkgState.stateFlow.value != canQueryPkgState.updateAndGet() ||
// https://github.com/gkd-kit/gkd/issues/543
appInfoCacheFlow.value.count { e -> !e.value.isSystem && !e.value.hidden } < 16
) {
appScope.launchTry {
initOrResetAppInfoCache()
}
}
}

View File

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

View File

@ -4,7 +4,6 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import android.media.projection.MediaProjectionManager
import android.provider.Settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@ -22,7 +21,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
@ -40,6 +38,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -62,11 +61,14 @@ import li.songe.gkd.appScope
import li.songe.gkd.debug.FloatingService
import li.songe.gkd.debug.HttpService
import li.songe.gkd.debug.ScreenshotService
import li.songe.gkd.permission.canDrawOverlaysState
import li.songe.gkd.permission.checkOrRequestPermission
import li.songe.gkd.permission.notificationState
import li.songe.gkd.permission.shizukuOkState
import li.songe.gkd.shizuku.CommandResult
import li.songe.gkd.shizuku.newActivityTaskManager
import li.songe.gkd.shizuku.newUserService
import li.songe.gkd.shizuku.safeGetTasks
import li.songe.gkd.shizuku.shizukuIsSafeOK
import li.songe.gkd.ui.component.AuthCard
import li.songe.gkd.ui.component.SettingItem
import li.songe.gkd.ui.component.TextSwitch
@ -74,9 +76,6 @@ import li.songe.gkd.ui.destinations.SnapshotPageDestination
import li.songe.gkd.util.LocalLauncher
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.ProfileTransitions
import li.songe.gkd.util.authActionFlow
import li.songe.gkd.util.canDrawOverlaysAuthAction
import li.songe.gkd.util.checkOrRequestNotifPermission
import li.songe.gkd.util.json
import li.songe.gkd.util.launchAsFn
import li.songe.gkd.util.launchTry
@ -84,7 +83,6 @@ import li.songe.gkd.util.navigate
import li.songe.gkd.util.openUri
import li.songe.gkd.util.storeFlow
import li.songe.gkd.util.toast
import li.songe.gkd.util.usePollState
import rikka.shizuku.Shizuku
@RootNavGraph
@ -92,6 +90,7 @@ import rikka.shizuku.Shizuku
@Composable
fun DebugPage() {
val context = LocalContext.current as MainActivity
val scope = rememberCoroutineScope()
val launcher = LocalLauncher.current
val navController = LocalNavController.current
val store by storeFlow.collectAsState()
@ -123,8 +122,8 @@ fun DebugPage() {
.padding(contentPadding),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
val shizukuIsOk by usePollState { shizukuIsSafeOK() }
if (!shizukuIsOk) {
val shizukuOk by shizukuOkState.stateFlow.collectAsState()
if (!shizukuOk) {
AuthCard(title = "Shizuku授权",
desc = "高级模式:准确识别界面ID,强制模拟点击",
onAuthClick = {
@ -135,56 +134,9 @@ fun DebugPage() {
toast("Shizuku可能没有运行")
}
})
HorizontalDivider()
ShizukuFragment(false)
} else {
TextSwitch(name = "Shizuku-界面识别",
desc = "更准确识别界面ID",
checked = store.enableShizukuActivity,
onCheckedChange = { enableShizuku ->
if (enableShizuku) {
appScope.launchTry(Dispatchers.IO) {
// 校验方法是否适配, 再允许使用 shizuku
val tasks =
newActivityTaskManager()?.safeGetTasks()?.firstOrNull()
if (tasks != null) {
storeFlow.value = store.copy(
enableShizukuActivity = true
)
} else {
toast("Shizuku-界面识别校验失败,无法使用")
}
}
} else {
storeFlow.value = store.copy(
enableShizukuActivity = false
)
}
})
HorizontalDivider()
TextSwitch(
name = "Shizuku-模拟点击",
desc = "变更 clickCenter 为强制模拟点击",
checked = store.enableShizukuClick,
onCheckedChange = { enableShizuku ->
if (enableShizuku) {
appScope.launchTry(Dispatchers.IO) {
val service = newUserService()
val result = service.userService.execCommand("input tap 0 0")
service.destroy()
if (json.decodeFromString<CommandResult>(result).code == 0) {
storeFlow.update { it.copy(enableShizukuClick = true) }
} else {
toast("Shizuku-模拟点击校验失败,无法使用")
}
}
} else {
storeFlow.value = store.copy(
enableShizukuClick = false
)
}
})
HorizontalDivider()
ShizukuFragment()
}
val httpServerRunning by HttpService.isRunning.collectAsState()
@ -240,11 +192,11 @@ fun DebugPage() {
Spacer(modifier = Modifier.width(10.dp))
Switch(
checked = httpServerRunning,
onCheckedChange = {
if (!checkOrRequestNotifPermission(context)) {
return@Switch
}
onCheckedChange = scope.launchAsFn<Boolean> {
if (it) {
if (!checkOrRequestPermission(context, notificationState)) {
return@launchAsFn
}
HttpService.start()
} else {
HttpService.stop()
@ -252,14 +204,12 @@ fun DebugPage() {
}
)
}
HorizontalDivider()
SettingItem(
title = "HTTP服务端口-${store.httpServerPort}", imageVector = Icons.Default.Edit
) {
showPortDlg = true
}
HorizontalDivider()
TextSwitch(
name = "保留内存订阅",
@ -270,23 +220,21 @@ fun DebugPage() {
autoClearMemorySubs = !it
)
}
HorizontalDivider()
SettingItem(title = "快照记录", onClick = {
navController.navigate(SnapshotPageDestination)
})
HorizontalDivider()
val screenshotRunning by ScreenshotService.isRunning.collectAsState()
TextSwitch(
name = "截屏服务",
desc = "生成快照需要获取屏幕截图,Android11无需开启",
checked = screenshotRunning,
onCheckedChange = appScope.launchAsFn<Boolean> {
if (!checkOrRequestNotifPermission(context)) {
return@launchAsFn
}
onCheckedChange = scope.launchAsFn<Boolean> {
if (it) {
if (!checkOrRequestPermission(context, notificationState)) {
return@launchAsFn
}
val mediaProjectionManager =
context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val activityResult =
@ -298,29 +246,28 @@ fun DebugPage() {
ScreenshotService.stop()
}
})
HorizontalDivider()
val floatingRunning by FloatingService.isRunning.collectAsState()
TextSwitch(
name = "悬浮窗服务",
desc = "显示截屏按钮,便于用户主动保存快照",
checked = floatingRunning
) {
if (!checkOrRequestNotifPermission(context)) {
return@TextSwitch
}
if (it) {
if (Settings.canDrawOverlays(context)) {
checked = floatingRunning,
onCheckedChange = scope.launchAsFn<Boolean> {
if (it) {
if (!checkOrRequestPermission(context, notificationState)) {
return@launchAsFn
}
if (!checkOrRequestPermission(context, canDrawOverlaysState)) {
return@launchAsFn
}
val intent = Intent(context, FloatingService::class.java)
ContextCompat.startForegroundService(context, intent)
} else {
authActionFlow.value = canDrawOverlaysAuthAction
FloatingService.stop(context)
}
} else {
FloatingService.stop(context)
}
}
HorizontalDivider()
)
TextSwitch(
name = "音量快照",
desc = "当音量变化时,生成快照,如果悬浮窗按钮不工作,可以使用这个",
@ -331,7 +278,6 @@ fun DebugPage() {
)
}
HorizontalDivider()
TextSwitch(
name = "截屏快照",
desc = "当用户截屏时保存快照(需手动替换快照图片),仅支持部分小米设备",
@ -342,7 +288,6 @@ fun DebugPage() {
)
}
HorizontalDivider()
TextSwitch(
name = "隐藏快照状态栏",
desc = "当保存快照时,隐藏截图里的顶部状态栏高度区域",
@ -407,4 +352,59 @@ fun DebugPage() {
})
}
}
}
@Composable
private fun ShizukuFragment(enabled: Boolean = true) {
val store by storeFlow.collectAsState()
TextSwitch(name = "Shizuku-界面识别",
desc = "更准确识别界面ID",
checked = store.enableShizukuActivity,
enabled = enabled,
onCheckedChange = { enableShizuku ->
if (enableShizuku) {
appScope.launchTry(Dispatchers.IO) {
// 校验方法是否适配, 再允许使用 shizuku
val tasks =
newActivityTaskManager()?.safeGetTasks()?.firstOrNull()
if (tasks != null) {
storeFlow.value = store.copy(
enableShizukuActivity = true
)
} else {
toast("Shizuku-界面识别校验失败,无法使用")
}
}
} else {
storeFlow.value = store.copy(
enableShizukuActivity = false
)
}
})
TextSwitch(
name = "Shizuku-模拟点击",
desc = "变更 clickCenter 为强制模拟点击",
checked = store.enableShizukuClick,
enabled = enabled,
onCheckedChange = { enableShizuku ->
if (enableShizuku) {
appScope.launchTry(Dispatchers.IO) {
val service = newUserService()
val result = service.userService.execCommand("input tap 0 0")
service.destroy()
if (json.decodeFromString<CommandResult>(result).code == 0) {
storeFlow.update { it.copy(enableShizukuClick = true) }
} else {
toast("Shizuku-模拟点击校验失败,无法使用")
}
}
} else {
storeFlow.value = store.copy(
enableShizukuClick = false
)
}
})
}

View File

@ -1,8 +1,6 @@
package li.songe.gkd.ui
import android.Manifest
import android.graphics.Bitmap
import android.os.Build
import androidx.activity.ComponentActivity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
@ -50,7 +48,6 @@ import androidx.lifecycle.viewModelScope
import com.blankj.utilcode.util.ClipboardUtils
import com.blankj.utilcode.util.ImageUtils
import com.blankj.utilcode.util.UriUtils
import com.dylanc.activityresult.launcher.launchForResult
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootNavGraph
import kotlinx.coroutines.CancellationException
@ -59,12 +56,13 @@ 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.permission.canSaveToAlbumState
import li.songe.gkd.permission.checkOrRequestPermission
import li.songe.gkd.ui.destinations.ImagePreviewPageDestination
import li.songe.gkd.util.IMPORT_BASE_URL
import li.songe.gkd.util.LoadStatus
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.LocalPickContentLauncher
import li.songe.gkd.util.LocalRequestPermissionLauncher
import li.songe.gkd.util.ProfileTransitions
import li.songe.gkd.util.format
import li.songe.gkd.util.launchAsFn
@ -83,7 +81,6 @@ fun SnapshotPage() {
val colorScheme = MaterialTheme.colorScheme
val pickContentLauncher = LocalPickContentLauncher.current
val requestPermissionLauncher = LocalRequestPermissionLauncher.current
val vm = hiltViewModel<SnapshotVm>()
val snapshots by vm.snapshotsState.collectAsState()
@ -244,13 +241,8 @@ fun SnapshotPage() {
text = "保存截图到相册",
modifier = Modifier
.clickable(onClick = vm.viewModelScope.launchAsFn {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
val isGranted =
requestPermissionLauncher.launchForResult(Manifest.permission.WRITE_EXTERNAL_STORAGE)
if (!isGranted) {
toast("保存失败,暂无权限")
return@launchAsFn
}
if (!checkOrRequestPermission(context, canSaveToAlbumState)) {
return@launchAsFn
}
ImageUtils.save2Album(
ImageUtils.getBitmap(snapshotVal.screenshotFile),

View File

@ -6,17 +6,26 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import li.songe.gkd.data.RawSubscription
import li.songe.gkd.data.SubsItem
import li.songe.gkd.util.formatTimeAgo
import li.songe.gkd.util.map
import li.songe.gkd.util.subsLoadErrorsFlow
import li.songe.gkd.util.subsRefreshErrorsFlow
@Composable
@ -26,30 +35,28 @@ fun SubsItemCard(
index: Int,
onCheckedChange: ((Boolean) -> Unit)? = null,
) {
val scope = rememberCoroutineScope()
val subsLoadError by remember(subsItem.id) {
subsLoadErrorsFlow.map(scope) { it[subsItem.id] }
}.collectAsState()
val subsRefreshError by remember(subsItem.id) {
subsRefreshErrorsFlow.map(scope) { it[subsItem.id] }
}.collectAsState()
Row(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(8.dp)
) {
Column(modifier = Modifier.weight(1f)) {
if (rawSubscription != null) {
Row {
Text(
text = index.toString() + ". " + (rawSubscription.name),
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = index.toString() + ". " + (rawSubscription.name),
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(5.dp))
Row {
val sourceText =
if (subsItem.id < 0) {
"本地来源"
} else if (subsItem.isSafeRemote) {
"可信来源"
} else {
"未知来源"
}
Text(text = sourceText, fontSize = 14.sp)
Text(text = subsItem.sourceText, fontSize = 14.sp)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = formatTimeAgo(subsItem.mtime),
@ -72,16 +79,34 @@ fun SubsItemCard(
}
Spacer(modifier = Modifier.height(5.dp))
Text(
text = rawSubscription.numText,
fontSize = 14.sp
text = rawSubscription.numText, fontSize = 14.sp
)
} else {
Text(
text = "本地无订阅文件,请下拉刷新",
text = "${index}. id:${subsItem.id}",
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(5.dp))
val color = if (subsLoadError != null) {
MaterialTheme.colorScheme.error
} else {
Color.Unspecified
}
Text(
text = subsLoadError?.message ?: "加载订阅中...",
fontSize = 14.sp,
color = color
)
}
if (subsRefreshError != null) {
Spacer(modifier = Modifier.height(5.dp))
Text(
text = "刷新错误: ${subsRefreshError?.message}",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.error
)
}
}
Spacer(modifier = Modifier.width(10.dp))

View File

@ -20,6 +20,7 @@ fun TextSwitch(
name: String = "",
desc: String = "",
checked: Boolean = true,
enabled: Boolean = true,
onCheckedChange: ((Boolean) -> Unit)? = null,
) {
Row(
@ -36,8 +37,9 @@ fun TextSwitch(
}
Spacer(modifier = Modifier.width(10.dp))
Switch(
checked,
onCheckedChange,
checked = checked,
enabled = enabled,
onCheckedChange = onCheckedChange,
)
}
}

View File

@ -24,8 +24,10 @@ import androidx.compose.material.icons.automirrored.filled.Sort
import androidx.compose.material.icons.filled.Android
import androidx.compose.material.icons.filled.Apps
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PriorityHigh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
@ -58,14 +60,19 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.viewModelScope
import com.blankj.utilcode.util.KeyboardUtils
import com.google.accompanist.drawablepainter.rememberDrawablePainter
import kotlinx.coroutines.flow.update
import li.songe.gkd.MainActivity
import li.songe.gkd.permission.canQueryPkgState
import li.songe.gkd.permission.checkOrRequestPermission
import li.songe.gkd.ui.component.AppBarTextField
import li.songe.gkd.ui.destinations.AppConfigPageDestination
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.SortTypeOption
import li.songe.gkd.util.appRefreshingFlow
import li.songe.gkd.util.launchAsFn
import li.songe.gkd.util.navigate
import li.songe.gkd.util.ruleSummaryFlow
import li.songe.gkd.util.storeFlow
@ -163,6 +170,17 @@ fun useAppListPage(): ScaffoldExt {
)
}
} else {
val canQueryPkg by canQueryPkgState.stateFlow.collectAsState()
if (!canQueryPkg) {
IconButton(onClick = vm.viewModelScope.launchAsFn {
checkOrRequestPermission(context, canQueryPkgState)
}) {
Icon(
imageVector = Icons.Default.PriorityHigh,
contentDescription = null
)
}
}
IconButton(onClick = {
showSearchBar = true
}) {
@ -245,114 +263,128 @@ fun useAppListPage(): ScaffoldExt {
}
}
})
}) { padding ->
LazyColumn(
modifier = Modifier.padding(padding),
state = listState
) {
items(orderedAppInfos, { it.id }) { appInfo ->
Row(
modifier = Modifier
.clickable {
navController.navigate(AppConfigPageDestination(appInfo.id))
}
.height(IntrinsicSize.Min)
.padding(4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
if (appInfo.icon != null) {
Box(
modifier = Modifier
.fillMaxHeight()
.aspectRatio(1f)
) {
Image(
painter = rememberDrawablePainter(appInfo.icon),
}
) { padding ->
val appRefreshing by appRefreshingFlow.collectAsState()
if (!appRefreshing) {
LazyColumn(
modifier = Modifier.padding(padding),
state = listState
) {
items(orderedAppInfos, { it.id }) { appInfo ->
Row(
modifier = Modifier
.clickable {
navController.navigate(AppConfigPageDestination(appInfo.id))
}
.height(IntrinsicSize.Min)
.padding(4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
if (appInfo.icon != null) {
Box(
modifier = Modifier
.fillMaxHeight()
.aspectRatio(1f)
) {
Image(
painter = rememberDrawablePainter(appInfo.icon),
contentDescription = null,
modifier = Modifier
.matchParentSize()
.padding(4.dp)
)
}
} else {
Icon(
imageVector = Icons.Default.Android,
contentDescription = null,
modifier = Modifier
.matchParentSize()
.aspectRatio(1f)
.fillMaxHeight()
.padding(4.dp)
)
}
} else {
Icon(
imageVector = Icons.Default.Android,
contentDescription = null,
modifier = Modifier
.aspectRatio(1f)
.fillMaxHeight()
.padding(4.dp)
)
}
Spacer(modifier = Modifier.width(10.dp))
Column(
modifier = Modifier.weight(1f),
) {
Text(
text = appInfo.name,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
style = LocalTextStyle.current.let {
if (appInfo.isSystem) {
it.copy(textDecoration = TextDecoration.Underline)
} else {
it
}
}
)
val appGroups = ruleSummary.appIdToAllGroups[appInfo.id] ?: emptyList()
val appDesc = if (appGroups.isNotEmpty()) {
when (val disabledCount = appGroups.count { g -> !g.enable }) {
0 -> {
"${appGroups.size}组规则"
}
appGroups.size -> {
"${appGroups.size}组规则/${disabledCount}关闭"
}
else -> {
"${appGroups.size}组规则/${appGroups.size - disabledCount}启用/${disabledCount}关闭"
}
}
} else {
null
}
val desc = if (globalDesc != null) {
if (appDesc != null) {
"$globalDesc/$appDesc"
} else {
globalDesc
}
} else {
appDesc
}
if (desc != null) {
Text(text = desc)
} else {
Spacer(modifier = Modifier.width(10.dp))
Column(
modifier = Modifier.weight(1f),
) {
Text(
text = "暂无规则",
color = LocalContentColor.current.copy(alpha = 0.5f)
text = appInfo.name,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
style = LocalTextStyle.current.let {
if (appInfo.isSystem) {
it.copy(textDecoration = TextDecoration.Underline)
} else {
it
}
}
)
val appGroups = ruleSummary.appIdToAllGroups[appInfo.id] ?: emptyList()
val appDesc = if (appGroups.isNotEmpty()) {
when (val disabledCount = appGroups.count { g -> !g.enable }) {
0 -> {
"${appGroups.size}组规则"
}
appGroups.size -> {
"${appGroups.size}组规则/${disabledCount}关闭"
}
else -> {
"${appGroups.size}组规则/${appGroups.size - disabledCount}启用/${disabledCount}关闭"
}
}
} else {
null
}
val desc = if (globalDesc != null) {
if (appDesc != null) {
"$globalDesc/$appDesc"
} else {
globalDesc
}
} else {
appDesc
}
if (desc != null) {
Text(text = desc)
} else {
Text(
text = "暂无规则",
color = LocalContentColor.current.copy(alpha = 0.5f)
)
}
}
}
}
item {
Spacer(modifier = Modifier.height(40.dp))
if (orderedAppInfos.isEmpty() && searchStr.isNotEmpty()) {
Text(
text = "暂无搜索结果",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
}
}
}
item {
} else {
Column(
modifier = Modifier
.padding(padding)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(40.dp))
if (orderedAppInfos.isEmpty() && searchStr.isNotEmpty()) {
Text(
text = "暂无搜索结果",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
}
CircularProgressIndicator()
}
}
}

View File

@ -32,9 +32,11 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.app.NotificationManagerCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.viewModelScope
import li.songe.gkd.MainActivity
import li.songe.gkd.permission.checkOrRequestPermission
import li.songe.gkd.permission.notificationState
import li.songe.gkd.service.GkdAbService
import li.songe.gkd.service.ManageService
import li.songe.gkd.ui.component.AuthCard
@ -43,13 +45,12 @@ import li.songe.gkd.ui.destinations.ClickLogPageDestination
import li.songe.gkd.ui.destinations.SlowGroupPageDestination
import li.songe.gkd.util.HOME_PAGE_URL
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.checkOrRequestNotifPermission
import li.songe.gkd.util.launchAsFn
import li.songe.gkd.util.navigate
import li.songe.gkd.util.openUri
import li.songe.gkd.util.ruleSummaryFlow
import li.songe.gkd.util.storeFlow
import li.songe.gkd.util.tryStartActivity
import li.songe.gkd.util.usePollState
val controlNav = BottomNavItem(label = "主页", icon = Icons.Outlined.Home)
@ -65,10 +66,6 @@ fun useControlPage(): ScaffoldExt {
val gkdAccessRunning by GkdAbService.isRunning.collectAsState()
val manageRunning by ManageService.isRunning.collectAsState()
val canDrawOverlays by usePollState { Settings.canDrawOverlays(context) }
val canNotif by usePollState {
NotificationManagerCompat.from(context).areNotificationsEnabled()
}
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
val scrollState = rememberScrollState()
return ScaffoldExt(navItem = controlNav,
@ -106,39 +103,15 @@ fun useControlPage(): ScaffoldExt {
)
})
}
HorizontalDivider()
if (!canNotif) {
AuthCard(title = "通知权限",
desc = "用于显示各类服务状态数据及前后台提示",
onAuthClick = {
checkOrRequestNotifPermission(context)
})
HorizontalDivider()
}
if (!canDrawOverlays) {
AuthCard(
title = "悬浮窗权限",
desc = "用于后台提示,显示保存快照按钮等功能",
onAuthClick = {
val intent = Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.tryStartActivity(intent)
})
HorizontalDivider()
}
TextSwitch(
name = "常驻通知",
desc = "通知栏显示运行状态及统计数据",
checked = manageRunning && store.enableStatusService,
onCheckedChange = {
onCheckedChange = vm.viewModelScope.launchAsFn<Boolean> {
if (it) {
if (!checkOrRequestNotifPermission(context)) {
return@TextSwitch
if (!checkOrRequestPermission(context, notificationState)) {
return@launchAsFn
}
storeFlow.value = store.copy(
enableStatusService = true
@ -151,7 +124,6 @@ fun useControlPage(): ScaffoldExt {
ManageService.stop(context)
}
})
HorizontalDivider()
Row(
horizontalArrangement = Arrangement.SpaceBetween,
@ -178,7 +150,6 @@ fun useControlPage(): ScaffoldExt {
contentDescription = null
)
}
HorizontalDivider()
Row(
horizontalArrangement = Arrangement.SpaceBetween,
@ -203,7 +174,6 @@ fun useControlPage(): ScaffoldExt {
contentDescription = null
)
}
HorizontalDivider()
if (ruleSummary.slowGroupCount > 0) {
Row(
@ -230,9 +200,8 @@ fun useControlPage(): ScaffoldExt {
contentDescription = null
)
}
HorizontalDivider()
}
HorizontalDivider()
Column(
modifier = Modifier
.fillMaxWidth()

View File

@ -1,10 +1,18 @@
package li.songe.gkd.ui.home
import android.provider.Settings
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.EaseInSine
import androidx.compose.animation.core.EaseOutSine
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@ -14,10 +22,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Autorenew
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.OutlinedTextField
@ -27,6 +35,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -34,6 +43,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
@ -55,11 +65,10 @@ import li.songe.gkd.ui.destinations.AboutPageDestination
import li.songe.gkd.ui.destinations.DebugPageDestination
import li.songe.gkd.util.LoadStatus
import li.songe.gkd.util.LocalNavController
import li.songe.gkd.util.authActionFlow
import li.songe.gkd.util.buildLogFile
import li.songe.gkd.util.canDrawOverlaysAuthAction
import li.songe.gkd.util.checkUpdate
import li.songe.gkd.util.checkUpdatingFlow
import li.songe.gkd.util.launchAsFn
import li.songe.gkd.util.launchTry
import li.songe.gkd.util.navigate
import li.songe.gkd.util.shareFile
@ -94,7 +103,6 @@ fun useSettingsPage(): ScaffoldExt {
val checkUpdating by checkUpdatingFlow.collectAsState()
if (showSubsIntervalDlg) {
Dialog(onDismissRequest = { showSubsIntervalDlg = false }) {
Card(
@ -335,7 +343,6 @@ fun useSettingsPage(): ScaffoldExt {
excludeFromRecents = it
)
})
HorizontalDivider()
TextSwitch(name = "前台悬浮窗",
desc = "添加透明悬浮窗,关闭可能导致不点击/点击缓慢",
@ -345,7 +352,6 @@ fun useSettingsPage(): ScaffoldExt {
enableAbFloatWindow = it
)
})
HorizontalDivider()
TextSwitch(name = "点击提示",
desc = "触发点击时提示:[${store.clickToast}]",
@ -354,15 +360,10 @@ fun useSettingsPage(): ScaffoldExt {
showToastInputDlg = true
},
onCheckedChange = {
if (it && !Settings.canDrawOverlays(context)) {
authActionFlow.value = canDrawOverlaysAuthAction
return@TextSwitch
}
storeFlow.value = store.copy(
toastWhenClick = it
)
})
HorizontalDivider()
Row(modifier = Modifier
.clickable {
@ -385,7 +386,6 @@ fun useSettingsPage(): ScaffoldExt {
)
}
}
HorizontalDivider()
TextSwitch(name = "自动更新应用",
desc = "打开应用时自动检测是否存在新版本",
@ -395,18 +395,27 @@ fun useSettingsPage(): ScaffoldExt {
autoCheckAppUpdate = it
)
})
HorizontalDivider()
SettingItem(title = if (checkUpdating) "检查更新ing" else "检查更新", onClick = {
appScope.launchTry {
if (checkUpdatingFlow.value) return@launchTry
val newVersion = checkUpdate()
if (newVersion == null) {
toast("暂无更新")
}
}
})
HorizontalDivider()
Row(
modifier = Modifier
.clickable(
onClick = appScope.launchAsFn(Dispatchers.IO) {
if (checkUpdatingFlow.value) return@launchAsFn
val newVersion = checkUpdate()
if (newVersion == null) {
toast("暂无更新")
}
}
)
.fillMaxWidth()
.padding(10.dp, 10.dp)
.defaultMinSize(minHeight = 30.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = "检查更新", fontSize = 18.sp)
RotatingLoadingIcon(loading = checkUpdating)
}
Row(modifier = Modifier
.clickable {
@ -429,7 +438,6 @@ fun useSettingsPage(): ScaffoldExt {
)
}
}
HorizontalDivider()
TextSwitch(name = "保存日志",
desc = "保存最近7天日志,关闭后无法定位解决错误",
@ -451,17 +459,14 @@ fun useSettingsPage(): ScaffoldExt {
}
}
)
HorizontalDivider()
SettingItem(title = "分享日志", onClick = {
showShareLogDlg = true
})
HorizontalDivider()
SettingItem(title = "高级模式", onClick = {
navController.navigate(DebugPageDestination)
})
HorizontalDivider()
SettingItem(title = "关于", onClick = {
navController.navigate(AboutPageDestination)
@ -488,3 +493,34 @@ private val darkThemeRadioOptions = arrayOf(
"启用" to true,
"关闭" to false,
)
@Composable
fun RotatingLoadingIcon(loading: Boolean) {
val rotation = remember { Animatable(0f) }
LaunchedEffect(loading) {
if (loading) {
rotation.animateTo(
targetValue = rotation.value + 360f,
animationSpec = tween(durationMillis = 500, easing = EaseInSine)
)
rotation.animateTo(
targetValue = rotation.value + 360f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 500, easing = LinearEasing),
repeatMode = RepeatMode.Restart
)
)
} else if (rotation.value != 0f) {
rotation.animateTo(
targetValue = rotation.value + 360f,
animationSpec = tween(durationMillis = 500, easing = EaseOutSine)
)
}
}
Icon(
imageVector = Icons.Default.Autorenew,
contentDescription = null,
modifier = Modifier.graphicsLayer(rotationZ = rotation.value)
)
}

View File

@ -1,8 +1,6 @@
package li.songe.gkd.ui.home
import android.webkit.URLUtil
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@ -22,7 +20,6 @@ import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@ -105,18 +102,6 @@ fun useSubsManagePage(): ScaffoldExt {
val refreshing by subsRefreshingFlow.collectAsState()
val pullRefreshState = rememberPullRefreshState(refreshing, { checkSubsUpdate(true) })
val lazyListState = rememberLazyListState()
val reorderableLazyColumnState = rememberReorderableLazyColumnState(lazyListState) { from, to ->
orderSubItems = orderSubItems.toMutableList().apply {
add(to.index, removeAt(from.index))
forEachIndexed { index, subsItem ->
if (subsItem.order != index) {
this[index] = subsItem.copy(order = index)
}
}
}.toImmutableList()
}
menuSubItem?.let { menuSubItemVal ->
Dialog(onDismissRequest = { menuSubItem = null }) {
Card(
@ -278,6 +263,18 @@ fun useSubsManagePage(): ScaffoldExt {
}
},
) { padding ->
val lazyListState = rememberLazyListState()
val reorderableLazyColumnState =
rememberReorderableLazyColumnState(lazyListState) { from, to ->
orderSubItems = orderSubItems.toMutableList().apply {
add(to.index, removeAt(from.index))
forEachIndexed { index, subsItem ->
if (subsItem.order != index) {
this[index] = subsItem.copy(order = index)
}
}
}.toImmutableList()
}
Box(
modifier = Modifier
.padding(padding)
@ -290,16 +287,21 @@ fun useSubsManagePage(): ScaffoldExt {
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
itemsIndexed(orderSubItems, { _, subItem -> subItem.id }) { index, subItem ->
ReorderableItem(reorderableLazyColumnState, key = subItem.id) { isDragging ->
val width by animateDpAsState(
if (isDragging) 1.dp else 0.dp,
label = "width",
)
ReorderableItem(
reorderableLazyColumnState,
key = subItem.id,
enabled = !refreshing,
) {
val interactionSource = remember { MutableInteractionSource() }
Card(
onClick = { menuSubItem = subItem },
onClick = {
if (!refreshing) {
menuSubItem = subItem
}
},
modifier = Modifier
.longPressDraggableHandle(
enabled = !refreshing,
interactionSource = interactionSource,
onDragStopped = {
val changeItems = orderSubItems.filter { newItem ->
@ -312,13 +314,8 @@ fun useSubsManagePage(): ScaffoldExt {
}
},
)
.animateItemPlacement()
.padding(vertical = 3.dp, horizontal = 8.dp),
elevation = CardDefaults.cardElevation(draggedElevation = 10.dp),
shape = RoundedCornerShape(8.dp),
border = if (isDragging) BorderStroke(
width, MaterialTheme.colorScheme.primary
) else null,
interactionSource = interactionSource,
) {
SubsItemCard(

View File

@ -12,6 +12,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import li.songe.gkd.app
import li.songe.gkd.appScope
import li.songe.gkd.data.AppInfo
@ -89,21 +90,27 @@ private fun updateAppInfo(appId: String) {
}
}
val appRefreshingFlow = MutableStateFlow(false)
suspend fun initOrResetAppInfoCache() {
if (updateAppMutex.isLocked) return
appRefreshingFlow.value = true
updateAppMutex.withLock {
val oldAppIds = appInfoCacheFlow.value.keys
val appMap = appInfoCacheFlow.value.toMutableMap()
app.packageManager.getInstalledPackages(0).forEach { packageInfo ->
if (!oldAppIds.contains(packageInfo.packageName)) {
val info = packageInfo.toAppInfo()
if (info != null) {
appMap[packageInfo.packageName] = info
withContext(Dispatchers.IO) {
app.packageManager.getInstalledPackages(0).forEach { packageInfo ->
if (!oldAppIds.contains(packageInfo.packageName)) {
val info = packageInfo.toAppInfo()
if (info != null) {
appMap[packageInfo.packageName] = info
}
}
}
}
appInfoCacheFlow.value = appMap.toImmutableMap()
}
appRefreshingFlow.value = false
}
fun initAppState() {

View File

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

View File

@ -1,19 +1,8 @@
package li.songe.gkd.util
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.dylanc.activityresult.launcher.PickContentLauncher
import com.dylanc.activityresult.launcher.RequestPermissionLauncher
import com.dylanc.activityresult.launcher.StartActivityLauncher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext
val LocalLauncher =
@ -21,27 +10,3 @@ val LocalLauncher =
val LocalPickContentLauncher =
compositionLocalOf<PickContentLauncher> { error("not found LocalPickContentLauncher") }
val LocalRequestPermissionLauncher =
compositionLocalOf<RequestPermissionLauncher> { error("not found RequestPermissionLauncher") }
@Composable
fun <T> usePollState(
context: CoroutineContext = Dispatchers.Default,
interval: Long = 1000L,
getter: () -> T,
): MutableState<T> {
val mutableState = remember { mutableStateOf(getter()) }
LaunchedEffect(Unit) {
withContext(context) {
while (isActive) {
delay(interval)
mutableState.value = getter()
}
}
}
return mutableState
}

View File

@ -53,6 +53,8 @@ fun <T> CoroutineScope.launchAsFn(
launch(context, start) {
try {
block(it)
} catch (e: CancellationException) {
e.printStackTrace()
} catch (e: Exception) {
e.printStackTrace()
Toaster.show(e.message ?: e.stackTraceToString())

View File

@ -1,6 +1,7 @@
package li.songe.gkd.util
import com.blankj.utilcode.util.LogUtils
import com.blankj.utilcode.util.NetworkUtils
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import kotlinx.collections.immutable.ImmutableList
@ -16,6 +17,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@ -36,10 +38,41 @@ val subsItemsFlow by lazy {
.stateIn(appScope, SharingStarted.Eagerly, persistentListOf())
}
val subsIdToRawFlow by lazy {
MutableStateFlow<ImmutableMap<Long, RawSubscription>>(persistentMapOf())
data class SubsEntry(
val subsItem: SubsItem,
val subscription: RawSubscription?,
) {
val checkUpdateUrl = run {
val checkUpdateUrl = subscription?.checkUpdateUrl ?: return@run null
val updateUrl = subscription.updateUrl ?: subsItem.updateUrl ?: return@run checkUpdateUrl
try {
return@run URI(updateUrl).resolve(checkUpdateUrl).toString()
} catch (e: Exception) {
e.printStackTrace()
}
return@run null
}
}
val subsLoadErrorsFlow = MutableStateFlow<ImmutableMap<Long, Exception>>(persistentMapOf())
val subsRefreshErrorsFlow = MutableStateFlow<ImmutableMap<Long, Exception>>(persistentMapOf())
val subsIdToRawFlow = MutableStateFlow<ImmutableMap<Long, RawSubscription>>(persistentMapOf())
val subsEntriesFlow by lazy {
combine(
subsItemsFlow,
subsIdToRawFlow,
) { subsItems, subsIdToRaw ->
subsItems.map { s ->
SubsEntry(
subsItem = s,
subscription = subsIdToRaw[s.id],
)
}.toImmutableList()
}.stateIn(appScope, SharingStarted.Eagerly, persistentListOf())
}
private val updateSubsFileMutex by lazy { Mutex() }
fun updateSubscription(subscription: RawSubscription) {
appScope.launchTry {
@ -51,10 +84,19 @@ fun updateSubscription(subscription: RawSubscription) {
newMap[subscription.id] = subscription
}
subsIdToRawFlow.value = newMap.toImmutableMap()
if (subsLoadErrorsFlow.value.contains(subscription.id)) {
subsLoadErrorsFlow.update {
it.toMutableMap().apply {
remove(subscription.id)
}.toImmutableMap()
}
}
withContext(Dispatchers.IO) {
DbSet.subsItemDao.updateMtime(subscription.id, System.currentTimeMillis())
subsFolder.resolve("${subscription.id}.json")
.writeText(json.encodeToString(subscription))
}
LogUtils.d("更新订阅文件:id=${subscription.id},name=${subscription.name}")
}
}
}
@ -92,33 +134,6 @@ fun getGroupRawEnable(
} ?: group.enable ?: true
}
data class SubsEntry(
val subsItem: SubsItem,
val subscription: RawSubscription?,
) {
val checkUpdateUrl = run {
val checkUpdateUrl = subscription?.checkUpdateUrl ?: return@run null
val updateUrl = subscription.updateUrl ?: subsItem.updateUrl ?: return@run checkUpdateUrl
try {
return@run URI(updateUrl).resolve(checkUpdateUrl).toString()
} catch (e: Exception) {
e.printStackTrace()
}
return@run null
}
}
val subsEntriesFlow by lazy {
combine(subsItemsFlow, subsIdToRawFlow) { subsItems, subsIdToRaw ->
subsItems.map { s ->
SubsEntry(
subsItem = s,
subscription = subsIdToRaw[s.id]
)
}.toImmutableList()
}.stateIn(appScope, SharingStarted.Eagerly, persistentListOf())
}
data class RuleSummary(
val globalRules: ImmutableList<GlobalRule> = persistentListOf(),
val globalGroups: ImmutableList<ResolvedGlobalGroup> = persistentListOf(),
@ -276,30 +291,44 @@ val ruleSummaryFlow by lazy {
}.stateIn(appScope, SharingStarted.Eagerly, RuleSummary())
}
private fun loadSubs(id: Long): RawSubscription {
val file = subsFolder.resolve("${id}.json")
if (!file.exists()) {
error("订阅文件不存在")
}
val subscription = try {
RawSubscription.parse(file.readText())
} catch (e: Exception) {
throw Exception("订阅文件解析失败", e)
}
if (subscription.id != id) {
error("订阅文件id不一致")
}
return subscription
}
private fun refreshRawSubsList(items: List<SubsItem>) {
val subscriptions = subsIdToRawFlow.value.toMutableMap()
val errors = subsLoadErrorsFlow.value.toMutableMap()
items.forEach { s ->
try {
subscriptions[s.id] = loadSubs(s.id)
errors.remove(s.id)
} catch (e: Exception) {
errors[s.id] = e
}
}
subsIdToRawFlow.value = subscriptions.toImmutableMap()
subsLoadErrorsFlow.value = errors.toImmutableMap()
}
fun initSubsState() {
subsItemsFlow.value
appScope.launchTry(Dispatchers.IO) {
subsRefreshingFlow.value = true
if (subsFolder.exists() && subsFolder.isDirectory) {
updateSubsFileMutex.withLock {
val filenames = DbSet.subsItemDao.queryAll().map { s -> "${s.id}.json" }
val files =
subsFolder.listFiles { f -> f.isFile && filenames.contains(f.name) }
?: emptyArray()
val subscriptions = files.mapNotNull { f ->
try {
RawSubscription.parse(f.readText())
} catch (e: Exception) {
LogUtils.d("加载订阅文件失败", e)
null
}
}
val newMap = subsIdToRawFlow.value.toMutableMap()
subscriptions.forEach { s ->
newMap[s.id] = s
}
subsIdToRawFlow.value = newMap.toImmutableMap()
}
updateSubsFileMutex.withLock {
val items = DbSet.subsItemDao.queryAll()
refreshRawSubsList(items)
}
subsRefreshingFlow.value = false
}
@ -308,77 +337,96 @@ fun initSubsState() {
private val updateSubsMutex by lazy { Mutex() }
val subsRefreshingFlow = MutableStateFlow(false)
private suspend fun updateSubs(subsEntry: SubsEntry): RawSubscription? {
val subsItem = subsEntry.subsItem
val subsRaw = subsEntry.subscription
if (subsItem.updateUrl == null || subsItem.id < 0) return null
val checkUpdateUrl = subsEntry.checkUpdateUrl
if (checkUpdateUrl != null && subsRaw != null) {
try {
val subsVersion = json.decodeFromString<SubsVersion>(
json5ToJson(
client.get(checkUpdateUrl).bodyAsText()
)
)
LogUtils.d(
"快速检测更新:id=${subsRaw.id},version=${subsRaw.version}",
subsVersion
)
if (subsVersion.id == subsRaw.id && subsVersion.version <= subsRaw.version) {
return null
}
} catch (e: Exception) {
LogUtils.d("快速检测更新失败", subsItem, e)
}
}
val updateUrl = subsRaw?.updateUrl ?: subsItem.updateUrl
val text = try {
client.get(updateUrl).bodyAsText()
} catch (e: Exception) {
throw Exception("请求更新链接失败", e)
}
val newSubsRaw = try {
RawSubscription.parse(text)
} catch (e: Exception) {
throw Exception("解析文本失败", e)
}
if (newSubsRaw.id != subsItem.id) {
error("新id=${newSubsRaw.id}不匹配旧id=${subsItem.id}")
}
if (subsRaw != null && newSubsRaw.version <= subsRaw.version) {
LogUtils.d(
"版本号不满足条件:id=${subsItem.id}",
"${subsRaw.version} -> ${newSubsRaw.version}"
)
return null
}
return newSubsRaw
}
fun checkSubsUpdate(showToast: Boolean = false) = appScope.launchTry(Dispatchers.IO) {
if (updateSubsMutex.isLocked || subsRefreshingFlow.value) {
return@launchTry
}
subsRefreshingFlow.value = true
updateSubsMutex.withLock {
if (subsRefreshingFlow.value) return@withLock
subsRefreshingFlow.value = true
LogUtils.d("开始检测更新")
val subsEntries = subsEntriesFlow.value
subsEntries.find { e -> e.subsItem.id == -2L && e.subscription == null }?.let { e ->
updateSubscription(
RawSubscription(
id = e.subsItem.id,
name = "本地订阅",
version = 0
)
)
if (!NetworkUtils.isAvailable()) {
if (showToast) {
toast("网络不可用")
}
return@withLock
}
LogUtils.d("开始检测更新")
val localSubsEntries =
subsEntriesFlow.value.filter { e -> e.subsItem.id < 0 && e.subscription == null }
val subsEntries = subsEntriesFlow.value.filter { e -> e.subsItem.id >= 0 }
refreshRawSubsList(localSubsEntries.map { e -> e.subsItem })
var successNum = 0
subsEntries.forEach { subsEntry ->
val subsItem = subsEntry.subsItem
val subsRaw = subsEntry.subscription
if (subsItem.updateUrl == null || subsItem.id < 0) return@forEach
val checkUpdateUrl = subsEntry.checkUpdateUrl
try {
if (checkUpdateUrl != null && subsRaw != null) {
try {
val subsVersion = json.decodeFromString<SubsVersion>(
json5ToJson(
client.get(checkUpdateUrl).bodyAsText()
)
)
LogUtils.d(
"快速检测更新:id=${subsRaw.id},version=${subsRaw.version}",
subsVersion
)
if (subsVersion.id == subsRaw.id && subsVersion.version <= subsRaw.version) {
return@forEach
}
} catch (e: Exception) {
LogUtils.d("快速检测更新失败", subsItem, e)
val newSubsRaw = updateSubs(subsEntry)
if (newSubsRaw != null) {
updateSubscription(newSubsRaw)
successNum++
}
if (subsRefreshErrorsFlow.value.contains(subsEntry.subsItem.id)) {
subsRefreshErrorsFlow.update {
it.toMutableMap().apply {
remove(subsEntry.subsItem.id)
}.toImmutableMap()
}
}
val newSubsRaw = RawSubscription.parse(
client.get(subsRaw?.updateUrl ?: subsItem.updateUrl).bodyAsText()
)
if (newSubsRaw.id != subsItem.id) {
LogUtils.d("id不匹配", newSubsRaw.id, subsItem.id)
return@forEach
}
if (subsRaw != null && newSubsRaw.version <= subsRaw.version) {
LogUtils.d(
"版本号不满足条件:id=${subsItem.id}",
"${subsRaw.version} -> ${newSubsRaw.version}"
)
return@forEach
}
updateSubscription(newSubsRaw)
DbSet.subsItemDao.update(
subsItem.copy(
mtime = System.currentTimeMillis()
)
)
successNum++
LogUtils.d("更新订阅文件:id=${subsItem.id},name=${newSubsRaw.name}")
} catch (e: Exception) {
e.printStackTrace()
subsRefreshErrorsFlow.update {
it.toMutableMap().apply {
set(subsEntry.subsItem.id, e)
}.toImmutableMap()
}
LogUtils.d("检测更新失败", e)
}
}
subsRefreshingFlow.value = false
if (showToast) {
if (successNum > 0) {
toast("更新 $successNum 条订阅")
@ -387,6 +435,7 @@ fun checkSubsUpdate(showToast: Boolean = false) = appScope.launchTry(Dispatchers
}
}
LogUtils.d("结束检测更新")
delay(500)
}
delay(500)
subsRefreshingFlow.value = false
}

View File

@ -15,6 +15,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.blankj.utilcode.util.AppUtils
import com.blankj.utilcode.util.NetworkUtils
import io.ktor.client.call.body
import io.ktor.client.plugins.onDownload
import io.ktor.client.request.get
@ -53,6 +54,9 @@ val newVersionFlow by lazy { MutableStateFlow<NewVersion?>(null) }
val downloadStatusFlow by lazy { MutableStateFlow<LoadStatus<String>?>(null) }
suspend fun checkUpdate(): NewVersion? {
if (checkUpdatingFlow.value) return null
if (!NetworkUtils.isAvailable()) {
error("网络不可用")
}
checkUpdatingFlow.value = true
try {
val newVersion = client.get(UPDATE_URL).body<NewVersion>()

View File

@ -71,6 +71,7 @@ coil_gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" }
reorderable = { module = "sh.calvin.reorderable:reorderable", version = "1.5.2" }
exp4j = { module = "net.objecthunter:exp4j", version = "0.4.8" }
toaster = { module = "com.github.getActivity:Toaster", version = "12.6" }
permissions = { module = "com.github.getActivity:XXPermissions", version = "18.63" }
[plugins]
android_library = { id = "com.android.library", version.ref = "android" }