mirror of
https://github.com/gkd-kit/gkd.git
synced 2026-09-03 07:19:59 +08:00
feat: 优化快照流程与相关界面
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
package li.songe.gkd
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
@@ -14,6 +16,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -36,9 +39,13 @@ import li.songe.gkd.ui.share.LocalMainViewModel
|
||||
import li.songe.gkd.ui.app.AppRoot
|
||||
import li.songe.gkd.util.BarUtils
|
||||
import li.songe.gkd.util.LogUtils
|
||||
import li.songe.gkd.util.SystemDownloads
|
||||
import li.songe.gkd.util.fixSomeProblems
|
||||
import li.songe.gkd.util.launchTry
|
||||
import li.songe.gkd.util.mapState
|
||||
import li.songe.gkd.util.toast
|
||||
import li.songe.gkd.util.tryStartActivity
|
||||
import java.io.File
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlin.reflect.jvm.jvmName
|
||||
|
||||
@@ -51,6 +58,29 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
var topBarWindowInsets by mutableStateOf(WindowInsets(top = BarUtils.getStatusBarHeight()))
|
||||
|
||||
fun shareFile(file: File, title: String) {
|
||||
val uri = FileProvider.getUriForFile(
|
||||
app,
|
||||
"${app.packageName}.provider",
|
||||
file,
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
tryStartActivity(Intent.createChooser(intent, title))
|
||||
}
|
||||
|
||||
suspend fun saveFileToDownloads(file: File) {
|
||||
if (!mainVm.permissionRequests.ensurePermissions(PermissionStates.writeExternalStorage)) {
|
||||
return
|
||||
}
|
||||
if (!SystemDownloads.save(file)) return
|
||||
toast("已保存 ${file.name} 到下载")
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
enableEdgeToEdge()
|
||||
|
||||
@@ -15,7 +15,7 @@ import li.songe.gkd.appScope
|
||||
import li.songe.gkd.store.storeFlow
|
||||
import li.songe.gkd.util.LogUtils
|
||||
import li.songe.gkd.util.ScreenUtils
|
||||
import li.songe.gkd.util.SnapshotExt
|
||||
import li.songe.gkd.snapshot.SnapshotCapture
|
||||
import li.songe.gkd.util.SubscriptionResult
|
||||
import li.songe.gkd.util.SubscriptionStore
|
||||
import li.songe.gkd.util.UpdateTimeOption
|
||||
@@ -95,6 +95,7 @@ private val a11yEventTransform by lazy {
|
||||
context(event: AccessibilityEvent)
|
||||
private fun watchCaptureScreenshot() {
|
||||
if (!storeFlow.value.captureScreenshot) return
|
||||
if (SnapshotCapture.isCapturing) return
|
||||
if (event.packageName != storeFlow.value.screenshotTargetAppId) return
|
||||
if (tempEventSelector.first != storeFlow.value.screenshotEventSelector) {
|
||||
tempEventSelector =
|
||||
@@ -105,7 +106,7 @@ private fun watchCaptureScreenshot() {
|
||||
if (it == null) return
|
||||
}
|
||||
appScope.launchTry {
|
||||
SnapshotExt.captureSnapshot()
|
||||
SnapshotCapture.capture()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +154,7 @@ private fun createVolumeReceiver() = object : BroadcastReceiver() {
|
||||
if (t - lastVolumeTriggerTime > 3000 && !ScreenUtils.isScreenLock()) {
|
||||
lastVolumeTriggerTime = t
|
||||
appScope.launchTry {
|
||||
SnapshotExt.captureSnapshot()
|
||||
SnapshotCapture.capture()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,12 @@ 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.serialization.Serializable
|
||||
import li.songe.gkd.util.SnapshotExt
|
||||
import li.songe.gkd.snapshot.SnapshotStore
|
||||
import li.songe.gkd.util.format
|
||||
|
||||
@Entity(
|
||||
@@ -34,7 +33,7 @@ data class Snapshot(
|
||||
|
||||
val date by lazy { id.format("MM-dd HH:mm:ss") }
|
||||
|
||||
val screenshotFile by lazy { SnapshotExt.screenshotFile(id) }
|
||||
val screenshotFile by lazy { SnapshotStore.screenshotFile(id) }
|
||||
|
||||
@Dao
|
||||
interface SnapshotDao {
|
||||
@@ -44,9 +43,6 @@ data class Snapshot(
|
||||
@Insert
|
||||
suspend fun insert(vararg users: Snapshot): List<Long>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertOrIgnore(vararg users: Snapshot): List<Long>
|
||||
|
||||
@Query("DELETE FROM snapshot")
|
||||
suspend fun deleteAll()
|
||||
|
||||
@@ -66,5 +62,3 @@ data class Snapshot(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import li.songe.gkd.service.EventService
|
||||
import li.songe.gkd.service.HttpService
|
||||
import li.songe.gkd.service.ScreenshotService
|
||||
import li.songe.gkd.service.TrackService
|
||||
import li.songe.gkd.snapshot.SnapshotScreenshotStatus
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
enum class ForegroundNotificationKey(
|
||||
@@ -124,10 +125,19 @@ object NotificationCatalog {
|
||||
text = "任务完成后自动关闭",
|
||||
)
|
||||
|
||||
fun snapshotSaved(text: String) = PostedNotification(
|
||||
fun snapshotSaved(
|
||||
appName: String,
|
||||
activityId: String?,
|
||||
screenshotStatus: SnapshotScreenshotStatus,
|
||||
savedToDownloads: Boolean,
|
||||
) = PostedNotification(
|
||||
key = PostedNotificationKey.SnapshotSaved,
|
||||
title = "快照已保存",
|
||||
text = text,
|
||||
title = "快照已保存 · $appName",
|
||||
text = buildList {
|
||||
activityId?.let(::add)
|
||||
screenshotStatus.detailText()?.let(::add)
|
||||
if (savedToDownloads) add("已保存至下载")
|
||||
}.joinToString(separator = " · ").takeIf { it.isNotEmpty() },
|
||||
uri = "gkd://page/2",
|
||||
)
|
||||
|
||||
|
||||
@@ -32,11 +32,7 @@ class CompatAccessibilityManager {
|
||||
check(serviceDump.isNotBlank()) {
|
||||
"AccessibilityManagerService dump is empty"
|
||||
}
|
||||
return if (AndroidTarget.P) {
|
||||
containsModernUiAutomation(serviceDump)
|
||||
} else {
|
||||
containsLegacyUiAutomation(serviceDump)
|
||||
}
|
||||
return containsUiAutomation(serviceDump)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,11 +41,8 @@ private val legacyUserStateDumpRegex = Regex("""User state\[attributes:\{([\s\S]
|
||||
private val legacyCurrentUserRegex = Regex("""\bcurrentUser\s*=\s*true\b""")
|
||||
private val legacyUiAutomationDumpRegex = Regex("""\bService\[""")
|
||||
|
||||
fun containsModernUiAutomation(dump: String): Boolean {
|
||||
return uiAutomationDumpRegex.containsMatchIn(dump)
|
||||
}
|
||||
|
||||
fun containsLegacyUiAutomation(dump: String): Boolean {
|
||||
fun containsUiAutomation(dump: String): Boolean {
|
||||
if (uiAutomationDumpRegex.containsMatchIn(dump)) return true
|
||||
return legacyUserStateDumpRegex.findAll(dump).any { result ->
|
||||
val attributes = result.groupValues[1]
|
||||
legacyCurrentUserRegex.containsMatchIn(attributes) &&
|
||||
|
||||
@@ -2,6 +2,7 @@ package li.songe.gkd.priv
|
||||
|
||||
import android.content.Context
|
||||
import android.view.IWindowManager
|
||||
import android.view.WindowManager
|
||||
import priv.kit.core.binder.PrivilegeBinderWrapper
|
||||
|
||||
class CompatWindowManager {
|
||||
@@ -45,4 +46,53 @@ class CompatWindowManager {
|
||||
ROTATION_WITH_CALLER -> value.thawRotation(caller)
|
||||
else -> throw NoSuchMethodException("IWindowManager.thawRotation")
|
||||
}
|
||||
|
||||
fun isFocusedWindowSecure(appId: String): Boolean? {
|
||||
return parseFocusedWindowSecure(value.asBinder().dump("visible-apps"), appId)
|
||||
}
|
||||
}
|
||||
|
||||
private val focusedWindowIdRegex = Regex("""Window\{([^\s}]+)""")
|
||||
private val windowHeaderRegex = Regex("""(?m)^\s*Window(?:\s+#\d+)?\s+Window\{""")
|
||||
private val windowFlagSeparatorRegex = Regex("""[\s|]+""")
|
||||
private val secureWindowFlagNames = setOf("SECURE", "FLAG_SECURE")
|
||||
|
||||
fun parseFocusedWindowSecure(windowDump: String, appId: String): Boolean? {
|
||||
if (appId.isBlank()) return null
|
||||
val focusLine = windowDump.lineSequence()
|
||||
.firstOrNull { line -> "mCurrentFocus=Window{" in line }
|
||||
?: return null
|
||||
if (!Regex("""(?:^|\s)${Regex.escape(appId)}/""").containsMatchIn(focusLine)) {
|
||||
return null
|
||||
}
|
||||
val windowId = focusedWindowIdRegex.find(focusLine)?.groupValues?.get(1)
|
||||
?: return null
|
||||
val focusedWindowHeaderRegex = Regex(
|
||||
"""(?m)^\s*Window(?:\s+#\d+)?\s+Window\{${Regex.escape(windowId)}[\s}][^\r\n]*"""
|
||||
)
|
||||
val header = focusedWindowHeaderRegex.find(windowDump) ?: return null
|
||||
val nextHeader = windowHeaderRegex.find(windowDump, header.range.last + 1)
|
||||
val blockEnd = nextHeader?.range?.first ?: windowDump.length
|
||||
val focusedWindowBlock = windowDump.substring(header.range.first, blockEnd)
|
||||
val rawFlags = focusedWindowBlock.substringAfter(" fl=", missingDelimiterValue = "")
|
||||
if (rawFlags.isEmpty()) return null
|
||||
val flagTokens = rawFlags
|
||||
.substringBefore('}')
|
||||
.trim()
|
||||
.split(windowFlagSeparatorRegex)
|
||||
.takeWhile { token -> '=' !in token }
|
||||
if (flagTokens.isEmpty()) return null
|
||||
if (flagTokens.any { token -> token in secureWindowFlagNames }) {
|
||||
return true
|
||||
}
|
||||
val numericFlags = flagTokens.first()
|
||||
.removePrefix("#")
|
||||
.removePrefix("0x")
|
||||
.removePrefix("0X")
|
||||
.toLongOrNull(16)
|
||||
return if (numericFlags != null) {
|
||||
numericFlags and WindowManager.LayoutParams.FLAG_SECURE.toLong() != 0L
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,11 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import li.songe.gkd.MainViewModel
|
||||
import li.songe.gkd.appScope
|
||||
import li.songe.gkd.notif.NotificationCatalog
|
||||
import li.songe.gkd.notif.StopServiceReceiver
|
||||
import li.songe.gkd.permission.PermissionStates
|
||||
import li.songe.gkd.ui.component.PerfIcon
|
||||
import li.songe.gkd.util.SnapshotExt
|
||||
import li.songe.gkd.snapshot.SnapshotCapture
|
||||
import li.songe.gkd.util.launchTry
|
||||
import li.songe.gkd.util.startForegroundServiceByClass
|
||||
import li.songe.gkd.util.stopServiceByClass
|
||||
@@ -22,9 +21,14 @@ import li.songe.gkd.util.stopServiceByClass
|
||||
class ButtonService : OverlayWindowService(
|
||||
positionKey = "button"
|
||||
) {
|
||||
override fun onClickView() = appScope.launchTry {
|
||||
SnapshotExt.captureSnapshot()
|
||||
}.let { }
|
||||
override fun onClickView() {
|
||||
if (isOverlayContentHidden) return
|
||||
scope.launchTry {
|
||||
withAllOverlaysHidden {
|
||||
SnapshotCapture.capture()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLongClickView() = stopSelf()
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -22,19 +21,12 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.getAndUpdate
|
||||
@@ -54,8 +46,7 @@ import li.songe.gkd.priv.uiAutomationFlow
|
||||
import li.songe.gkd.ui.EventLogCard
|
||||
import li.songe.gkd.ui.component.PerfIcon
|
||||
import li.songe.gkd.ui.component.PerfIconButton
|
||||
import li.songe.gkd.ui.component.isAtBottom
|
||||
import li.songe.gkd.ui.share.ListPlaceholder
|
||||
import li.songe.gkd.ui.component.rememberLazyListAutoFollowState
|
||||
import li.songe.gkd.util.launchTry
|
||||
import li.songe.gkd.util.startForegroundServiceByClass
|
||||
import li.songe.gkd.util.stopServiceByClass
|
||||
@@ -64,8 +55,6 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||
class EventService : OverlayWindowService(positionKey = "event") {
|
||||
|
||||
val eventLogs = mutableStateListOf<A11yEventLog>()
|
||||
private var tempEventId = 0
|
||||
private var firstToBottom = false
|
||||
|
||||
@Composable
|
||||
override fun ComposeContent() {
|
||||
@@ -73,19 +62,11 @@ class EventService : OverlayWindowService(positionKey = "event") {
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides contentColorFor(bgColor),
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(eventLogs.isEmpty()) { listState.scrollToItem(0) }
|
||||
val isAtBottom by listState.isAtBottom()
|
||||
val subScope = rememberCoroutineScope()
|
||||
SideEffect {
|
||||
val latestId = eventLogs.lastOrNull()?.id ?: 0
|
||||
if (tempEventId != latestId) {
|
||||
tempEventId = latestId
|
||||
if (isAtBottom) {
|
||||
subScope.launch { listState.scrollToItem(eventLogs.lastIndex) }
|
||||
}
|
||||
}
|
||||
}
|
||||
val latestEventId = eventLogs.lastOrNull()?.id ?: 0
|
||||
val followState = rememberLazyListAutoFollowState(
|
||||
itemCount = eventLogs.size,
|
||||
latestItemKey = latestEventId,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
@@ -107,7 +88,8 @@ class EventService : OverlayWindowService(positionKey = "event") {
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
state = followState.listState,
|
||||
contentPadding = PaddingValues(bottom = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(eventLogs, { it.id }) {
|
||||
@@ -116,19 +98,10 @@ class EventService : OverlayWindowService(positionKey = "event") {
|
||||
modifier = Modifier.padding(horizontal = 2.dp)
|
||||
)
|
||||
}
|
||||
item(ListPlaceholder.KEY, ListPlaceholder.TYPE) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
}
|
||||
}
|
||||
if (eventLogs.isNotEmpty() && !isAtBottom) {
|
||||
if (!firstToBottom) {
|
||||
firstToBottom = true
|
||||
SideEffect {
|
||||
subScope.launch { listState.scrollToItem(eventLogs.lastIndex) }
|
||||
}
|
||||
}
|
||||
var count by remember { mutableIntStateOf(-1) }
|
||||
LaunchedEffect(eventLogs.last().id) { count++ }
|
||||
if (eventLogs.isNotEmpty() && !followState.isAutoFollowEnabled) {
|
||||
val count = (latestEventId - followState.pausedAtItemKey)
|
||||
.coerceAtLeast(0)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
@@ -140,11 +113,7 @@ class EventService : OverlayWindowService(positionKey = "event") {
|
||||
}
|
||||
PerfIconButton(
|
||||
imageVector = PerfIcon.ArrowDownward,
|
||||
onClick = {
|
||||
subScope.launch {
|
||||
listState.scrollToItem(eventLogs.lastIndex)
|
||||
}
|
||||
},
|
||||
onClick = followState::resume,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import li.songe.gkd.appScope
|
||||
import li.songe.gkd.notif.NotificationCatalog
|
||||
import li.songe.gkd.syncFixState
|
||||
import li.songe.gkd.util.LogUtils
|
||||
import li.songe.gkd.util.SnapshotExt
|
||||
import li.songe.gkd.snapshot.SnapshotCapture
|
||||
import li.songe.gkd.util.componentName
|
||||
import li.songe.gkd.util.launchTry
|
||||
import li.songe.gkd.util.runMainPost
|
||||
@@ -34,7 +34,7 @@ class ExposeService : Service() {
|
||||
LogUtils.d("ExposeService::handleIntent", expose, data)
|
||||
when (expose) {
|
||||
-1 -> StatusService.autoStart()
|
||||
0 -> SnapshotExt.captureSnapshot()
|
||||
0 -> SnapshotCapture.capture()
|
||||
1 -> {
|
||||
toast("执行成功", forced = true)
|
||||
syncFixState()
|
||||
|
||||
@@ -54,8 +54,8 @@ import li.songe.gkd.util.LOCAL_HTTP_SUBS_ID
|
||||
import li.songe.gkd.util.LogUtils
|
||||
import li.songe.gkd.util.OnSimpleLife
|
||||
import li.songe.gkd.util.SERVER_SCRIPT_URL
|
||||
import li.songe.gkd.util.SnapshotExt
|
||||
import li.songe.gkd.util.SnapshotExt.getMinSnapshot
|
||||
import li.songe.gkd.snapshot.SnapshotCapture
|
||||
import li.songe.gkd.snapshot.SnapshotStore
|
||||
import li.songe.gkd.util.SubscriptionStore
|
||||
import li.songe.gkd.util.getIpAddressInLocalNetwork
|
||||
import li.songe.gkd.util.isPortAvailable
|
||||
@@ -194,7 +194,7 @@ private fun CoroutineScope.createServer(port: Int) = embeddedServer(CIO, port) {
|
||||
post("/getServerInfo") { call.respond(ServerInfo()) }
|
||||
post("/getSnapshot") {
|
||||
val data = call.receive<ReqId>()
|
||||
val fp = SnapshotExt.snapshotFile(data.id)
|
||||
val fp = SnapshotStore.snapshotFile(data.id)
|
||||
if (!fp.exists()) {
|
||||
throw RpcError("对应快照不存在")
|
||||
}
|
||||
@@ -202,19 +202,19 @@ private fun CoroutineScope.createServer(port: Int) = embeddedServer(CIO, port) {
|
||||
}
|
||||
post("/getScreenshot") {
|
||||
val data = call.receive<ReqId>()
|
||||
val fp = SnapshotExt.screenshotFile(data.id)
|
||||
val fp = SnapshotStore.screenshotFile(data.id)
|
||||
if (!fp.exists()) {
|
||||
throw RpcError("对应截图不存在")
|
||||
}
|
||||
call.respondFile(fp)
|
||||
}
|
||||
post("/captureSnapshot") {
|
||||
call.respond(SnapshotExt.captureSnapshot())
|
||||
call.respond(SnapshotCapture.capture())
|
||||
}
|
||||
post("/getSnapshots") {
|
||||
val list = DbSet.snapshotDao.query().first().mapNotNull {
|
||||
try {
|
||||
getMinSnapshot(it.id)
|
||||
SnapshotStore.getMinSnapshot(it.id)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
@@ -226,8 +226,7 @@ private fun CoroutineScope.createServer(port: Int) = embeddedServer(CIO, port) {
|
||||
val allSnapshots = DbSet.snapshotDao.query().first()
|
||||
val snapshot = allSnapshots.find { it.id == data.id }
|
||||
if (snapshot != null) {
|
||||
SnapshotExt.removeSnapshot(data.id)
|
||||
DbSet.snapshotDao.delete(snapshot)
|
||||
SnapshotStore.delete(snapshot)
|
||||
call.respond(RpcOk("快照删除成功"))
|
||||
} else {
|
||||
throw RpcError("快照不存在或已被删除")
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package li.songe.gkd.service
|
||||
|
||||
import android.app.Activity.RESULT_OK
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PixelFormat
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.hardware.display.VirtualDisplay
|
||||
import android.media.Image
|
||||
import android.media.ImageReader
|
||||
import android.media.projection.MediaProjection
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import androidx.core.graphics.createBitmap
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import li.songe.gkd.app
|
||||
import li.songe.gkd.util.LogUtils
|
||||
import li.songe.gkd.util.ScreenUtils
|
||||
import li.songe.gkd.util.isFullTransparent
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
// https://github.com/npes87184/ScreenShareTile/blob/master/app/src/main/java/com/npes87184/screenshottile/ScreenshotService.kt
|
||||
|
||||
class MediaProjectionScreenshotSession(
|
||||
private val screenshotIntent: Intent,
|
||||
private val onProjectionStop: () -> Unit,
|
||||
) : AutoCloseable {
|
||||
private val handlerThread = HandlerThread("gkd-screenshot").apply { start() }
|
||||
private val handler = Handler(handlerThread.looper)
|
||||
|
||||
@Volatile
|
||||
private var closed = false
|
||||
private var projectionStopped = false
|
||||
private var mediaProjection: MediaProjection? = null
|
||||
private var virtualDisplay: VirtualDisplay? = null
|
||||
private var activeImageReader: ImageReader? = null
|
||||
private var activeContinuation: CancellableContinuation<Bitmap>? = null
|
||||
|
||||
private val mediaProjectionCallback = object : MediaProjection.Callback() {
|
||||
override fun onStop() {
|
||||
projectionStopped = true
|
||||
val continuation = activeContinuation
|
||||
releaseActiveCapture()
|
||||
virtualDisplay?.release()
|
||||
virtualDisplay = null
|
||||
mediaProjection?.unregisterCallback(this)
|
||||
mediaProjection = null
|
||||
if (continuation?.isActive == true) {
|
||||
continuation.resumeWithException(
|
||||
IllegalStateException("截屏授权已失效")
|
||||
)
|
||||
}
|
||||
onProjectionStop()
|
||||
}
|
||||
}
|
||||
|
||||
private val width: Int
|
||||
get() = ScreenUtils.getScreenWidth()
|
||||
private val height: Int
|
||||
get() = ScreenUtils.getScreenHeight()
|
||||
private val dpi: Int
|
||||
get() = ScreenUtils.getScreenDensityDpi()
|
||||
|
||||
@Synchronized
|
||||
override fun close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
if (!handler.post(::closeOnHandler)) {
|
||||
handlerThread.quitSafely()
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeOnHandler() {
|
||||
val continuation = activeContinuation
|
||||
releaseActiveCapture()
|
||||
virtualDisplay?.release()
|
||||
virtualDisplay = null
|
||||
mediaProjection?.let { projection ->
|
||||
projection.unregisterCallback(mediaProjectionCallback)
|
||||
projection.stop()
|
||||
}
|
||||
mediaProjection = null
|
||||
if (continuation?.isActive == true) {
|
||||
continuation.resumeWithException(
|
||||
IllegalStateException("截屏服务已停止")
|
||||
)
|
||||
}
|
||||
handlerThread.quitSafely()
|
||||
}
|
||||
|
||||
suspend fun capture(): Bitmap = suspendCancellableCoroutine { continuation ->
|
||||
continuation.invokeOnCancellation {
|
||||
handler.post {
|
||||
if (activeContinuation === continuation) {
|
||||
releaseActiveCapture()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!handler.post { startCapture(continuation) } && continuation.isActive) {
|
||||
continuation.resumeWithException(
|
||||
IllegalStateException("截屏线程不可用")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startCapture(continuation: CancellableContinuation<Bitmap>) {
|
||||
if (!continuation.isActive) return
|
||||
if (closed || projectionStopped) {
|
||||
continuation.resumeWithException(
|
||||
IllegalStateException("截屏服务不可用")
|
||||
)
|
||||
return
|
||||
}
|
||||
if (activeContinuation != null) {
|
||||
continuation.resumeWithException(
|
||||
IllegalStateException("正在截取屏幕")
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
var imageReader: ImageReader? = null
|
||||
try {
|
||||
val captureWidth = width
|
||||
val captureHeight = height
|
||||
val captureDpi = dpi
|
||||
val projection = mediaProjection ?: (
|
||||
app.mediaProjectionManager.getMediaProjection(
|
||||
RESULT_OK,
|
||||
screenshotIntent,
|
||||
) ?: throw IllegalStateException("获取截屏授权失败")
|
||||
).also {
|
||||
it.registerCallback(mediaProjectionCallback, handler)
|
||||
mediaProjection = it
|
||||
}
|
||||
imageReader = ImageReader.newInstance(
|
||||
captureWidth,
|
||||
captureHeight,
|
||||
PixelFormat.RGBA_8888,
|
||||
2,
|
||||
)
|
||||
val display = virtualDisplay
|
||||
if (display == null) {
|
||||
virtualDisplay = projection.createVirtualDisplay(
|
||||
"screenshot",
|
||||
captureWidth,
|
||||
captureHeight,
|
||||
captureDpi,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
imageReader.surface,
|
||||
null,
|
||||
handler,
|
||||
) ?: throw IllegalStateException("创建截屏虚拟显示失败")
|
||||
} else {
|
||||
display.resize(captureWidth, captureHeight, captureDpi)
|
||||
display.surface = imageReader.surface
|
||||
}
|
||||
activeImageReader = imageReader
|
||||
activeContinuation = continuation
|
||||
imageReader.setOnImageAvailableListener(
|
||||
{ reader -> handleImageAvailable(reader, continuation) },
|
||||
handler,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (activeImageReader === imageReader) {
|
||||
releaseActiveCapture()
|
||||
} else {
|
||||
imageReader?.close()
|
||||
}
|
||||
if (continuation.isActive) {
|
||||
continuation.resumeWithException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleImageAvailable(
|
||||
reader: ImageReader,
|
||||
continuation: CancellableContinuation<Bitmap>,
|
||||
) {
|
||||
if (activeContinuation !== continuation || !continuation.isActive) {
|
||||
if (activeContinuation === continuation) {
|
||||
releaseActiveCapture()
|
||||
}
|
||||
return
|
||||
}
|
||||
var image: Image? = null
|
||||
var bitmapWithStride: Bitmap? = null
|
||||
var bitmap: Bitmap? = null
|
||||
var result: Bitmap? = null
|
||||
var failure: Exception? = null
|
||||
try {
|
||||
image = reader.acquireLatestImage() ?: return
|
||||
val plane = image.planes[0]
|
||||
val rowWidth = plane.rowStride / plane.pixelStride
|
||||
bitmapWithStride = createBitmap(rowWidth, image.height)
|
||||
bitmapWithStride.copyPixelsFromBuffer(plane.buffer)
|
||||
bitmap = Bitmap.createBitmap(
|
||||
bitmapWithStride,
|
||||
0,
|
||||
0,
|
||||
reader.width,
|
||||
reader.height,
|
||||
)
|
||||
if (bitmap === bitmapWithStride) {
|
||||
bitmapWithStride = null
|
||||
}
|
||||
if (bitmap.isFullTransparent()) {
|
||||
return
|
||||
}
|
||||
result = bitmap
|
||||
bitmap = null
|
||||
} catch (e: Exception) {
|
||||
failure = e
|
||||
} finally {
|
||||
bitmap?.recycle()
|
||||
bitmapWithStride?.recycle()
|
||||
image?.close()
|
||||
}
|
||||
releaseActiveCapture()
|
||||
result?.let { captured ->
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(captured)
|
||||
} else {
|
||||
captured.recycle()
|
||||
}
|
||||
}
|
||||
failure?.let { error ->
|
||||
if (continuation.isActive) {
|
||||
continuation.resumeWithException(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseActiveCapture() {
|
||||
activeImageReader?.setOnImageAvailableListener(null, null)
|
||||
try {
|
||||
virtualDisplay?.surface = null
|
||||
} catch (e: Exception) {
|
||||
LogUtils.d("释放截屏 Surface 失败", e)
|
||||
}
|
||||
activeImageReader?.close()
|
||||
activeImageReader = null
|
||||
activeContinuation = null
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,18 @@ import android.view.MotionEvent
|
||||
import android.view.ViewConfiguration
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.animation.doOnEnd
|
||||
@@ -37,6 +42,7 @@ import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import li.songe.gkd.a11y.topActivityFlow
|
||||
import li.songe.gkd.app
|
||||
import li.songe.gkd.permission.PermissionStates
|
||||
@@ -54,6 +60,7 @@ import li.songe.gkd.util.px
|
||||
import li.songe.gkd.util.runMainPost
|
||||
import li.songe.gkd.util.throttle
|
||||
import li.songe.gkd.util.toast
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.abs
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@@ -73,6 +80,7 @@ private fun OverlayWindowService.useShareContext(): ShareContext {
|
||||
|
||||
private class ShareContext {
|
||||
var count = 0
|
||||
var overlayContentHidden by mutableStateOf(false)
|
||||
val scope = MainScope()
|
||||
val positionMapFlow = createAnyFlow<Map<String, List<Int>>>(
|
||||
key = "overlay_position",
|
||||
@@ -168,12 +176,46 @@ abstract class OverlayWindowService(
|
||||
setViewTreeLifecycleOwner(this@OverlayWindowService)
|
||||
setContent {
|
||||
AppTheme(invertedTheme = true) {
|
||||
ComposeContent()
|
||||
Box(
|
||||
modifier = Modifier.drawWithContent {
|
||||
if (!shareContext.overlayContentHidden) drawContent()
|
||||
},
|
||||
) {
|
||||
ComposeContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected val isOverlayContentHidden
|
||||
get() = shareContext.overlayContentHidden
|
||||
|
||||
protected suspend fun <T> withAllOverlaysHidden(block: suspend () -> T): T {
|
||||
shareContext.overlayContentHidden = true
|
||||
return try {
|
||||
awaitOverlayContentHidden()
|
||||
block()
|
||||
} finally {
|
||||
shareContext.overlayContentHidden = false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitOverlayContentHidden() =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val afterFrame = Runnable {
|
||||
if (continuation.isActive) continuation.resume(Unit)
|
||||
}
|
||||
val onFrame = Runnable {
|
||||
if (continuation.isActive) view.post(afterFrame)
|
||||
}
|
||||
continuation.invokeOnCancellation {
|
||||
view.removeCallbacks(onFrame)
|
||||
view.removeCallbacks(afterFrame)
|
||||
}
|
||||
view.postOnAnimation(onFrame)
|
||||
}
|
||||
|
||||
private val minMargin get() = 10.dp.px.toInt()
|
||||
private val defaultPosition get() = listOf(minMargin, BarUtils.getStatusBarHeight())
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package li.songe.gkd.service
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import coil3.Bitmap
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import li.songe.gkd.app
|
||||
@@ -11,8 +12,8 @@ import li.songe.gkd.notif.StopServiceReceiver
|
||||
import li.songe.gkd.util.DefaultSimpleLifeImpl
|
||||
import li.songe.gkd.util.LogUtils
|
||||
import li.songe.gkd.util.OnSimpleLife
|
||||
import li.songe.gkd.util.ScreenshotUtil
|
||||
import li.songe.gkd.util.componentName
|
||||
import li.songe.gkd.util.runMainPost
|
||||
import li.songe.gkd.util.stopServiceByClass
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@@ -26,14 +27,26 @@ class ScreenshotService : Service(), OnSimpleLife by DefaultSimpleLifeImpl() {
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
} finally {
|
||||
intent?.let {
|
||||
screenshotUtil?.destroy()
|
||||
screenshotUtil = ScreenshotUtil(intent)
|
||||
captureSession?.close()
|
||||
captureSession = createCaptureSession(intent)
|
||||
LogUtils.d("screenshot restart")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var screenshotUtil: ScreenshotUtil? = null
|
||||
private var captureSession: MediaProjectionScreenshotSession? = null
|
||||
|
||||
private fun createCaptureSession(intent: Intent): MediaProjectionScreenshotSession {
|
||||
lateinit var created: MediaProjectionScreenshotSession
|
||||
created = MediaProjectionScreenshotSession(intent) {
|
||||
runMainPost {
|
||||
if (captureSession === created) {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
init {
|
||||
useLogLifecycle()
|
||||
@@ -45,7 +58,7 @@ class ScreenshotService : Service(), OnSimpleLife by DefaultSimpleLifeImpl() {
|
||||
}
|
||||
onCreated { instance = this }
|
||||
onDestroyed {
|
||||
screenshotUtil?.destroy()
|
||||
captureSession?.close()
|
||||
instance = null
|
||||
}
|
||||
}
|
||||
@@ -55,8 +68,15 @@ class ScreenshotService : Service(), OnSimpleLife by DefaultSimpleLifeImpl() {
|
||||
val isRunning = MutableStateFlow(false)
|
||||
suspend fun screenshot(): Bitmap? {
|
||||
if (!isRunning.value) return null
|
||||
return withTimeoutOrNull(5000.milliseconds) {
|
||||
instance?.screenshotUtil?.execute()
|
||||
return try {
|
||||
withTimeoutOrNull(5000.milliseconds) {
|
||||
instance?.captureSession?.capture()
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtils.d("截取屏幕失败", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import kotlinx.coroutines.isActive
|
||||
import li.songe.gkd.a11y.A11yRuleEngine
|
||||
import li.songe.gkd.appScope
|
||||
import li.songe.gkd.util.LogUtils
|
||||
import li.songe.gkd.util.SnapshotExt
|
||||
import li.songe.gkd.snapshot.SnapshotCapture
|
||||
import li.songe.gkd.util.launchTry
|
||||
import li.songe.gkd.util.toast
|
||||
|
||||
@@ -54,7 +54,7 @@ private fun execSnapshot() {
|
||||
} else if (latestAppId != oldAppId) {
|
||||
ok = true
|
||||
LogUtils.d("SnapshotTileService::eventExecutor.execute")
|
||||
appScope.launchTry { SnapshotExt.captureSnapshot(forcedCropStatusBar = true) }
|
||||
appScope.launchTry { SnapshotCapture.capture(forcedCropStatusBar = true) }
|
||||
break
|
||||
} else {
|
||||
A11yRuleEngine.performActionBack()
|
||||
|
||||
316
app/src/main/kotlin/li/songe/gkd/snapshot/SnapshotCapture.kt
Normal file
316
app/src/main/kotlin/li/songe/gkd/snapshot/SnapshotCapture.kt
Normal file
@@ -0,0 +1,316 @@
|
||||
package li.songe.gkd.snapshot
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.scale
|
||||
import androidx.core.graphics.set
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.withContext
|
||||
import li.songe.gkd.a11y.A11yRuleEngine
|
||||
import li.songe.gkd.a11y.topActivityFlow
|
||||
import li.songe.gkd.data.ComplexSnapshot
|
||||
import li.songe.gkd.data.RpcError
|
||||
import li.songe.gkd.data.info2nodeList
|
||||
import li.songe.gkd.notif.NotificationCatalog
|
||||
import li.songe.gkd.priv.privilegeContextFlow
|
||||
import li.songe.gkd.service.ScreenshotService
|
||||
import li.songe.gkd.store.storeFlow
|
||||
import li.songe.gkd.util.AndroidTarget
|
||||
import li.songe.gkd.util.AutomatorModeOption
|
||||
import li.songe.gkd.util.BarUtils
|
||||
import li.songe.gkd.util.LogUtils
|
||||
import li.songe.gkd.util.ScreenUtils
|
||||
import li.songe.gkd.util.SystemDownloads
|
||||
import li.songe.gkd.util.getShowActivityId
|
||||
import li.songe.gkd.util.px
|
||||
import li.songe.gkd.util.toast
|
||||
import kotlin.math.min
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
object SnapshotCapture {
|
||||
private val captureMutex = Mutex()
|
||||
val isCapturing: Boolean
|
||||
get() = captureMutex.isLocked
|
||||
|
||||
private data class ScreenResult(
|
||||
val bitmap: Bitmap,
|
||||
val status: SnapshotScreenshotStatus,
|
||||
)
|
||||
|
||||
private fun createMissingScreenshotBitmap(): Bitmap {
|
||||
val bitmap = createBitmap(ScreenUtils.getScreenWidth(), ScreenUtils.getScreenHeight())
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textSize = 32.sp.px
|
||||
color = Color.BLUE
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
val canvas = Canvas(bitmap)
|
||||
val lines = listOf("未获取到屏幕画面", "请手动替换截图")
|
||||
lines.forEachIndexed { index, line ->
|
||||
canvas.drawText(
|
||||
line,
|
||||
bitmap.width / 2f,
|
||||
(bitmap.height / 2f) +
|
||||
(index - lines.size / 2f) * (paint.textSize + 4.sp.px),
|
||||
paint,
|
||||
)
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private fun createBlankScreenshotBitmap(): Bitmap {
|
||||
return createBitmap(ScreenUtils.getScreenWidth(), ScreenUtils.getScreenHeight()).apply {
|
||||
eraseColor(Color.BLACK)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cropStatusBar(bitmap: Bitmap): Bitmap {
|
||||
val mutableBitmap = bitmap.run {
|
||||
if (!isMutable || config == Bitmap.Config.HARDWARE) {
|
||||
copy(Bitmap.Config.ARGB_8888, true)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
val barHeight = min(BarUtils.getStatusBarHeight(), mutableBitmap.height)
|
||||
for (x in 0 until mutableBitmap.width) {
|
||||
for (y in 0 until barHeight) {
|
||||
mutableBitmap[x, y] = 0
|
||||
}
|
||||
}
|
||||
return mutableBitmap
|
||||
}
|
||||
|
||||
private fun looksLikeBlankScreenshot(bitmap: Bitmap): Boolean {
|
||||
fun Bitmap.recycleIfTemporary() {
|
||||
if (this !== bitmap) recycle()
|
||||
}
|
||||
|
||||
val size = 64
|
||||
val scaled = bitmap.scale(size, size, false)
|
||||
val softwareBitmap = if (scaled.config == Bitmap.Config.HARDWARE) {
|
||||
val copy = scaled.copy(Bitmap.Config.ARGB_8888, false)
|
||||
scaled.recycleIfTemporary()
|
||||
copy ?: return false
|
||||
} else {
|
||||
scaled
|
||||
}
|
||||
val pixels = IntArray(size * size)
|
||||
softwareBitmap.getPixels(pixels, 0, size, 0, 0, size, size)
|
||||
softwareBitmap.recycleIfTemporary()
|
||||
val ignoredEdge = (size * 0.08).toInt()
|
||||
var sum = 0.0
|
||||
var sumSq = 0.0
|
||||
var count = 0
|
||||
var nearBlackCount = 0
|
||||
val step = 2
|
||||
for (y in ignoredEdge until size - ignoredEdge step step) {
|
||||
for (x in ignoredEdge until size - ignoredEdge step step) {
|
||||
val pixel = pixels[y * size + x]
|
||||
val red = (pixel shr 16) and 0xff
|
||||
val green = (pixel shr 8) and 0xff
|
||||
val blue = pixel and 0xff
|
||||
val luminance = 0.299 * red + 0.587 * green + 0.114 * blue
|
||||
sum += luminance
|
||||
sumSq += luminance * luminance
|
||||
count++
|
||||
if (luminance < 10) nearBlackCount++
|
||||
}
|
||||
}
|
||||
if (count == 0) return false
|
||||
val mean = sum / count
|
||||
val variance = sumSq / count - mean * mean
|
||||
val blackRatio = nearBlackCount.toDouble() / count
|
||||
return variance < 15.0 && blackRatio > 0.85 && mean < 15.0
|
||||
}
|
||||
|
||||
private suspend fun resolveActivityId(appId: String): String? {
|
||||
privilegeContextFlow.value?.run {
|
||||
topCpn()?.className
|
||||
}?.let { return it }
|
||||
var topActivity = topActivityFlow.value
|
||||
var waited = 0L
|
||||
while (topActivity.appId != appId && waited < 2000) {
|
||||
delay(100.milliseconds)
|
||||
topActivity = topActivityFlow.value
|
||||
waited += 100
|
||||
}
|
||||
return topActivity.activityId.takeIf { topActivity.appId == appId }
|
||||
}
|
||||
|
||||
private suspend fun isFocusedWindowSecure(appId: String): Boolean? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
privilegeContextFlow.value?.run {
|
||||
wmManager.isFocusedWindowSecure(appId)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtils.d("读取前台窗口 FLAG_SECURE 失败", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun captureScreen(
|
||||
appId: String,
|
||||
automatorMode: AutomatorModeOption,
|
||||
forcedCropStatusBar: Boolean,
|
||||
): ScreenResult {
|
||||
// Android 14+ 的部分 ROM(已在 Android 16 HyperOS 上复现)不会在 FLAG_SECURE
|
||||
// 窗口下回调 IWindowManager.captureDisplay 的 listener,读取 buffer 会等待系统 4 秒后超时。
|
||||
// 自动化模式先检查窗口标志,命中后跳过特权截图,避免无意义的等待。
|
||||
val checkSecureBeforeCapture =
|
||||
automatorMode == AutomatorModeOption.AutomationMode && AndroidTarget.UPSIDE_DOWN_CAKE
|
||||
val focusedWindowSecure = if (checkSecureBeforeCapture) {
|
||||
isFocusedWindowSecure(appId)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val a11yScreenshot = if (focusedWindowSecure == true) {
|
||||
null
|
||||
} else {
|
||||
try {
|
||||
A11yRuleEngine.screenshot()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtils.d("无障碍截图失败", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
val rawPicture = a11yScreenshot ?: ScreenshotService.screenshot()
|
||||
val (bitmap, status) = when {
|
||||
rawPicture == null && focusedWindowSecure == true -> {
|
||||
createBlankScreenshotBitmap() to SnapshotScreenshotStatus.LikelyProtected
|
||||
}
|
||||
|
||||
rawPicture == null -> {
|
||||
createMissingScreenshotBitmap() to SnapshotScreenshotStatus.Unavailable
|
||||
}
|
||||
|
||||
looksLikeBlankScreenshot(rawPicture) -> {
|
||||
val secure = if (checkSecureBeforeCapture) {
|
||||
focusedWindowSecure
|
||||
} else {
|
||||
isFocusedWindowSecure(appId)
|
||||
}
|
||||
val status = if (secure == false) {
|
||||
SnapshotScreenshotStatus.Captured
|
||||
} else {
|
||||
SnapshotScreenshotStatus.LikelyProtected
|
||||
}
|
||||
rawPicture to status
|
||||
}
|
||||
|
||||
else -> {
|
||||
rawPicture to SnapshotScreenshotStatus.Captured
|
||||
}
|
||||
}
|
||||
val processedBitmap = if (
|
||||
status == SnapshotScreenshotStatus.Captured &&
|
||||
storeFlow.value.hideSnapshotStatusBar &&
|
||||
(forcedCropStatusBar || BarUtils.checkStatusBarVisible() == true)
|
||||
) {
|
||||
cropStatusBar(bitmap).also { cropped ->
|
||||
if (cropped !== bitmap) bitmap.recycle()
|
||||
}
|
||||
} else {
|
||||
bitmap
|
||||
}
|
||||
return ScreenResult(processedBitmap, status)
|
||||
}
|
||||
|
||||
suspend fun capture(forcedCropStatusBar: Boolean = false): ComplexSnapshot {
|
||||
val engine = A11yRuleEngine.instance ?: throw RpcError("服务不可用,请先授权")
|
||||
if (!captureMutex.tryLock()) {
|
||||
throw RpcError("正在保存快照,不可重复操作")
|
||||
}
|
||||
try {
|
||||
val rootNode = engine.safeActiveWindow
|
||||
?: throw RpcError("当前应用没有无障碍信息,捕获失败")
|
||||
val snapshotId = System.currentTimeMillis()
|
||||
val appId = rootNode.packageName.toString()
|
||||
val screenHeight = ScreenUtils.getScreenHeight()
|
||||
val screenWidth = ScreenUtils.getScreenWidth()
|
||||
val isLandscape = ScreenUtils.isLandscape()
|
||||
val (snapshot, screenResult) = coroutineScope {
|
||||
val nodes = async(Dispatchers.IO) { info2nodeList(rootNode) }
|
||||
val activityId = async(Dispatchers.IO) { resolveActivityId(appId) }
|
||||
val capturedScreen = async(Dispatchers.Default) {
|
||||
captureScreen(appId, engine.service.mode, forcedCropStatusBar)
|
||||
}
|
||||
val result = capturedScreen.await()
|
||||
try {
|
||||
ComplexSnapshot(
|
||||
id = snapshotId,
|
||||
appId = appId,
|
||||
activityId = activityId.await(),
|
||||
screenHeight = screenHeight,
|
||||
screenWidth = screenWidth,
|
||||
isLandscape = isLandscape,
|
||||
nodes = nodes.await(),
|
||||
) to result
|
||||
} catch (e: Throwable) {
|
||||
result.bitmap.recycle()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
SnapshotStore.save(snapshot, screenResult.bitmap)
|
||||
} finally {
|
||||
screenResult.bitmap.recycle()
|
||||
}
|
||||
val savedToDownloads = if (
|
||||
storeFlow.value.autoSaveSnapshotToDownloads && SystemDownloads.canSave()
|
||||
) {
|
||||
try {
|
||||
val archive = SnapshotStore.createArchive(
|
||||
snapshot.id,
|
||||
snapshot.appId,
|
||||
snapshot.activityId,
|
||||
)
|
||||
try {
|
||||
SystemDownloads.save(archive)
|
||||
} finally {
|
||||
SnapshotStore.deleteArchive(archive)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtils.d("自动保存快照至下载失败", e)
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
val appName = snapshot.appInfo?.name ?: snapshot.appId
|
||||
NotificationCatalog.snapshotSaved(
|
||||
appName = appName,
|
||||
activityId = getShowActivityId(snapshot.appId, snapshot.activityId),
|
||||
screenshotStatus = screenResult.status,
|
||||
savedToDownloads = savedToDownloads,
|
||||
).post()
|
||||
val statusDetail = screenResult.status.detailText()
|
||||
val toastText = if (statusDetail == null) {
|
||||
"快照已保存"
|
||||
} else {
|
||||
"快照已保存 ($statusDetail)"
|
||||
}
|
||||
toast(toastText, forced = true)
|
||||
return snapshot
|
||||
} finally {
|
||||
captureMutex.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package li.songe.gkd.snapshot
|
||||
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
// 文件重命名和数据库发布必须作为不可取消的提交阶段完成,耗时写入仍应响应取消。
|
||||
suspend fun commitSnapshotDirectory(
|
||||
layout: SnapshotFileLayout,
|
||||
id: Long,
|
||||
write: (SnapshotFileLayout.Files) -> Unit,
|
||||
publish: suspend () -> Unit,
|
||||
) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
val target = layout.committed(id)
|
||||
val staging = layout.staging(id)
|
||||
if (target.directory.exists()) {
|
||||
throw IOException("目标目录已存在: ${target.directory.name}")
|
||||
}
|
||||
staging.directory.deleteIfExists()
|
||||
if (!staging.directory.mkdirs()) {
|
||||
throw IOException("无法创建临时目录: ${staging.directory.name}")
|
||||
}
|
||||
try {
|
||||
write(staging)
|
||||
currentCoroutineContext().ensureActive()
|
||||
withContext(NonCancellable) {
|
||||
if (!staging.directory.renameTo(target.directory)) {
|
||||
throw IOException("无法提交目录: ${target.directory.name}")
|
||||
}
|
||||
try {
|
||||
publish()
|
||||
} catch (e: Throwable) {
|
||||
try {
|
||||
target.directory.deleteIfExists()
|
||||
} catch (cleanupError: IOException) {
|
||||
e.addSuppressed(cleanupError)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
try {
|
||||
withContext(NonCancellable) {
|
||||
staging.directory.deleteIfExists()
|
||||
}
|
||||
} catch (cleanupError: IOException) {
|
||||
e.addSuppressed(cleanupError)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun File.deleteIfExists() {
|
||||
if (exists() && !deleteRecursively()) {
|
||||
throw IOException("无法删除目录: $name")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package li.songe.gkd.snapshot
|
||||
|
||||
import java.io.File
|
||||
|
||||
class SnapshotFileLayout(
|
||||
private val root: File,
|
||||
) {
|
||||
class Files(
|
||||
private val id: Long,
|
||||
val directory: File,
|
||||
) {
|
||||
val snapshotFile: File
|
||||
get() = directory.resolve("$id.json")
|
||||
val minSnapshotFile: File
|
||||
get() = directory.resolve("$id.min.json")
|
||||
val webpFile: File
|
||||
get() = directory.resolve("$id.webp")
|
||||
val legacyPngFile: File
|
||||
get() = directory.resolve("$id.png")
|
||||
val screenshotFile: File
|
||||
get() = validScreenshotFile ?: webpFile.takeIf { it.exists() } ?: legacyPngFile
|
||||
val hasCompleteFiles: Boolean
|
||||
get() = snapshotFile.isFile && snapshotFile.length() > 0 &&
|
||||
validScreenshotFile != null
|
||||
|
||||
private val validScreenshotFile: File?
|
||||
get() = when {
|
||||
webpFile.hasSupportedImageHeader() -> webpFile
|
||||
legacyPngFile.hasSupportedImageHeader() -> legacyPngFile
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun committed(id: Long): Files {
|
||||
return Files(id, root.resolve(id.toString()))
|
||||
}
|
||||
|
||||
fun staging(id: Long): Files {
|
||||
return Files(id, root.resolve(".$id.tmp"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun File.hasSupportedImageHeader(): Boolean {
|
||||
if (!isFile || length() < 2) return false
|
||||
return runCatching {
|
||||
val header = ByteArray(12)
|
||||
val size = inputStream().use { it.read(header) }
|
||||
fun matches(vararg bytes: Int): Boolean {
|
||||
return size >= bytes.size && bytes.indices.all { index ->
|
||||
header[index].toInt() and 0xff == bytes[index]
|
||||
}
|
||||
}
|
||||
matches(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a) ||
|
||||
matches(0xff, 0xd8, 0xff) ||
|
||||
matches(0x52, 0x49, 0x46, 0x46) &&
|
||||
size >= 12 && header.copyOfRange(8, 12).contentEquals("WEBP".encodeToByteArray()) ||
|
||||
matches(0x47, 0x49, 0x46, 0x38, 0x37, 0x61) ||
|
||||
matches(0x47, 0x49, 0x46, 0x38, 0x39, 0x61) ||
|
||||
matches(0x42, 0x4d)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package li.songe.gkd.snapshot
|
||||
|
||||
enum class SnapshotScreenshotStatus {
|
||||
Captured,
|
||||
Unavailable,
|
||||
LikelyProtected,
|
||||
;
|
||||
|
||||
fun detailText(): String? = when (this) {
|
||||
Captured -> null
|
||||
Unavailable -> "未获取到屏幕画面"
|
||||
LikelyProtected -> "当前界面可能受截图保护"
|
||||
}
|
||||
}
|
||||
215
app/src/main/kotlin/li/songe/gkd/snapshot/SnapshotStore.kt
Normal file
215
app/src/main/kotlin/li/songe/gkd/snapshot/SnapshotStore.kt
Normal file
@@ -0,0 +1,215 @@
|
||||
package li.songe.gkd.snapshot
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.system.Os
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import li.songe.gkd.data.ComplexSnapshot
|
||||
import li.songe.gkd.data.Snapshot
|
||||
import li.songe.gkd.db.DbSet
|
||||
import li.songe.gkd.util.LogUtils
|
||||
import li.songe.gkd.util.ZipUtils
|
||||
import li.songe.gkd.util.appInfoMapFlow
|
||||
import li.songe.gkd.util.clearCache
|
||||
import li.songe.gkd.util.json
|
||||
import li.songe.gkd.util.keepNullJson
|
||||
import li.songe.gkd.util.sharedDir
|
||||
import li.songe.gkd.util.snapshotFolder
|
||||
import li.songe.gkd.util.webpLossyCompressFormat
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
|
||||
object SnapshotStore {
|
||||
private val mutationMutex = Mutex()
|
||||
private val fileLayout by lazy { SnapshotFileLayout(snapshotFolder) }
|
||||
|
||||
fun snapshotFile(id: Long): File = fileLayout.committed(id).snapshotFile
|
||||
|
||||
fun screenshotFile(id: Long): File = fileLayout.committed(id).screenshotFile
|
||||
|
||||
suspend fun getMinSnapshot(id: Long): JsonObject = mutationMutex.withLock {
|
||||
val files = fileLayout.committed(id)
|
||||
val cachedText = withContext(Dispatchers.IO) {
|
||||
files.minSnapshotFile.takeIf { it.isFile && it.length() > 0 }?.readText()
|
||||
}
|
||||
if (cachedText != null) {
|
||||
val cachedSnapshot = withContext(Dispatchers.Default) {
|
||||
runCatching { json.decodeFromString<JsonObject>(cachedText) }.getOrNull()
|
||||
}
|
||||
if (cachedSnapshot != null) return@withLock cachedSnapshot
|
||||
}
|
||||
val text = withContext(Dispatchers.IO) { files.snapshotFile.readText() }
|
||||
val snapshotJson = withContext(Dispatchers.Default) {
|
||||
// #1185
|
||||
json.decodeFromString<JsonObject>(text)
|
||||
}
|
||||
val minSnapshot = JsonObject(snapshotJson.toMutableMap().apply {
|
||||
this["nodes"] = JsonArray(emptyList())
|
||||
})
|
||||
withContext(Dispatchers.IO) {
|
||||
files.minSnapshotFile.writeText(keepNullJson.encodeToString(minSnapshot))
|
||||
}
|
||||
minSnapshot
|
||||
}
|
||||
|
||||
suspend fun delete(snapshot: Snapshot) {
|
||||
mutationMutex.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
fileLayout.committed(snapshot.id).directory.deleteRecursivelyOrThrow()
|
||||
DbSet.snapshotDao.delete(snapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteAll() = mutationMutex.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
snapshotFolder.listFiles()?.forEach { file ->
|
||||
file.deleteRecursivelyOrThrow()
|
||||
}
|
||||
DbSet.snapshotDao.deleteAll()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun replaceScreenshot(snapshot: Snapshot, newBytes: ByteArray): Boolean =
|
||||
mutationMutex.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
val files = fileLayout.committed(snapshot.id)
|
||||
val oldBitmap = BitmapFactory.decodeFile(files.screenshotFile.absolutePath)
|
||||
?: return@withContext false
|
||||
val newBitmap = BitmapFactory.decodeByteArray(newBytes, 0, newBytes.size)
|
||||
if (newBitmap == null) {
|
||||
oldBitmap.recycle()
|
||||
return@withContext false
|
||||
}
|
||||
val sameSize = oldBitmap.width == newBitmap.width &&
|
||||
oldBitmap.height == newBitmap.height
|
||||
oldBitmap.recycle()
|
||||
if (!sameSize) {
|
||||
newBitmap.recycle()
|
||||
return@withContext false
|
||||
}
|
||||
val tempFile = files.directory.resolve(
|
||||
".${files.webpFile.name}.${System.nanoTime()}.tmp"
|
||||
)
|
||||
try {
|
||||
FileOutputStream(tempFile).use { stream ->
|
||||
if (!newBitmap.compress(webpLossyCompressFormat, 85, stream)) {
|
||||
throw IOException("替换截图压缩失败")
|
||||
}
|
||||
stream.fd.sync()
|
||||
}
|
||||
Os.rename(tempFile.absolutePath, files.webpFile.absolutePath)
|
||||
if (files.legacyPngFile.exists() && !files.legacyPngFile.delete()) {
|
||||
LogUtils.d("无法删除旧快照截图", files.legacyPngFile.absolutePath)
|
||||
}
|
||||
if (snapshot.githubAssetId != null) {
|
||||
DbSet.snapshotDao.deleteGithubAssetId(snapshot.id)
|
||||
}
|
||||
true
|
||||
} finally {
|
||||
newBitmap.recycle()
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createArchive(
|
||||
snapshotId: Long,
|
||||
appId: String? = null,
|
||||
activityId: String? = null,
|
||||
): File =
|
||||
mutationMutex.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
val filename = if (appId != null) {
|
||||
val appName = appInfoMapFlow.value[appId]?.name
|
||||
?.filterNot { char -> char in "\\/:*?\"<>|" || char <= ' ' }
|
||||
if (activityId != null) {
|
||||
"${(appName ?: appId).take(20)}_${
|
||||
activityId.split('.').last().take(40)
|
||||
}-${snapshotId}.zip"
|
||||
} else {
|
||||
"${(appName ?: appId).take(20)}-${snapshotId}.zip"
|
||||
}
|
||||
} else {
|
||||
"${snapshotId}.zip"
|
||||
}
|
||||
require(File(filename).name == filename) { "无效压缩包名称" }
|
||||
clearCache()
|
||||
val outputDirectory = sharedDir.resolve(
|
||||
"snapshot-$snapshotId-${UUID.randomUUID()}"
|
||||
)
|
||||
if (!outputDirectory.mkdirs()) {
|
||||
throw IOException("无法创建快照压缩目录")
|
||||
}
|
||||
val outputFile = outputDirectory.resolve(filename)
|
||||
try {
|
||||
val files = fileLayout.committed(snapshotId)
|
||||
if (!files.hasCompleteFiles) {
|
||||
throw IOException("快照文件不完整: $snapshotId")
|
||||
}
|
||||
if (!ZipUtils.zipFiles(
|
||||
listOf(files.snapshotFile, files.screenshotFile),
|
||||
outputFile,
|
||||
)
|
||||
) {
|
||||
throw IOException("快照压缩失败")
|
||||
}
|
||||
outputFile
|
||||
} catch (e: Throwable) {
|
||||
if (!outputDirectory.deleteRecursively()) {
|
||||
e.addSuppressed(IOException("无法清理快照压缩目录"))
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteArchive(file: File) = withContext(NonCancellable + Dispatchers.IO) {
|
||||
val directory = file.parentFile ?: return@withContext
|
||||
if (directory.parentFile != sharedDir || !directory.name.startsWith("snapshot-")) {
|
||||
return@withContext
|
||||
}
|
||||
if (directory.exists() && !directory.deleteRecursively()) {
|
||||
LogUtils.d("无法清理快照压缩目录", directory.absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun save(snapshot: ComplexSnapshot, bitmap: Bitmap): Unit = mutationMutex.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
commitSnapshotDirectory(
|
||||
layout = fileLayout,
|
||||
id = snapshot.id,
|
||||
write = { files ->
|
||||
files.webpFile.outputStream().use { stream ->
|
||||
if (!bitmap.compress(webpLossyCompressFormat, 85, stream)) {
|
||||
throw IOException("快照截图压缩失败")
|
||||
}
|
||||
}
|
||||
files.snapshotFile.writeText(
|
||||
keepNullJson.encodeToString(snapshot)
|
||||
)
|
||||
files.minSnapshotFile.writeText(
|
||||
keepNullJson.encodeToString(snapshot.copy(nodes = emptyList()))
|
||||
)
|
||||
},
|
||||
publish = {
|
||||
DbSet.snapshotDao.insert(snapshot.toSnapshot())
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun File.deleteRecursivelyOrThrow() {
|
||||
if (exists() && !deleteRecursively()) {
|
||||
throw IOException("无法删除快照文件: $name")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,9 @@ data class SettingsStore(
|
||||
val actionToast: String = META.appName,
|
||||
val autoClearMemorySubs: Boolean = false,
|
||||
val hideSnapshotStatusBar: Boolean = false,
|
||||
val autoSaveSnapshotToDownloads: Boolean = false,
|
||||
val enableDarkTheme: Boolean? = null,
|
||||
val enableDynamicColor: Boolean = true,
|
||||
val showSaveSnapshotToast: Boolean = true,
|
||||
val useSystemToast: Boolean = false,
|
||||
val useCustomNotifText: Boolean = false,
|
||||
val customNotifTitle: String = META.appName,
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -15,7 +16,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
@@ -47,6 +47,7 @@ import kotlinx.serialization.json.JsonPrimitive
|
||||
import li.songe.gkd.MainActivity
|
||||
import li.songe.gkd.data.A11yEventLog
|
||||
import li.songe.gkd.ui.component.AppNameText
|
||||
import li.songe.gkd.ui.component.CopyableText
|
||||
import li.songe.gkd.ui.component.EmptyText
|
||||
import li.songe.gkd.ui.component.FixedTimeText
|
||||
import li.songe.gkd.ui.component.AppAlertDialog
|
||||
@@ -191,23 +192,15 @@ fun A11yEventLogPage() {
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(text = "事件数据")
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
SelectionContainer(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = eventText,
|
||||
modifier = textModifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
CopyIcon(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp),
|
||||
onClick = {
|
||||
copyText(eventText.text)
|
||||
})
|
||||
}
|
||||
CopyableText(
|
||||
text = eventText,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.extraSmall)
|
||||
.background(MaterialTheme.colorScheme.tertiaryContainer),
|
||||
contentPadding = PaddingValues(horizontal = 4.dp),
|
||||
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
if (eventLog.isStateChanged) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
val selectorText = remember(eventLog.id) {
|
||||
|
||||
@@ -33,8 +33,6 @@ import li.songe.gkd.util.format
|
||||
import li.songe.gkd.util.getShareApkFile
|
||||
import li.songe.gkd.util.launchAsFn
|
||||
import li.songe.gkd.util.openUri
|
||||
import li.songe.gkd.util.saveFileToDownloads
|
||||
import li.songe.gkd.util.shareFile
|
||||
|
||||
@Composable
|
||||
fun AboutDialogs() {
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
package li.songe.gkd.ui
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.activity.compose.LocalActivity
|
||||
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.Row
|
||||
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.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
@@ -25,34 +20,26 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.serialization.Serializable
|
||||
import li.songe.gkd.MainActivity
|
||||
import li.songe.gkd.R
|
||||
import li.songe.gkd.app
|
||||
import li.songe.gkd.permission.PermissionStates
|
||||
import li.songe.gkd.service.ActivityService
|
||||
import li.songe.gkd.service.ButtonService
|
||||
import li.songe.gkd.service.EventService
|
||||
import li.songe.gkd.service.HttpService
|
||||
import li.songe.gkd.service.ScreenshotService
|
||||
import li.songe.gkd.store.storeFlow
|
||||
import li.songe.gkd.ui.component.CustomOutlinedTextField
|
||||
import li.songe.gkd.ui.component.AppAlertDialog
|
||||
import li.songe.gkd.ui.component.PerfCustomIconButton
|
||||
import li.songe.gkd.ui.component.PerfIcon
|
||||
@@ -64,12 +51,11 @@ import li.songe.gkd.ui.component.TextSwitch
|
||||
import li.songe.gkd.ui.component.autoFocus
|
||||
import li.songe.gkd.ui.share.LocalMainViewModel
|
||||
import li.songe.gkd.ui.style.EmptyHeight
|
||||
import li.songe.gkd.ui.style.itemPadding
|
||||
import li.songe.gkd.ui.style.TABULAR_NUMBERS_FONT_FEATURE
|
||||
import li.songe.gkd.ui.style.itemHorizontalPadding
|
||||
import li.songe.gkd.ui.style.itemVerticalPadding
|
||||
import li.songe.gkd.ui.style.titleItemPadding
|
||||
import li.songe.gkd.util.AndroidTarget
|
||||
import li.songe.gkd.util.ShortUrlSet
|
||||
import li.songe.gkd.util.launchAsFn
|
||||
import li.songe.gkd.util.launchTry
|
||||
import li.songe.gkd.util.throttle
|
||||
|
||||
@Serializable
|
||||
@@ -82,17 +68,14 @@ fun AdvancedPage() {
|
||||
|
||||
@Composable
|
||||
private fun AdvancedContent() {
|
||||
val context = LocalActivity.current as MainActivity
|
||||
val mainVm = LocalMainViewModel.current
|
||||
val vm = viewModel<AdvancedVm>()
|
||||
val scope = vm.scope
|
||||
val showEditPortDialog by vm.showEditPortDialogFlow.collectAsStateWithLifecycle()
|
||||
val showCaptureScreenshotDialog by vm.showCaptureScreenshotDialogFlow.collectAsStateWithLifecycle()
|
||||
val showHttpSettingsDialog by vm.httpSettingsDialogVisibleFlow.collectAsStateWithLifecycle()
|
||||
val store by storeFlow.collectAsStateWithLifecycle()
|
||||
val httpServer by HttpService.httpServerFlow.collectAsStateWithLifecycle()
|
||||
val localNetworkIps by HttpService.localNetworkIpsFlow.collectAsStateWithLifecycle()
|
||||
val screenshotServiceRunning by ScreenshotService.isRunning.collectAsStateWithLifecycle()
|
||||
val buttonServiceRunning by ButtonService.isRunning.collectAsStateWithLifecycle()
|
||||
val activityServiceRunning by ActivityService.isRunning.collectAsStateWithLifecycle()
|
||||
val eventServiceRunning by EventService.isRunning.collectAsStateWithLifecycle()
|
||||
@@ -120,25 +103,6 @@ private fun AdvancedContent() {
|
||||
}
|
||||
}
|
||||
|
||||
fun setScreenshotServiceEnabled(enabled: Boolean) {
|
||||
scope.launchTry {
|
||||
if (!enabled) {
|
||||
ScreenshotService.stop()
|
||||
return@launchTry
|
||||
}
|
||||
if (!mainVm.permissionRequests.ensurePermissions(PermissionStates.notification)) {
|
||||
return@launchTry
|
||||
}
|
||||
val activityResult = mainVm.activityResults.startActivity(
|
||||
app.mediaProjectionManager.createScreenCaptureIntent(),
|
||||
)
|
||||
val intent = activityResult.data
|
||||
if (activityResult.resultCode == Activity.RESULT_OK && intent != null) {
|
||||
ScreenshotService.start(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showEditPortDialog) {
|
||||
EditHttpPortDialog(
|
||||
currentPort = store.httpServerPort,
|
||||
@@ -151,23 +115,6 @@ private fun AdvancedContent() {
|
||||
)
|
||||
}
|
||||
|
||||
if (showCaptureScreenshotDialog) {
|
||||
CaptureScreenshotConfigDialog(
|
||||
appId = store.screenshotTargetAppId,
|
||||
eventSelector = store.screenshotEventSelector,
|
||||
onOpenHelp = {
|
||||
vm.setCaptureScreenshotDialogVisible(false)
|
||||
mainVm.navigateWebPage(ShortUrlSet.URL15)
|
||||
},
|
||||
onDismissRequest = { vm.setCaptureScreenshotDialogVisible(false) },
|
||||
onConfirm = { appId, selector ->
|
||||
if (vm.saveCaptureScreenshotConfig(appId, selector)) {
|
||||
vm.setCaptureScreenshotDialogVisible(false)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
@@ -196,64 +143,17 @@ private fun AdvancedContent() {
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
TextSwitch(
|
||||
title = "HTTP服务",
|
||||
subtitle = "在浏览器下连接调试",
|
||||
suffixIcon = {
|
||||
PerfCustomIconButton(
|
||||
size = 32.dp,
|
||||
iconSize = 20.dp,
|
||||
onClickLabel = "打开HTTP设置弹窗",
|
||||
onClick = { vm.setHttpSettingsDialogVisible(true) },
|
||||
id = R.drawable.ic_page_info,
|
||||
contentDescription = "HTTP设置",
|
||||
tint = if (showHttpSettingsDialog) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
LocalContentColor.current
|
||||
},
|
||||
)
|
||||
},
|
||||
checked = httpServer != null,
|
||||
onCheckedChange = throttle(fn = scope.launchAsFn { enabled ->
|
||||
HttpServiceItem(
|
||||
running = httpServer != null,
|
||||
settingsSelected = showHttpSettingsDialog,
|
||||
port = store.httpServerPort,
|
||||
localNetworkIps = localNetworkIps,
|
||||
onSettingsClick = { vm.setHttpSettingsDialogVisible(true) },
|
||||
onRunningChange = throttle(fn = scope.launchAsFn { enabled ->
|
||||
HttpService.setEnabled(mainVm, enabled)
|
||||
}),
|
||||
onAddressClick = mainVm::openUrl,
|
||||
)
|
||||
AnimatedVisibility(visible = httpServer != null) {
|
||||
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyMedium) {
|
||||
Column(modifier = Modifier.itemPadding()) {
|
||||
Text(text = "点击下方链接即可连接")
|
||||
Row {
|
||||
val localUrl = "http://127.0.0.1:${store.httpServerPort}"
|
||||
Text(
|
||||
text = localUrl,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = LocalTextStyle.current.copy(
|
||||
textDecoration = TextDecoration.Underline
|
||||
),
|
||||
modifier = Modifier.clickable(
|
||||
onClick = throttle { mainVm.openUrl(localUrl) }
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Text(text = "仅本设备访问")
|
||||
}
|
||||
localNetworkIps.forEach { host ->
|
||||
val lanUrl = "http://${host}:${store.httpServerPort}"
|
||||
Text(
|
||||
text = lanUrl,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = LocalTextStyle.current.copy(
|
||||
textDecoration = TextDecoration.Underline
|
||||
),
|
||||
modifier = Modifier.clickable(
|
||||
onClick = throttle { mainVm.openUrl(lanUrl) }
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "快照",
|
||||
modifier = Modifier.titleItemPadding(),
|
||||
@@ -265,14 +165,6 @@ private fun AdvancedContent() {
|
||||
subtitle = "应用界面节点信息及截图",
|
||||
onClick = { mainVm.navigatePage(SnapshotPageRoute) },
|
||||
)
|
||||
if (!AndroidTarget.R) {
|
||||
TextSwitch(
|
||||
title = "截屏服务",
|
||||
subtitle = "生成快照需要获取屏幕截图",
|
||||
checked = screenshotServiceRunning,
|
||||
onCheckedChange = ::setScreenshotServiceEnabled,
|
||||
)
|
||||
}
|
||||
TextSwitch(
|
||||
title = "快照按钮",
|
||||
subtitle = "显示按钮点击保存快照",
|
||||
@@ -281,42 +173,20 @@ private fun AdvancedContent() {
|
||||
ButtonService.setEnabled(mainVm, enabled)
|
||||
},
|
||||
)
|
||||
TextSwitch(
|
||||
title = "音量快照",
|
||||
subtitle = "音量变化时保存快照",
|
||||
checked = store.captureVolumeChange,
|
||||
onCheckedChange = vm::setCaptureVolumeChange,
|
||||
SettingItem(
|
||||
title = "快照设置",
|
||||
subtitle = "触发方式、截图处理与导出",
|
||||
onClick = { mainVm.navigatePage(SnapshotSettingsRoute) },
|
||||
)
|
||||
TextSwitch(
|
||||
title = "截屏快照",
|
||||
subtitle = "截屏时保存快照",
|
||||
checked = store.captureScreenshot,
|
||||
suffixIcon = {
|
||||
PerfCustomIconButton(
|
||||
size = 32.dp,
|
||||
iconSize = 20.dp,
|
||||
onClickLabel = "打开配置截屏快照弹窗",
|
||||
onClick = throttle { vm.setCaptureScreenshotDialogVisible(true) },
|
||||
id = R.drawable.ic_page_info,
|
||||
contentDescription = "截屏快照设置",
|
||||
)
|
||||
},
|
||||
onCheckedChange = vm::setCaptureScreenshot,
|
||||
)
|
||||
TextSwitch(
|
||||
title = "隐藏状态栏",
|
||||
subtitle = "隐藏快照截图状态栏",
|
||||
checked = store.hideSnapshotStatusBar,
|
||||
onCheckedChange = vm::setHideSnapshotStatusBar,
|
||||
)
|
||||
TextSwitch(
|
||||
title = "保存提示",
|
||||
subtitle = "提示「正在保存快照」",
|
||||
checked = store.showSaveSnapshotToast,
|
||||
onCheckedChange = vm::setShowSaveSnapshotToast,
|
||||
|
||||
Text(
|
||||
text = "上传",
|
||||
modifier = Modifier.titleItemPadding(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
SettingItem(
|
||||
title = "Github Cookie",
|
||||
title = "GitHub Cookie",
|
||||
subtitle = "生成快照/日志链接",
|
||||
suffix = "获取教程",
|
||||
suffixUnderline = true,
|
||||
@@ -362,6 +232,96 @@ private fun AdvancedContent() {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HttpServiceItem(
|
||||
running: Boolean,
|
||||
settingsSelected: Boolean,
|
||||
port: Int,
|
||||
localNetworkIps: List<String>,
|
||||
onSettingsClick: () -> Unit,
|
||||
onRunningChange: (Boolean) -> Unit,
|
||||
onAddressClick: (String) -> Unit,
|
||||
) {
|
||||
val addressStyle = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFeatureSettings = TABULAR_NUMBERS_FONT_FEATURE,
|
||||
)
|
||||
val addressItem: @Composable (String, String) -> Unit = { host, type ->
|
||||
Text(
|
||||
text = "${host}:${port} · $type",
|
||||
style = addressStyle,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
onClickLabel = "查看${type}访问地址",
|
||||
onClick = throttle { onAddressClick("http://${host}:${port}") },
|
||||
)
|
||||
.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
onClickLabel = "切换HTTP 服务状态",
|
||||
onClick = { onRunningChange(!running) },
|
||||
)
|
||||
.padding(
|
||||
start = itemHorizontalPadding,
|
||||
top = itemVerticalPadding,
|
||||
end = itemHorizontalPadding,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
) {
|
||||
TextSwitch(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
paddingDisabled = true,
|
||||
title = "HTTP 服务",
|
||||
subtitle = "通过浏览器连接调试",
|
||||
suffixIcon = {
|
||||
PerfCustomIconButton(
|
||||
size = 32.dp,
|
||||
iconSize = 20.dp,
|
||||
onClickLabel = "打开HTTP设置弹窗",
|
||||
onClick = onSettingsClick,
|
||||
id = R.drawable.ic_page_info,
|
||||
contentDescription = "HTTP设置",
|
||||
tint = if (settingsSelected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
LocalContentColor.current
|
||||
},
|
||||
)
|
||||
},
|
||||
checked = running,
|
||||
onCheckedChange = onRunningChange,
|
||||
onClick = null,
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(visible = running) {
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
start = itemHorizontalPadding,
|
||||
top = 0.dp,
|
||||
end = itemHorizontalPadding,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
) {
|
||||
addressItem("127.0.0.1", "本机")
|
||||
localNetworkIps.forEach { host ->
|
||||
addressItem(host, "局域网")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditHttpPortDialog(
|
||||
currentPort: Int,
|
||||
@@ -407,67 +367,3 @@ private fun EditHttpPortDialog(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CaptureScreenshotConfigDialog(
|
||||
appId: String,
|
||||
eventSelector: String,
|
||||
onOpenHelp: () -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
onConfirm: (String, String) -> Unit,
|
||||
) {
|
||||
var appIdValue by remember { mutableStateOf(appId) }
|
||||
var eventSelectorValue by remember { mutableStateOf(eventSelector) }
|
||||
AppAlertDialog(
|
||||
properties = DialogProperties(dismissOnClickOutside = false),
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(text = "截屏快照")
|
||||
PerfIconButton(
|
||||
imageVector = PerfIcon.HelpOutline,
|
||||
onClick = throttle(onOpenHelp),
|
||||
)
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
CustomOutlinedTextField(
|
||||
label = { Text("应用ID") },
|
||||
value = appIdValue,
|
||||
placeholder = { Text(text = "请输入目标应用ID") },
|
||||
onValueChange = { appIdValue = it },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
CustomOutlinedTextField(
|
||||
label = { Text("特征事件选择器") },
|
||||
value = eventSelectorValue,
|
||||
placeholder = { Text(text = "请输入特征事件选择器") },
|
||||
onValueChange = { eventSelectorValue = it },
|
||||
maxLines = 4,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.autoFocus(),
|
||||
)
|
||||
}
|
||||
},
|
||||
onDismissRequest = onDismissRequest,
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = throttle { onConfirm(appIdValue, eventSelectorValue) },
|
||||
) {
|
||||
Text(text = "确认")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(text = "取消")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,16 +5,12 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import li.songe.gkd.store.storeFlow
|
||||
import li.songe.gkd.ui.share.BaseViewModel
|
||||
import li.songe.gkd.util.appInfoMapFlow
|
||||
import li.songe.gkd.util.toast
|
||||
import li.songe.selector.Selector
|
||||
|
||||
class AdvancedVm : BaseViewModel() {
|
||||
|
||||
val showEditPortDialogFlow: StateFlow<Boolean>
|
||||
field = MutableStateFlow(false)
|
||||
val showCaptureScreenshotDialogFlow: StateFlow<Boolean>
|
||||
field = MutableStateFlow(false)
|
||||
val httpSettingsDialogVisibleFlow: StateFlow<Boolean>
|
||||
field = MutableStateFlow(false)
|
||||
|
||||
@@ -22,10 +18,6 @@ class AdvancedVm : BaseViewModel() {
|
||||
showEditPortDialogFlow.value = visible
|
||||
}
|
||||
|
||||
fun setCaptureScreenshotDialogVisible(visible: Boolean) {
|
||||
showCaptureScreenshotDialogFlow.value = visible
|
||||
}
|
||||
|
||||
fun setHttpSettingsDialogVisible(visible: Boolean) {
|
||||
httpSettingsDialogVisibleFlow.value = visible
|
||||
}
|
||||
@@ -44,62 +36,7 @@ class AdvancedVm : BaseViewModel() {
|
||||
return true
|
||||
}
|
||||
|
||||
fun saveCaptureScreenshotConfig(
|
||||
appId: String,
|
||||
eventSelector: String,
|
||||
): Boolean {
|
||||
val store = storeFlow.value
|
||||
if (
|
||||
appId == store.screenshotTargetAppId &&
|
||||
eventSelector == store.screenshotEventSelector
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (appId.isNotEmpty() && !appInfoMapFlow.value.contains(appId)) {
|
||||
toast("无效应用ID")
|
||||
return false
|
||||
}
|
||||
if (eventSelector.isNotEmpty() && Selector.parseOrNull(eventSelector) == null) {
|
||||
toast("无效事件选择器")
|
||||
return false
|
||||
}
|
||||
storeFlow.update {
|
||||
it.copy(
|
||||
screenshotTargetAppId = appId,
|
||||
screenshotEventSelector = eventSelector,
|
||||
)
|
||||
}
|
||||
toast("更新成功")
|
||||
return true
|
||||
}
|
||||
|
||||
fun setAutoClearMemorySubs(enabled: Boolean) {
|
||||
storeFlow.update { it.copy(autoClearMemorySubs = enabled) }
|
||||
}
|
||||
|
||||
fun setCaptureVolumeChange(enabled: Boolean) {
|
||||
storeFlow.update { it.copy(captureVolumeChange = enabled) }
|
||||
}
|
||||
|
||||
fun setCaptureScreenshot(enabled: Boolean) {
|
||||
val store = storeFlow.value
|
||||
storeFlow.update { it.copy(captureScreenshot = enabled) }
|
||||
if (
|
||||
enabled && (
|
||||
store.screenshotTargetAppId.isEmpty() ||
|
||||
store.screenshotEventSelector.isEmpty()
|
||||
)
|
||||
) {
|
||||
toast("请配置目标应用和特征事件选择器")
|
||||
}
|
||||
}
|
||||
|
||||
fun setHideSnapshotStatusBar(enabled: Boolean) {
|
||||
storeFlow.update { it.copy(hideSnapshotStatusBar = enabled) }
|
||||
}
|
||||
|
||||
fun setShowSaveSnapshotToast(enabled: Boolean) {
|
||||
storeFlow.update { it.copy(showSaveSnapshotToast = enabled) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import kotlinx.serialization.Serializable
|
||||
import li.songe.gkd.MainActivity
|
||||
import li.songe.gkd.data.Snapshot
|
||||
import li.songe.gkd.permission.PermissionStates
|
||||
import li.songe.gkd.snapshot.SnapshotStore
|
||||
import li.songe.gkd.ui.component.EmptyText
|
||||
import li.songe.gkd.ui.component.FixedTimeText
|
||||
import li.songe.gkd.ui.component.AppDialog
|
||||
@@ -58,8 +59,6 @@ import li.songe.gkd.util.IMPORT_SHORT_URL
|
||||
import li.songe.gkd.util.UriUtils
|
||||
import li.songe.gkd.util.copyText
|
||||
import li.songe.gkd.util.launchTry
|
||||
import li.songe.gkd.util.saveFileToDownloads
|
||||
import li.songe.gkd.util.shareFile
|
||||
import li.songe.gkd.util.throttle
|
||||
import li.songe.gkd.util.toast
|
||||
|
||||
@@ -192,7 +191,12 @@ fun SnapshotPage() {
|
||||
actionScope.launchTry {
|
||||
selectedSnapshot = null
|
||||
toast("正在保存...")
|
||||
context.saveFileToDownloads(vm.buildShareArchive(snapshotVal))
|
||||
val archive = vm.buildShareArchive(snapshotVal)
|
||||
try {
|
||||
context.saveFileToDownloads(archive)
|
||||
} finally {
|
||||
SnapshotStore.deleteArchive(archive)
|
||||
}
|
||||
}
|
||||
})
|
||||
.then(modifier)
|
||||
|
||||
263
app/src/main/kotlin/li/songe/gkd/ui/SnapshotSettingsPage.kt
Normal file
263
app/src/main/kotlin/li/songe/gkd/ui/SnapshotSettingsPage.kt
Normal file
@@ -0,0 +1,263 @@
|
||||
package li.songe.gkd.ui
|
||||
|
||||
import android.app.Activity
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
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 androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.serialization.Serializable
|
||||
import li.songe.gkd.R
|
||||
import li.songe.gkd.app
|
||||
import li.songe.gkd.permission.PermissionStates
|
||||
import li.songe.gkd.service.ScreenshotService
|
||||
import li.songe.gkd.store.storeFlow
|
||||
import li.songe.gkd.ui.component.AppAlertDialog
|
||||
import li.songe.gkd.ui.component.CustomOutlinedTextField
|
||||
import li.songe.gkd.ui.component.PerfCustomIconButton
|
||||
import li.songe.gkd.ui.component.PerfIcon
|
||||
import li.songe.gkd.ui.component.PerfIconButton
|
||||
import li.songe.gkd.ui.component.PerfTopAppBar
|
||||
import li.songe.gkd.ui.component.TextSwitch
|
||||
import li.songe.gkd.ui.component.autoFocus
|
||||
import li.songe.gkd.ui.share.LocalMainViewModel
|
||||
import li.songe.gkd.ui.style.EmptyHeight
|
||||
import li.songe.gkd.ui.style.titleItemPadding
|
||||
import li.songe.gkd.util.AndroidTarget
|
||||
import li.songe.gkd.util.ShortUrlSet
|
||||
import li.songe.gkd.util.launchAsFn
|
||||
import li.songe.gkd.util.launchTry
|
||||
import li.songe.gkd.util.throttle
|
||||
|
||||
@Serializable
|
||||
data object SnapshotSettingsRoute : NavKey
|
||||
|
||||
@Composable
|
||||
fun SnapshotSettingsPage() {
|
||||
val mainVm = LocalMainViewModel.current
|
||||
val vm = viewModel<SnapshotSettingsVm>()
|
||||
val scope = vm.scope
|
||||
val store by storeFlow.collectAsStateWithLifecycle()
|
||||
val screenshotServiceRunning by ScreenshotService.isRunning.collectAsStateWithLifecycle()
|
||||
var showCaptureScreenshotDialog by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
fun setScreenshotServiceEnabled(enabled: Boolean) {
|
||||
scope.launchTry {
|
||||
if (!enabled) {
|
||||
ScreenshotService.stop()
|
||||
return@launchTry
|
||||
}
|
||||
if (!mainVm.permissionRequests.ensurePermissions(PermissionStates.notification)) {
|
||||
return@launchTry
|
||||
}
|
||||
val activityResult = mainVm.activityResults.startActivity(
|
||||
app.mediaProjectionManager.createScreenCaptureIntent(),
|
||||
)
|
||||
val intent = activityResult.data
|
||||
if (activityResult.resultCode == Activity.RESULT_OK && intent != null) {
|
||||
ScreenshotService.start(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCaptureScreenshotDialog) {
|
||||
CaptureScreenshotConfigDialog(
|
||||
appId = store.screenshotTargetAppId,
|
||||
eventSelector = store.screenshotEventSelector,
|
||||
onOpenHelp = {
|
||||
showCaptureScreenshotDialog = false
|
||||
mainVm.navigateWebPage(ShortUrlSet.URL15)
|
||||
},
|
||||
onDismissRequest = { showCaptureScreenshotDialog = false },
|
||||
onConfirm = { appId, selector ->
|
||||
if (vm.saveCaptureScreenshotConfig(appId, selector)) {
|
||||
showCaptureScreenshotDialog = false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
PerfTopAppBar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = {
|
||||
PerfIconButton(
|
||||
imageVector = PerfIcon.ArrowBack,
|
||||
onClick = mainVm::popPage,
|
||||
)
|
||||
},
|
||||
title = { Text(text = "快照设置") },
|
||||
)
|
||||
},
|
||||
) { contentPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(contentPadding),
|
||||
) {
|
||||
Text(
|
||||
text = "生成方式",
|
||||
modifier = Modifier.titleItemPadding(showTop = false),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
if (!AndroidTarget.R) {
|
||||
TextSwitch(
|
||||
title = "截屏服务",
|
||||
subtitle = "生成快照需要获取屏幕截图",
|
||||
checked = screenshotServiceRunning,
|
||||
onCheckedChange = ::setScreenshotServiceEnabled,
|
||||
)
|
||||
}
|
||||
TextSwitch(
|
||||
title = "音量快照",
|
||||
subtitle = "音量变化时保存快照",
|
||||
checked = store.captureVolumeChange,
|
||||
onCheckedChange = vm::setCaptureVolumeChange,
|
||||
)
|
||||
TextSwitch(
|
||||
title = "截屏快照",
|
||||
subtitle = "截屏时保存快照",
|
||||
checked = store.captureScreenshot,
|
||||
suffixIcon = {
|
||||
PerfCustomIconButton(
|
||||
size = 32.dp,
|
||||
iconSize = 20.dp,
|
||||
onClickLabel = "打开配置截屏快照弹窗",
|
||||
onClick = throttle { showCaptureScreenshotDialog = true },
|
||||
id = R.drawable.ic_page_info,
|
||||
contentDescription = "截屏快照设置",
|
||||
)
|
||||
},
|
||||
onCheckedChange = vm::setCaptureScreenshot,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "截图处理",
|
||||
modifier = Modifier.titleItemPadding(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
TextSwitch(
|
||||
title = "隐藏状态栏",
|
||||
subtitle = "隐藏快照截图状态栏",
|
||||
checked = store.hideSnapshotStatusBar,
|
||||
onCheckedChange = vm::setHideSnapshotStatusBar,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "导出",
|
||||
modifier = Modifier.titleItemPadding(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
TextSwitch(
|
||||
title = "自动保存至下载",
|
||||
subtitle = "快照完成后导出 ZIP 文件",
|
||||
checked = store.autoSaveSnapshotToDownloads,
|
||||
onCheckedChange = scope.launchAsFn { enabled ->
|
||||
if (
|
||||
!enabled || mainVm.permissionRequests.ensurePermissions(
|
||||
PermissionStates.writeExternalStorage,
|
||||
)
|
||||
) {
|
||||
vm.setAutoSaveSnapshotToDownloads(enabled)
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.height(EmptyHeight))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CaptureScreenshotConfigDialog(
|
||||
appId: String,
|
||||
eventSelector: String,
|
||||
onOpenHelp: () -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
onConfirm: (String, String) -> Unit,
|
||||
) {
|
||||
var appIdValue by remember { mutableStateOf(appId) }
|
||||
var eventSelectorValue by remember { mutableStateOf(eventSelector) }
|
||||
AppAlertDialog(
|
||||
properties = DialogProperties(dismissOnClickOutside = false),
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(text = "截屏快照")
|
||||
PerfIconButton(
|
||||
imageVector = PerfIcon.HelpOutline,
|
||||
onClick = throttle(onOpenHelp),
|
||||
)
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
CustomOutlinedTextField(
|
||||
label = { Text("应用ID") },
|
||||
value = appIdValue,
|
||||
placeholder = { Text(text = "请输入目标应用ID") },
|
||||
onValueChange = { appIdValue = it },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
CustomOutlinedTextField(
|
||||
label = { Text("特征事件选择器") },
|
||||
value = eventSelectorValue,
|
||||
placeholder = { Text(text = "请输入特征事件选择器") },
|
||||
onValueChange = { eventSelectorValue = it },
|
||||
maxLines = 4,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.autoFocus(),
|
||||
)
|
||||
}
|
||||
},
|
||||
onDismissRequest = onDismissRequest,
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = throttle { onConfirm(appIdValue, eventSelectorValue) },
|
||||
) {
|
||||
Text(text = "确认")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(text = "取消")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
64
app/src/main/kotlin/li/songe/gkd/ui/SnapshotSettingsVm.kt
Normal file
64
app/src/main/kotlin/li/songe/gkd/ui/SnapshotSettingsVm.kt
Normal file
@@ -0,0 +1,64 @@
|
||||
package li.songe.gkd.ui
|
||||
|
||||
import kotlinx.coroutines.flow.update
|
||||
import li.songe.gkd.store.storeFlow
|
||||
import li.songe.gkd.ui.share.BaseViewModel
|
||||
import li.songe.gkd.util.appInfoMapFlow
|
||||
import li.songe.gkd.util.toast
|
||||
import li.songe.selector.Selector
|
||||
|
||||
class SnapshotSettingsVm : BaseViewModel() {
|
||||
fun saveCaptureScreenshotConfig(
|
||||
appId: String,
|
||||
eventSelector: String,
|
||||
): Boolean {
|
||||
val store = storeFlow.value
|
||||
if (
|
||||
appId == store.screenshotTargetAppId &&
|
||||
eventSelector == store.screenshotEventSelector
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (appId.isNotEmpty() && !appInfoMapFlow.value.contains(appId)) {
|
||||
toast("无效应用ID")
|
||||
return false
|
||||
}
|
||||
if (eventSelector.isNotEmpty() && Selector.parseOrNull(eventSelector) == null) {
|
||||
toast("无效事件选择器")
|
||||
return false
|
||||
}
|
||||
storeFlow.update {
|
||||
it.copy(
|
||||
screenshotTargetAppId = appId,
|
||||
screenshotEventSelector = eventSelector,
|
||||
)
|
||||
}
|
||||
toast("更新成功")
|
||||
return true
|
||||
}
|
||||
|
||||
fun setCaptureVolumeChange(enabled: Boolean) {
|
||||
storeFlow.update { it.copy(captureVolumeChange = enabled) }
|
||||
}
|
||||
|
||||
fun setCaptureScreenshot(enabled: Boolean) {
|
||||
val store = storeFlow.value
|
||||
storeFlow.update { it.copy(captureScreenshot = enabled) }
|
||||
if (
|
||||
enabled && (
|
||||
store.screenshotTargetAppId.isEmpty() ||
|
||||
store.screenshotEventSelector.isEmpty()
|
||||
)
|
||||
) {
|
||||
toast("请配置目标应用和特征事件选择器")
|
||||
}
|
||||
}
|
||||
|
||||
fun setHideSnapshotStatusBar(enabled: Boolean) {
|
||||
storeFlow.update { it.copy(hideSnapshotStatusBar = enabled) }
|
||||
}
|
||||
|
||||
fun setAutoSaveSnapshotToDownloads(enabled: Boolean) {
|
||||
storeFlow.update { it.copy(autoSaveSnapshotToDownloads = enabled) }
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,12 @@ package li.songe.gkd.ui
|
||||
import android.graphics.BitmapFactory
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import li.songe.gkd.data.Snapshot
|
||||
import li.songe.gkd.db.DbSet
|
||||
import li.songe.gkd.snapshot.SnapshotStore
|
||||
import li.songe.gkd.ui.share.BaseViewModel
|
||||
import li.songe.gkd.util.ImageUtils
|
||||
import li.songe.gkd.util.SnapshotExt
|
||||
import li.songe.gkd.util.appInfoMapFlow
|
||||
import java.io.File
|
||||
|
||||
@@ -31,25 +30,16 @@ class SnapshotVm : BaseViewModel() {
|
||||
)
|
||||
}.stateLoadable()
|
||||
|
||||
suspend fun deleteAllSnapshots() = withContext(Dispatchers.IO) {
|
||||
snapshotsFlow.first().forEach { snapshot ->
|
||||
SnapshotExt.removeSnapshot(snapshot.id)
|
||||
}
|
||||
DbSet.snapshotDao.deleteAll()
|
||||
suspend fun deleteAllSnapshots() = SnapshotStore.deleteAll()
|
||||
|
||||
suspend fun deleteSnapshot(snapshot: Snapshot) = SnapshotStore.delete(snapshot)
|
||||
|
||||
suspend fun buildShareArchive(snapshot: Snapshot): File {
|
||||
return SnapshotStore.createArchive(snapshot.id, snapshot.appId, snapshot.activityId)
|
||||
}
|
||||
|
||||
suspend fun deleteSnapshot(snapshot: Snapshot) = withContext(Dispatchers.IO) {
|
||||
DbSet.snapshotDao.delete(snapshot)
|
||||
SnapshotExt.removeSnapshot(snapshot.id)
|
||||
}
|
||||
|
||||
suspend fun buildShareArchive(snapshot: Snapshot): File = withContext(Dispatchers.IO) {
|
||||
SnapshotExt.snapshotZipFile(snapshot.id, snapshot.appId, snapshot.activityId)
|
||||
}
|
||||
|
||||
suspend fun buildUploadArchive(snapshot: Snapshot): File = withContext(Dispatchers.IO) {
|
||||
SnapshotExt.snapshotZipFile(snapshot.id)
|
||||
}
|
||||
suspend fun buildUploadArchive(snapshot: Snapshot): File =
|
||||
SnapshotStore.createArchive(snapshot.id)
|
||||
|
||||
suspend fun saveScreenshotToAlbum(snapshot: Snapshot) = withContext(Dispatchers.IO) {
|
||||
ImageUtils.save2Album(BitmapFactory.decodeFile(snapshot.screenshotFile.absolutePath))
|
||||
@@ -60,17 +50,7 @@ class SnapshotVm : BaseViewModel() {
|
||||
DbSet.snapshotDao.update(snapshot.copy(githubAssetId = githubAssetId))
|
||||
}
|
||||
|
||||
suspend fun replaceScreenshot(snapshot: Snapshot, newBytes: ByteArray): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
val oldBitmap = BitmapFactory.decodeFile(snapshot.screenshotFile.absolutePath)
|
||||
val newBitmap = BitmapFactory.decodeByteArray(newBytes, 0, newBytes.size)
|
||||
if (oldBitmap.width != newBitmap.width || oldBitmap.height != newBitmap.height) {
|
||||
return@withContext false
|
||||
}
|
||||
snapshot.screenshotFile.writeBytes(newBytes)
|
||||
if (snapshot.githubAssetId != null) {
|
||||
DbSet.snapshotDao.deleteGithubAssetId(snapshot.id)
|
||||
}
|
||||
true
|
||||
}
|
||||
suspend fun replaceScreenshot(snapshot: Snapshot, newBytes: ByteArray): Boolean {
|
||||
return SnapshotStore.replaceScreenshot(snapshot, newBytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ import li.songe.gkd.ui.SlowGroupPage
|
||||
import li.songe.gkd.ui.SlowGroupRoute
|
||||
import li.songe.gkd.ui.SnapshotPage
|
||||
import li.songe.gkd.ui.SnapshotPageRoute
|
||||
import li.songe.gkd.ui.SnapshotSettingsPage
|
||||
import li.songe.gkd.ui.SnapshotSettingsRoute
|
||||
import li.songe.gkd.ui.SubsAppGroupListPage
|
||||
import li.songe.gkd.ui.SubsAppGroupListRoute
|
||||
import li.songe.gkd.ui.SubsAppListPage
|
||||
@@ -66,6 +68,7 @@ private val mainRouteEntryProvider = entryProvider {
|
||||
entry<AdvancedPageRoute> { AdvancedPage() }
|
||||
entry<PrivilegeServiceRoute> { PrivilegeServicePage() }
|
||||
entry<SnapshotPageRoute> { SnapshotPage() }
|
||||
entry<SnapshotSettingsRoute> { SnapshotSettingsPage() }
|
||||
entry<A11YScopeAppListRoute> { A11yScopeAppListPage() }
|
||||
entry<ActivityLogRoute> { ActivityLogPage() }
|
||||
entry<A11yEventLogRoute> { A11yEventLogPage() }
|
||||
|
||||
138
app/src/main/kotlin/li/songe/gkd/ui/component/CopyText.kt
Normal file
138
app/src/main/kotlin/li/songe/gkd/ui/component/CopyText.kt
Normal file
@@ -0,0 +1,138 @@
|
||||
package li.songe.gkd.ui.component
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.input.OutputTransformation
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import li.songe.gkd.util.copyText
|
||||
import li.songe.gkd.util.throttle
|
||||
|
||||
@Composable
|
||||
fun CopyableText(
|
||||
text: AnnotatedString,
|
||||
modifier: Modifier = Modifier,
|
||||
textToCopy: String = text.text,
|
||||
contentPadding: PaddingValues = PaddingValues.Zero,
|
||||
textStyle: TextStyle = LocalTextStyle.current,
|
||||
contentColor: Color = LocalContentColor.current,
|
||||
textContentDescription: String? = null,
|
||||
) {
|
||||
val textFieldState = remember(text.text) { TextFieldState(text.text) }
|
||||
val scrollState = rememberScrollState()
|
||||
val outputTransformation = remember(text) {
|
||||
if (text.spanStyles.isEmpty() && text.paragraphStyles.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
OutputTransformation {
|
||||
text.spanStyles.forEach { range ->
|
||||
addStyle(range.item, range.start, range.end)
|
||||
}
|
||||
text.paragraphStyles.forEach { range ->
|
||||
addStyle(range.item, range.start, range.end)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
CopyIconOverlay(
|
||||
textToCopy = textToCopy,
|
||||
copyIconVisible = textFieldState.selection.collapsed,
|
||||
contentColor = contentColor,
|
||||
modifier = modifier.defaultMinSize(
|
||||
minHeight = LocalMinimumInteractiveComponentSize.current,
|
||||
),
|
||||
) {
|
||||
BasicTextField(
|
||||
state = textFieldState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(contentPadding)
|
||||
.run {
|
||||
if (textContentDescription == null) {
|
||||
this
|
||||
} else {
|
||||
clearAndSetSemantics {
|
||||
contentDescription = textContentDescription
|
||||
}
|
||||
}
|
||||
},
|
||||
readOnly = true,
|
||||
textStyle = textStyle.copy(color = contentColor),
|
||||
outputTransformation = outputTransformation,
|
||||
cursorBrush = SolidColor(Color.Unspecified),
|
||||
scrollState = scrollState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CopyIconOverlay(
|
||||
textToCopy: String,
|
||||
copyIconVisible: Boolean,
|
||||
contentColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
content()
|
||||
AnimatedVisibility(
|
||||
visible = copyIconVisible,
|
||||
modifier = Modifier.align(Alignment.TopEnd),
|
||||
enter = fadeIn(animationSpec = tween(durationMillis = 300)),
|
||||
exit = fadeOut(animationSpec = tween(durationMillis = 200)),
|
||||
) {
|
||||
PerfIconButton(
|
||||
imageVector = PerfIcon.ContentCopy,
|
||||
onClick = throttle { copyText(textToCopy) },
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
contentColor = contentColor.copy(alpha = 0.5f),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CopyTextCard(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val shape = MaterialTheme.shapes.extraSmall
|
||||
CopyableText(
|
||||
text = remember(text) { AnnotatedString(text) },
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
textStyle = MaterialTheme.typography.bodyLarge,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package li.songe.gkd.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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.unit.dp
|
||||
import li.songe.gkd.util.copyText
|
||||
import li.songe.gkd.util.throttle
|
||||
|
||||
|
||||
@Composable
|
||||
fun CopyTextCard(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
SelectionContainer(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier
|
||||
.clip(MaterialTheme.shapes.extraSmall)
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||
.padding(8.dp),
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
PerfIcon(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.clickable(onClick = throttle {
|
||||
copyText(text)
|
||||
})
|
||||
.padding(4.dp)
|
||||
.size(24.dp),
|
||||
imageVector = PerfIcon.ContentCopy,
|
||||
tint = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.75f),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import li.songe.gkd.ui.style.TABULAR_NUMBERS_FONT_FEATURE
|
||||
|
||||
@Composable
|
||||
fun FixedTimeText(
|
||||
@@ -18,7 +19,7 @@ fun FixedTimeText(
|
||||
text = text,
|
||||
modifier = modifier,
|
||||
color = color,
|
||||
style = style.copy(fontFeatureSettings = "tnum"),
|
||||
style = style.copy(fontFeatureSettings = TABULAR_NUMBERS_FONT_FEATURE),
|
||||
softWrap = false,
|
||||
maxLines = 1,
|
||||
)
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
@@ -187,22 +186,6 @@ fun rememberColumnScrollState(): ColumnScrollState {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LazyListState.isAtBottom(): androidx.compose.runtime.State<Boolean> = remember(this) {
|
||||
derivedStateOf {
|
||||
val visibleItemsInfo = layoutInfo.visibleItemsInfo
|
||||
if (layoutInfo.totalItemsCount == 0) {
|
||||
false
|
||||
} else {
|
||||
val lastVisibleItem = visibleItemsInfo.last()
|
||||
val viewportHeight = layoutInfo.viewportEndOffset + layoutInfo.viewportStartOffset
|
||||
(lastVisibleItem.index + 1 == layoutInfo.totalItemsCount &&
|
||||
lastVisibleItem.offset + lastVisibleItem.size <= viewportHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val TopAppBarScrollBehavior.isFullVisible: Boolean
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package li.songe.gkd.ui.component
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
|
||||
private const val LAZY_TEXT_TARGET_CHUNK_SIZE = 2_000
|
||||
|
||||
@Composable
|
||||
fun LazyCopyableText(
|
||||
text: AnnotatedString,
|
||||
modifier: Modifier = Modifier,
|
||||
textToCopy: String = text.text,
|
||||
contentPadding: PaddingValues = PaddingValues.Zero,
|
||||
textStyle: TextStyle = LocalTextStyle.current,
|
||||
contentColor: Color = LocalContentColor.current,
|
||||
textContentDescription: String? = null,
|
||||
) {
|
||||
val chunkRanges = remember(text.text) { buildTextChunkRanges(text.text) }
|
||||
CopyIconOverlay(
|
||||
textToCopy = textToCopy,
|
||||
copyIconVisible = true,
|
||||
contentColor = contentColor,
|
||||
modifier = modifier.defaultMinSize(
|
||||
minHeight = LocalMinimumInteractiveComponentSize.current,
|
||||
),
|
||||
) {
|
||||
SelectionContainer {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.run {
|
||||
if (textContentDescription == null) {
|
||||
this
|
||||
} else {
|
||||
clearAndSetSemantics {
|
||||
contentDescription = textContentDescription
|
||||
}
|
||||
}
|
||||
},
|
||||
contentPadding = contentPadding,
|
||||
) {
|
||||
items(
|
||||
items = chunkRanges,
|
||||
key = { it.first },
|
||||
) { range ->
|
||||
val chunk = remember(text, range) {
|
||||
text.subSequence(range.first, range.last + 1)
|
||||
}
|
||||
BasicText(
|
||||
text = chunk,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
style = textStyle.copy(color = contentColor),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildTextChunkRanges(text: String): List<IntRange> {
|
||||
if (text.isEmpty()) return emptyList()
|
||||
val ranges = mutableListOf<IntRange>()
|
||||
var chunkStart = 0
|
||||
text.forEachIndexed { index, char ->
|
||||
if (
|
||||
char == '\n' &&
|
||||
index < text.lastIndex &&
|
||||
index + 1 - chunkStart >= LAZY_TEXT_TARGET_CHUNK_SIZE
|
||||
) {
|
||||
// The item boundary represents this newline. Keeping it in the preceding BasicText
|
||||
// would add an extra empty visual line before the next item.
|
||||
ranges.add(chunkStart until index)
|
||||
chunkStart = index + 1
|
||||
}
|
||||
}
|
||||
if (chunkStart < text.length) {
|
||||
ranges.add(chunkStart until text.length)
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package li.songe.gkd.ui.component
|
||||
|
||||
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
@Stable
|
||||
class LazyListAutoFollowState<K>(
|
||||
initialItemKey: K,
|
||||
val listState: LazyListState = LazyListState(),
|
||||
) {
|
||||
var isAutoFollowEnabled by mutableStateOf(true)
|
||||
private set
|
||||
|
||||
var pausedAtItemKey by mutableStateOf(initialItemKey)
|
||||
private set
|
||||
|
||||
fun pause(latestItemKey: K) {
|
||||
if (isAutoFollowEnabled) {
|
||||
pausedAtItemKey = latestItemKey
|
||||
}
|
||||
isAutoFollowEnabled = false
|
||||
}
|
||||
|
||||
fun resume() {
|
||||
isAutoFollowEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <K> rememberLazyListAutoFollowState(
|
||||
itemCount: Int,
|
||||
latestItemKey: K,
|
||||
): LazyListAutoFollowState<K> {
|
||||
val listState = rememberLazyListState()
|
||||
val state = remember {
|
||||
LazyListAutoFollowState(
|
||||
initialItemKey = latestItemKey,
|
||||
listState = listState,
|
||||
)
|
||||
}
|
||||
val isDragged by listState.interactionSource.collectIsDraggedAsState()
|
||||
LaunchedEffect(isDragged) {
|
||||
if (isDragged) {
|
||||
state.pause(latestItemKey)
|
||||
} else {
|
||||
snapshotFlow { listState.isScrollInProgress }.first { !it }
|
||||
if (listState.isAtBottom) {
|
||||
state.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(itemCount, latestItemKey, state.isAutoFollowEnabled) {
|
||||
if (state.isAutoFollowEnabled && itemCount > 0) {
|
||||
listState.scrollToItem(itemCount - 1)
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
private val LazyListState.isAtBottom: Boolean
|
||||
get() {
|
||||
val layoutInfo = layoutInfo
|
||||
val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull()
|
||||
return lastVisibleItem != null &&
|
||||
lastVisibleItem.index + 1 == layoutInfo.totalItemsCount &&
|
||||
lastVisibleItem.offset + lastVisibleItem.size <= layoutInfo.viewportEndOffset
|
||||
}
|
||||
@@ -22,7 +22,6 @@ import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.UnfoldMore
|
||||
import androidx.compose.material.icons.filled.WarningAmber
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.Api
|
||||
import androidx.compose.material.icons.outlined.ArrowDownward
|
||||
import androidx.compose.material.icons.outlined.AutoMode
|
||||
import androidx.compose.material.icons.outlined.Check
|
||||
@@ -198,7 +197,6 @@ object PerfIcon {
|
||||
val LightMode get() = Icons.Outlined.LightMode
|
||||
val DarkMode get() = Icons.Outlined.DarkMode
|
||||
val VerifiedUser get() = Icons.Outlined.VerifiedUser
|
||||
val Api get() = Icons.Outlined.Api
|
||||
val Autorenew get() = Icons.Default.Autorenew
|
||||
val UnfoldMore get() = Icons.Default.UnfoldMore
|
||||
val Memory get() = Icons.Default.Memory
|
||||
|
||||
@@ -2,30 +2,20 @@ package li.songe.gkd.ui.component
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import li.songe.gkd.data.RawSubscription
|
||||
import li.songe.gkd.ui.ImagePreviewItem
|
||||
import li.songe.gkd.ui.ImagePreviewRoute
|
||||
@@ -34,8 +24,8 @@ import li.songe.gkd.ui.SubsGlobalGroupListRoute
|
||||
import li.songe.gkd.ui.icon.ResetSettings
|
||||
import li.songe.gkd.ui.share.LocalDarkTheme
|
||||
import li.songe.gkd.ui.share.LocalMainViewModel
|
||||
import li.songe.gkd.ui.style.JSON5_LARGE_TEXT_THRESHOLD
|
||||
import li.songe.gkd.ui.style.getJson5AnnotatedString
|
||||
import li.songe.gkd.util.copyText
|
||||
import li.songe.gkd.util.throttle
|
||||
|
||||
@Composable
|
||||
@@ -50,24 +40,10 @@ fun RuleGroupDialog(
|
||||
onClickDelete: () -> Unit = {}
|
||||
) {
|
||||
val mainVm = LocalMainViewModel.current
|
||||
val scrollState = rememberScrollState()
|
||||
val textState = remember {
|
||||
mutableStateOf(
|
||||
group.cacheStr.run {
|
||||
// 优化: 大字符串第一次显示卡顿
|
||||
if (length > 1000) substring(0, 1000) else this
|
||||
}
|
||||
)
|
||||
}
|
||||
LaunchedEffect(group.cacheStr) {
|
||||
delay(50)
|
||||
if (group.cacheStr.length != textState.value.length) {
|
||||
textState.value = group.cacheStr
|
||||
}
|
||||
}
|
||||
val source = group.cacheStr
|
||||
val darkTheme = LocalDarkTheme.current
|
||||
val annotatedText = remember(textState.value, darkTheme) {
|
||||
getJson5AnnotatedString(textState.value, darkTheme)
|
||||
val annotatedText = remember(source, darkTheme) {
|
||||
getJson5AnnotatedString(source, darkTheme)
|
||||
}
|
||||
val targetRoute = remember(subs.id, appId, group.key) {
|
||||
if (group is RawSubscription.RawGlobalGroup) {
|
||||
@@ -88,43 +64,37 @@ fun RuleGroupDialog(
|
||||
title = { Text(text = "规则详情") },
|
||||
text = {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
val maxHeight = 300.dp
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 100.dp, max = maxHeight)
|
||||
.clip(MaterialTheme.shapes.extraSmall)
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||
.verticalScroll(scrollState)
|
||||
.clearAndSetSemantics {
|
||||
contentDescription = "规则内容"
|
||||
}
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = annotatedText,
|
||||
modifier = Modifier.padding(4.dp),
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
val textModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 100.dp, max = maxHeight)
|
||||
.clip(MaterialTheme.shapes.extraSmall)
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||
val contentPadding = PaddingValues(4.dp)
|
||||
if (source.length > JSON5_LARGE_TEXT_THRESHOLD) {
|
||||
LazyCopyableText(
|
||||
text = annotatedText,
|
||||
modifier = textModifier,
|
||||
contentPadding = contentPadding,
|
||||
textStyle = MaterialTheme.typography.bodySmall,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
textContentDescription = "规则内容",
|
||||
)
|
||||
} else {
|
||||
CopyableText(
|
||||
text = annotatedText,
|
||||
textToCopy = source,
|
||||
modifier = textModifier,
|
||||
contentPadding = contentPadding,
|
||||
textStyle = MaterialTheme.typography.bodySmall,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
textContentDescription = "规则内容",
|
||||
)
|
||||
}
|
||||
PerfIcon(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.clickable(onClick = throttle {
|
||||
copyText(group.cacheStr)
|
||||
})
|
||||
.padding(4.dp)
|
||||
.size(24.dp),
|
||||
imageVector = PerfIcon.ContentCopy,
|
||||
tint = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.75f),
|
||||
)
|
||||
Text(
|
||||
text = group.cacheStr.length.toString(),
|
||||
text = source.length.toString(),
|
||||
modifier = Modifier
|
||||
.padding(end = 4.dp, bottom = 4.dp)
|
||||
.align(Alignment.BottomEnd)
|
||||
|
||||
@@ -89,4 +89,4 @@ fun SettingItem(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,6 @@ import kotlinx.coroutines.withContext
|
||||
import li.songe.gkd.MainActivity
|
||||
import li.songe.gkd.util.buildLogFile
|
||||
import li.songe.gkd.util.launchTry
|
||||
import li.songe.gkd.util.saveFileToDownloads
|
||||
import li.songe.gkd.util.shareFile
|
||||
import li.songe.gkd.util.throttle
|
||||
|
||||
class ShareLogState(
|
||||
|
||||
@@ -2,24 +2,22 @@ package li.songe.gkd.ui.component
|
||||
|
||||
import android.webkit.URLUtil
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
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.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import li.songe.gkd.util.copyText
|
||||
import li.songe.gkd.util.openUri
|
||||
import li.songe.gkd.util.throttle
|
||||
|
||||
@@ -63,7 +61,7 @@ class TextDialogState {
|
||||
val request by requestFlow.collectAsStateWithLifecycle()
|
||||
val currentRequest = request
|
||||
if (currentRequest != null) {
|
||||
val scrollState = rememberScrollState()
|
||||
val text = remember(currentRequest.text) { AnnotatedString(currentRequest.text) }
|
||||
AppAlertDialog(
|
||||
onDismissRequest = ::dismiss,
|
||||
title = {
|
||||
@@ -77,17 +75,12 @@ class TextDialogState {
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
SelectionContainer(
|
||||
modifier = Modifier
|
||||
.verticalScroll(scrollState)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = currentRequest.text,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
CopyableText(
|
||||
text = text,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(12.dp),
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
@@ -95,11 +88,6 @@ class TextDialogState {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(
|
||||
onClick = throttle { copyText(currentRequest.text) },
|
||||
) {
|
||||
Text(text = "复制")
|
||||
}
|
||||
TextButton(onClick = throttle(::dismiss)) {
|
||||
Text(text = "关闭")
|
||||
}
|
||||
|
||||
@@ -81,8 +81,6 @@ import li.songe.gkd.util.DarkThemeOption
|
||||
import li.songe.gkd.util.findOption
|
||||
import li.songe.gkd.util.launchTry
|
||||
import li.songe.gkd.util.openAppDetailsSettings
|
||||
import li.songe.gkd.util.saveFileToDownloads
|
||||
import li.songe.gkd.util.shareFile
|
||||
import li.songe.gkd.util.throttle
|
||||
import li.songe.gkd.util.toast
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@@ -6,12 +6,12 @@ import androidx.compose.ui.text.input.OffsetMapping
|
||||
import androidx.compose.ui.text.input.TransformedText
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
|
||||
private const val HIGHLIGHT_JSON5_MAX_LENGTH = 10000
|
||||
const val JSON5_LARGE_TEXT_THRESHOLD = 10_000
|
||||
|
||||
private class Json5VisualTransformation(val dark: Boolean) : VisualTransformation {
|
||||
val cache = LruCache<String, TransformedText>(0xF)
|
||||
override fun filter(text: AnnotatedString): TransformedText {
|
||||
if (text.text.isBlank() || text.text.length > HIGHLIGHT_JSON5_MAX_LENGTH) {
|
||||
if (text.text.isBlank() || text.text.length > JSON5_LARGE_TEXT_THRESHOLD) {
|
||||
return VisualTransformation.None.filter(text)
|
||||
}
|
||||
cache[text.text]?.let { return it }
|
||||
|
||||
@@ -15,16 +15,21 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import li.songe.gkd.app
|
||||
import li.songe.gkd.store.storeFlow
|
||||
import li.songe.gkd.ui.share.LocalDarkTheme
|
||||
@@ -34,24 +39,32 @@ import li.songe.gkd.util.AndroidTarget
|
||||
private val LightColorScheme = lightColorScheme()
|
||||
private val DarkColorScheme = darkColorScheme()
|
||||
|
||||
private fun createAppearanceFlow(scope: CoroutineScope) =
|
||||
storeFlow
|
||||
.map { it.enableDarkTheme to it.enableDynamicColor }
|
||||
.distinctUntilChanged()
|
||||
.debounce(300)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
storeFlow.value.let { it.enableDarkTheme to it.enableDynamicColor },
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AppTheme(
|
||||
invertedTheme: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val appearanceFlow = remember {
|
||||
storeFlow
|
||||
.distinctUntilChangedBy { it.enableDarkTheme to it.enableDynamicColor }
|
||||
.debounce(300)
|
||||
}
|
||||
val store by appearanceFlow.collectAsStateWithLifecycle(storeFlow.value)
|
||||
val scope = rememberCoroutineScope()
|
||||
val appearanceFlow = remember(scope) { createAppearanceFlow(scope) }
|
||||
val (enableDarkTheme, enableDynamicColor) = appearanceFlow.collectAsStateWithLifecycle().value
|
||||
val systemInDarkTheme = isSystemInDarkTheme()
|
||||
val darkTheme = (store.enableDarkTheme ?: systemInDarkTheme).let {
|
||||
val darkTheme = (enableDarkTheme ?: systemInDarkTheme).let {
|
||||
if (invertedTheme) !it else it
|
||||
}
|
||||
val colorScheme = when {
|
||||
AndroidTarget.S && store.enableDynamicColor && darkTheme -> dynamicDarkColorScheme(app)
|
||||
AndroidTarget.S && store.enableDynamicColor && !darkTheme -> dynamicLightColorScheme(app)
|
||||
AndroidTarget.S && enableDynamicColor && darkTheme -> dynamicDarkColorScheme(app)
|
||||
AndroidTarget.S && enableDynamicColor && !darkTheme -> dynamicLightColorScheme(app)
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
3
app/src/main/kotlin/li/songe/gkd/ui/style/Typography.kt
Normal file
3
app/src/main/kotlin/li/songe/gkd/ui/style/Typography.kt
Normal file
@@ -0,0 +1,3 @@
|
||||
package li.songe.gkd.ui.style
|
||||
|
||||
const val TABULAR_NUMBERS_FONT_FEATURE = "tnum"
|
||||
18
app/src/main/kotlin/li/songe/gkd/util/BitmapExt.kt
Normal file
18
app/src/main/kotlin/li/songe/gkd/util/BitmapExt.kt
Normal file
@@ -0,0 +1,18 @@
|
||||
package li.songe.gkd.util
|
||||
|
||||
import android.graphics.Bitmap
|
||||
|
||||
fun Bitmap.isFullTransparent(): Boolean {
|
||||
val bufferHeight = height.coerceAtMost(32)
|
||||
val pixels = IntArray(width * bufferHeight)
|
||||
var y = 0
|
||||
while (y < height) {
|
||||
val rows = (height - y).coerceAtMost(bufferHeight)
|
||||
getPixels(pixels, 0, width, 0, y, width, rows)
|
||||
repeat(width * rows) { index ->
|
||||
if (pixels[index] ushr 24 != 0) return false
|
||||
}
|
||||
y += rows
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -2,69 +2,17 @@ package li.songe.gkd.util
|
||||
|
||||
import android.app.Service
|
||||
import android.content.ComponentName
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.provider.Settings
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import li.songe.gkd.META
|
||||
import li.songe.gkd.MainActivity
|
||||
import li.songe.gkd.app
|
||||
import li.songe.gkd.isActivityVisible
|
||||
import li.songe.gkd.permission.PermissionStates
|
||||
import java.io.File
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
fun MainActivity.shareFile(file: File, title: String) {
|
||||
val uri = FileProvider.getUriForFile(
|
||||
app, "${app.packageName}.provider", file
|
||||
)
|
||||
val intent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
tryStartActivity(
|
||||
Intent.createChooser(
|
||||
intent, title
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun MainActivity.saveFileToDownloads(file: File) {
|
||||
if (AndroidTarget.Q) {
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, file.name)
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
val uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
|
||||
?: error("创建URI失败")
|
||||
contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||
outputStream.write(file.readBytes())
|
||||
outputStream.flush()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!mainVm.permissionRequests.ensurePermissions(PermissionStates.writeExternalStorage)) return
|
||||
val targetFile = File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
file.name
|
||||
)
|
||||
targetFile.writeBytes(file.readBytes())
|
||||
}
|
||||
toast("已保存 ${file.name} 到下载")
|
||||
}
|
||||
|
||||
fun Context.tryStartActivity(intent: Intent) {
|
||||
try {
|
||||
startActivity(intent)
|
||||
|
||||
@@ -4,10 +4,7 @@ import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInfo
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@@ -22,8 +19,6 @@ import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.graphics.get
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import li.songe.gkd.META
|
||||
import li.songe.gkd.MainActivity
|
||||
@@ -40,18 +35,7 @@ private val componentNameCache by lazy { HashMap<String, ComponentName>() }
|
||||
val KClass<*>.componentName
|
||||
get() = componentNameCache.getOrPut(jvmName) { ComponentName(META.appId, jvmName) }
|
||||
|
||||
fun Bitmap.isFullTransparent(): Boolean {
|
||||
repeat(width) { x ->
|
||||
repeat(height) { y ->
|
||||
if (this[x, y] != Color.TRANSPARENT) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
class InterruptRuleMatchException() : Exception()
|
||||
class InterruptRuleMatchException : Exception()
|
||||
|
||||
fun getShowActivityId(appId: String, activityId: String?): String? {
|
||||
return if (activityId != null) {
|
||||
@@ -100,24 +84,6 @@ inline fun <reified T> toJson5String(value: T): String {
|
||||
return json.encodeToJson5String(value, defaultJson5Config)
|
||||
}
|
||||
|
||||
fun drawTextToBitmap(text: String, bitmap: Bitmap) {
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textSize = 32.sp.px
|
||||
color = Color.BLUE
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
val canvas = Canvas(bitmap)
|
||||
val strList = text.split('\n')
|
||||
strList.forEachIndexed { i, str ->
|
||||
canvas.drawText(
|
||||
str,
|
||||
bitmap.width / 2f,
|
||||
(bitmap.height / 2f) + (i - strList.size / 2f) * (paint.textSize + 4.sp.px),
|
||||
paint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/gkd-kit/gkd/issues/924
|
||||
private val Drawable.safeDrawable: Drawable?
|
||||
get() = if (intrinsicHeight > 0 && intrinsicWidth > 0) {
|
||||
@@ -234,4 +200,4 @@ fun getShareApkFile(): File {
|
||||
return sharedDir.resolve("gkd-v${META.versionName}.apk").apply {
|
||||
File(app.packageCodePath).copyTo(this, overwrite = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
package li.songe.gkd.util
|
||||
|
||||
import android.app.Activity.RESULT_OK
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PixelFormat
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.hardware.display.VirtualDisplay
|
||||
import android.media.Image
|
||||
import android.media.ImageReader
|
||||
import android.media.projection.MediaProjection
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.core.graphics.createBitmap
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import li.songe.gkd.app
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
// https://github.com/npes87184/ScreenShareTile/blob/master/app/src/main/java/com/npes87184/screenshottile/ScreenshotService.kt
|
||||
|
||||
class ScreenshotUtil(
|
||||
private val screenshotIntent: Intent
|
||||
) {
|
||||
|
||||
private val handler by lazy { Handler(Looper.getMainLooper()) }
|
||||
private var virtualDisplay: VirtualDisplay? = null
|
||||
private var imageReader: ImageReader? = null
|
||||
private var mediaProjection: MediaProjection? = null
|
||||
|
||||
|
||||
private val width: Int
|
||||
get() = ScreenUtils.getScreenWidth()
|
||||
private val height: Int
|
||||
get() = ScreenUtils.getScreenHeight()
|
||||
private val dpi: Int
|
||||
get() = ScreenUtils.getScreenDensityDpi()
|
||||
|
||||
fun destroy() {
|
||||
imageReader?.setOnImageAvailableListener(null, null)
|
||||
virtualDisplay?.release()
|
||||
imageReader?.close()
|
||||
mediaProjection?.stop()
|
||||
}
|
||||
|
||||
// TODO android13 上一半概率获取到全透明图片, android12 暂无此问题
|
||||
suspend fun execute() = suspendCancellableCoroutine { cont ->
|
||||
imageReader = ImageReader.newInstance(
|
||||
width, height,
|
||||
PixelFormat.RGBA_8888, 2
|
||||
)
|
||||
if (mediaProjection == null) {
|
||||
mediaProjection = app.mediaProjectionManager.getMediaProjection(
|
||||
RESULT_OK,
|
||||
screenshotIntent
|
||||
)
|
||||
}
|
||||
virtualDisplay = mediaProjection!!.createVirtualDisplay(
|
||||
"screenshot",
|
||||
width,
|
||||
height,
|
||||
dpi,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
imageReader!!.surface,
|
||||
null,
|
||||
handler
|
||||
)
|
||||
var resumed = false
|
||||
imageReader!!.setOnImageAvailableListener({ reader ->
|
||||
if (resumed) return@setOnImageAvailableListener
|
||||
var image: Image? = null
|
||||
var bitmapWithStride: Bitmap? = null
|
||||
val bitmap: Bitmap?
|
||||
try {
|
||||
image = reader.acquireLatestImage()
|
||||
if (image != null) {
|
||||
val planes = image.planes
|
||||
val buffer = planes[0].buffer
|
||||
val pixelStride = planes[0].pixelStride
|
||||
val rowStride = planes[0].rowStride
|
||||
bitmapWithStride = createBitmap(rowStride / pixelStride, height)
|
||||
bitmapWithStride.copyPixelsFromBuffer(buffer)
|
||||
bitmap = Bitmap.createBitmap(bitmapWithStride, 0, 0, width, height)
|
||||
if (!bitmap.isFullTransparent()) {
|
||||
imageReader?.setOnImageAvailableListener(null, null)
|
||||
if (cont.isActive) {
|
||||
cont.resume(bitmap)
|
||||
}
|
||||
resumed = true
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
imageReader?.setOnImageAvailableListener(null, null)
|
||||
if (cont.isActive) {
|
||||
cont.resumeWithException(e)
|
||||
}
|
||||
} finally {
|
||||
bitmapWithStride?.recycle()
|
||||
image?.close()
|
||||
}
|
||||
}, handler)
|
||||
}
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
package li.songe.gkd.util
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.scale
|
||||
import androidx.core.graphics.set
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import li.songe.gkd.a11y.A11yRuleEngine
|
||||
import li.songe.gkd.a11y.TopActivity
|
||||
import li.songe.gkd.a11y.topActivityFlow
|
||||
import li.songe.gkd.data.ComplexSnapshot
|
||||
import li.songe.gkd.data.RpcError
|
||||
import li.songe.gkd.data.info2nodeList
|
||||
import li.songe.gkd.db.DbSet
|
||||
import li.songe.gkd.notif.NotificationCatalog
|
||||
import li.songe.gkd.service.ScreenshotService
|
||||
import li.songe.gkd.priv.privilegeContextFlow
|
||||
import li.songe.gkd.store.storeFlow
|
||||
import java.io.File
|
||||
import kotlin.math.min
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
object SnapshotExt {
|
||||
|
||||
private fun snapshotParentPath(id: Long) = snapshotFolder.resolve(id.toString())
|
||||
fun snapshotFile(id: Long) = snapshotParentPath(id).resolve("${id}.json")
|
||||
private fun minSnapshotFile(id: Long): File {
|
||||
return snapshotParentPath(id).resolve("${id}.min.json")
|
||||
}
|
||||
|
||||
suspend fun getMinSnapshot(id: Long): JsonObject {
|
||||
val f = minSnapshotFile(id)
|
||||
if (!f.exists()) {
|
||||
val text = withContext(Dispatchers.IO) { snapshotFile(id).readText() }
|
||||
val snapshotJson = withContext(Dispatchers.Default) {
|
||||
// #1185
|
||||
json.decodeFromString<JsonObject>(text)
|
||||
}
|
||||
val minSnapshot = JsonObject(snapshotJson.toMutableMap().apply {
|
||||
this["nodes"] = JsonArray(emptyList())
|
||||
})
|
||||
withContext(Dispatchers.IO) {
|
||||
f.writeText(keepNullJson.encodeToString(minSnapshot))
|
||||
}
|
||||
return minSnapshot
|
||||
}
|
||||
val text = withContext(Dispatchers.IO) { f.readText() }
|
||||
return withContext(Dispatchers.Default) {
|
||||
json.decodeFromString<JsonObject>(text)
|
||||
}
|
||||
}
|
||||
|
||||
fun screenshotFile(id: Long): File {
|
||||
val webp = snapshotParentPath(id).resolve("${id}.webp")
|
||||
// 存在webp优先返回;不存在则返回旧png文件
|
||||
if (webp.exists()) return webp
|
||||
return snapshotParentPath(id).resolve("${id}.png")
|
||||
}
|
||||
|
||||
// 仅在[保存新快照]时调用,强制输出webp,不要在读取逻辑使用
|
||||
private fun newScreenshotOutputFile(id: Long): File {
|
||||
return snapshotParentPath(id).resolve("${id}.webp")
|
||||
}
|
||||
suspend fun snapshotZipFile(
|
||||
snapshotId: Long,
|
||||
appId: String? = null,
|
||||
activityId: String? = null
|
||||
): File {
|
||||
val filename = if (appId != null) {
|
||||
val name =
|
||||
appInfoMapFlow.value[appId]?.name?.filterNot { c -> c in "\\/:*?\"<>|" || c <= ' ' }
|
||||
if (activityId != null) {
|
||||
"${(name ?: appId).take(20)}_${
|
||||
activityId.split('.').last().take(40)
|
||||
}-${snapshotId}.zip"
|
||||
} else {
|
||||
"${(name ?: appId).take(20)}-${snapshotId}.zip"
|
||||
}
|
||||
} else {
|
||||
"${snapshotId}.zip"
|
||||
}
|
||||
val file = sharedDir.resolve(filename)
|
||||
if (file.exists()) {
|
||||
file.delete()
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
ZipUtils.zipFiles(
|
||||
listOf(
|
||||
snapshotFile(snapshotId),
|
||||
screenshotFile(snapshotId)
|
||||
),
|
||||
file
|
||||
)
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
fun removeSnapshot(id: Long) {
|
||||
snapshotParentPath(id).apply {
|
||||
if (exists()) {
|
||||
deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("SameParameterValue")
|
||||
private fun emptyScreenBitmap(text: String): Bitmap {
|
||||
return createBitmap(ScreenUtils.getScreenWidth(), ScreenUtils.getScreenHeight()).apply {
|
||||
drawTextToBitmap(text, this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cropBitmapStatusBar(bitmap: Bitmap): Bitmap {
|
||||
val tempBp = bitmap.run {
|
||||
if (!isMutable || config == Bitmap.Config.HARDWARE) {
|
||||
copy(Bitmap.Config.ARGB_8888, true)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
val barHeight = min(BarUtils.getStatusBarHeight(), tempBp.height)
|
||||
for (x in 0 until tempBp.width) {
|
||||
for (y in 0 until barHeight) {
|
||||
tempBp[x, y] = 0
|
||||
}
|
||||
}
|
||||
return tempBp
|
||||
}
|
||||
// 截图三种状态
|
||||
private enum class ScreenWhy {
|
||||
Pass,
|
||||
NotHave,
|
||||
Block,
|
||||
}
|
||||
// App拒绝提供画面判定逻辑
|
||||
private fun isAppProtected(bitmap: Bitmap): Boolean {
|
||||
fun Bitmap.recycleIfTemp() { if (this !== bitmap) recycle() }
|
||||
// 缩小图片
|
||||
val size = 64
|
||||
val scaled = bitmap.scale(size, size, false)
|
||||
|
||||
/* 强制转为 ARGB_8888(软件位图)
|
||||
部分设备,Android版本scale()返回仍是HARDWARE
|
||||
bitmap在 gpu内存,cpu无法直接读,会崩溃
|
||||
*/
|
||||
val softBitmap = if (scaled.config == Bitmap.Config.HARDWARE) {
|
||||
val copy = scaled.copy(Bitmap.Config.ARGB_8888, false)
|
||||
scaled.recycleIfTemp()
|
||||
copy ?: return false // copy 失败(极端 OOM)直接返回,不继续执行
|
||||
} else {
|
||||
scaled
|
||||
}
|
||||
// 像素一次性读取到数组
|
||||
val pixels = IntArray(size * size)
|
||||
softBitmap.getPixels(pixels, 0, size, 0, 0, size, size)
|
||||
softBitmap.recycleIfTemp()
|
||||
val ignore = (size * 0.08).toInt() // 忽略图片边缘
|
||||
// 统计变量
|
||||
var sum = 0.0 // 亮度总和
|
||||
var sumSq = 0.0 // 平方总和
|
||||
var count = 0 // 样本数量
|
||||
// 统计极值像素占比
|
||||
var nearBlackCount = 0
|
||||
// 采样
|
||||
val step = 2 //隔一个像素取样
|
||||
for (y in ignore until size - ignore step step) { // ignore(忽略边缘)
|
||||
for (x in ignore until size - ignore step step) {
|
||||
// 提取RGB
|
||||
val p = pixels[y * size + x]
|
||||
val r = (p shr 16) and 0xff
|
||||
val g = (p shr 8) and 0xff
|
||||
val b = p and 0xff
|
||||
// 计算亮度
|
||||
val l = 0.299 * r + 0.587 * g + 0.114 * b
|
||||
// 统计
|
||||
sum += l
|
||||
sumSq += l * l
|
||||
count++
|
||||
if (l < 10) nearBlackCount++
|
||||
}
|
||||
}
|
||||
// 防止除零
|
||||
if (count == 0) return false
|
||||
// 平均值和方差计算
|
||||
val mean = sum / count
|
||||
val variance = sumSq / count - mean * mean
|
||||
|
||||
// 极值(纯黑)像素占比
|
||||
val blackRatio = nearBlackCount.toDouble() / count
|
||||
// 判断条件拆分,低方差+像素高度集中在极端值
|
||||
val isNearlyUniform = variance < 15.0 // 放宽,包容轻微噪点
|
||||
val isDominantlyBlack = blackRatio > 0.85 && mean < 15.0
|
||||
// 判断值设定
|
||||
return isNearlyUniform && (isDominantlyBlack)
|
||||
}
|
||||
private val captureLoading = MutableStateFlow(false)
|
||||
suspend fun captureSnapshot(forcedCropStatusBar: Boolean = false): ComplexSnapshot {
|
||||
if (A11yRuleEngine.instance == null) {
|
||||
throw RpcError("服务不可用,请先授权")
|
||||
}
|
||||
if (captureLoading.value) {
|
||||
throw RpcError("正在保存快照,不可重复操作")
|
||||
}
|
||||
captureLoading.value = true
|
||||
try {
|
||||
val rootNode =
|
||||
A11yRuleEngine.instance?.safeActiveWindow
|
||||
?: throw RpcError("当前应用没有无障碍信息,捕获失败")
|
||||
if (storeFlow.value.showSaveSnapshotToast) {
|
||||
toast("正在保存快照...", forced = true)
|
||||
}
|
||||
val (snapshot, screenResult) = coroutineScope { // 快照数据+截图(图片 && 状态)
|
||||
val d1 = async(Dispatchers.IO) {
|
||||
val appId = rootNode.packageName.toString()
|
||||
var activityId = privilegeContextFlow.value?.run {
|
||||
topCpn()?.className
|
||||
}
|
||||
if (activityId == null) {
|
||||
var topActivity = topActivityFlow.value
|
||||
var i = 0L
|
||||
while (topActivity.appId != appId) {
|
||||
delay(100.milliseconds)
|
||||
topActivity = topActivityFlow.value
|
||||
i += 100
|
||||
if (i >= 2000) {
|
||||
topActivity = TopActivity(appId = appId)
|
||||
break
|
||||
}
|
||||
}
|
||||
activityId = topActivity.activityId
|
||||
}
|
||||
ComplexSnapshot(
|
||||
id = System.currentTimeMillis(),
|
||||
appId = appId,
|
||||
activityId = activityId,
|
||||
screenHeight = ScreenUtils.getScreenHeight(),
|
||||
screenWidth = ScreenUtils.getScreenWidth(),
|
||||
isLandscape = ScreenUtils.isLandscape(),
|
||||
nodes = info2nodeList(rootNode)
|
||||
)
|
||||
}
|
||||
val d2 = async(Dispatchers.IO) {
|
||||
val rawPicture = // 获取原始图片
|
||||
A11yRuleEngine.screenshot() // 无障碍
|
||||
?: ScreenshotService.screenshot() // 截图服务
|
||||
|
||||
val (finalBitmap, status) = when {
|
||||
rawPicture == null -> {
|
||||
emptyScreenBitmap("无截图权限\n请自行替换") to ScreenWhy.NotHave
|
||||
}
|
||||
isAppProtected(rawPicture) -> {
|
||||
rawPicture to ScreenWhy.Block
|
||||
}
|
||||
else -> {
|
||||
rawPicture to ScreenWhy.Pass
|
||||
}
|
||||
}
|
||||
|
||||
val processedBitmap = if (status == ScreenWhy.Pass &&
|
||||
storeFlow.value.hideSnapshotStatusBar && (forcedCropStatusBar || BarUtils.checkStatusBarVisible() == true)) {
|
||||
cropBitmapStatusBar(finalBitmap)
|
||||
} else {
|
||||
finalBitmap
|
||||
}
|
||||
processedBitmap to status
|
||||
}
|
||||
d1.await() to d2.await()
|
||||
}
|
||||
|
||||
val (bitmap, currentStatus) = screenResult // 拆开(图片+状态)
|
||||
withContext(Dispatchers.IO) {
|
||||
snapshotParentPath(snapshot.id).autoMk()
|
||||
newScreenshotOutputFile(snapshot.id).outputStream().use { stream ->
|
||||
bitmap.compress(webpLossyCompressFormat, 85, stream)
|
||||
}
|
||||
snapshotFile(snapshot.id).writeText(keepNullJson.encodeToString(snapshot))
|
||||
minSnapshotFile(snapshot.id).writeText(
|
||||
keepNullJson.encodeToString(
|
||||
snapshot.copy(
|
||||
nodes = emptyList()
|
||||
)
|
||||
)
|
||||
)
|
||||
DbSet.snapshotDao.insert(snapshot.toSnapshot())
|
||||
}
|
||||
val tip = when (currentStatus) {
|
||||
ScreenWhy.NotHave -> "快照成功 (无截图)"
|
||||
ScreenWhy.Block -> "快照成功 (应用可能禁止截图)"
|
||||
ScreenWhy.Pass -> "快照成功"
|
||||
}
|
||||
toast(tip, forced = true)
|
||||
val desc = snapshot.appInfo?.name ?: snapshot.appId
|
||||
NotificationCatalog.snapshotSaved("快照「$desc」已保存至记录").post()
|
||||
return snapshot
|
||||
} finally {
|
||||
captureLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
67
app/src/main/kotlin/li/songe/gkd/util/SystemDownloads.kt
Normal file
67
app/src/main/kotlin/li/songe/gkd/util/SystemDownloads.kt
Normal file
@@ -0,0 +1,67 @@
|
||||
package li.songe.gkd.util
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.annotation.RequiresApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import li.songe.gkd.app
|
||||
import li.songe.gkd.permission.PermissionStates
|
||||
import java.io.File
|
||||
|
||||
object SystemDownloads {
|
||||
fun canSave(): Boolean = PermissionStates.writeExternalStorage.updateAndGet()
|
||||
|
||||
suspend fun save(source: File): Boolean {
|
||||
if (!canSave()) return false
|
||||
withContext(Dispatchers.IO) {
|
||||
if (AndroidTarget.Q) {
|
||||
saveWithMediaStore(source)
|
||||
} else {
|
||||
saveToLegacyDownloads(source)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun saveToLegacyDownloads(source: File) {
|
||||
@Suppress("DEPRECATION")
|
||||
val downloadsDirectory =
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
if (!downloadsDirectory.exists() && !downloadsDirectory.mkdirs()) {
|
||||
error("创建下载目录失败")
|
||||
}
|
||||
source.copyTo(downloadsDirectory.resolve(source.name), overwrite = true)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private fun saveWithMediaStore(source: File) {
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, source.name)
|
||||
put(
|
||||
MediaStore.MediaColumns.MIME_TYPE,
|
||||
MimeTypeMap.getSingleton().getMimeTypeFromExtension(source.extension)
|
||||
?: "application/octet-stream",
|
||||
)
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||
}
|
||||
val resolver = app.contentResolver
|
||||
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
|
||||
?: error("创建下载文件失败")
|
||||
try {
|
||||
resolver.openOutputStream(uri)?.use { output ->
|
||||
source.inputStream().use { input -> input.copyTo(output) }
|
||||
} ?: error("打开下载文件失败")
|
||||
values.clear()
|
||||
values.put(MediaStore.MediaColumns.IS_PENDING, 0)
|
||||
resolver.update(uri, values, null, null)
|
||||
} catch (e: Throwable) {
|
||||
resolver.delete(uri, null, null)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ class CompatAccessibilityManagerTest {
|
||||
services:{}
|
||||
""".trimIndent()
|
||||
|
||||
assertTrue(containsLegacyUiAutomation(dump))
|
||||
assertTrue(containsUiAutomation(dump))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -23,7 +23,7 @@ class CompatAccessibilityManagerTest {
|
||||
services:{Service[label=TalkBack]}
|
||||
""".trimIndent()
|
||||
|
||||
assertFalse(containsLegacyUiAutomation(dump))
|
||||
assertFalse(containsUiAutomation(dump))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -35,7 +35,7 @@ class CompatAccessibilityManagerTest {
|
||||
services:{}
|
||||
""".trimIndent()
|
||||
|
||||
assertFalse(containsLegacyUiAutomation(dump))
|
||||
assertFalse(containsUiAutomation(dump))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -47,7 +47,7 @@ class CompatAccessibilityManagerTest {
|
||||
services:{}
|
||||
""".trimIndent()
|
||||
|
||||
assertTrue(containsLegacyUiAutomation(dump))
|
||||
assertTrue(containsUiAutomation(dump))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -57,7 +57,7 @@ class CompatAccessibilityManagerTest {
|
||||
services:{}
|
||||
""".trimIndent()
|
||||
|
||||
assertTrue(containsModernUiAutomation(dump))
|
||||
assertTrue(containsUiAutomation(dump))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -67,21 +67,21 @@ class CompatAccessibilityManagerTest {
|
||||
Ui Automation[eventTypes=TYPES_ALL_MASK, notificationTimeout=0]
|
||||
""".trimIndent()
|
||||
|
||||
assertTrue(containsModernUiAutomation(dump))
|
||||
assertTrue(containsUiAutomation(dump))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uiAutomationMarkerDoesNotRequireLineBoundary() {
|
||||
val dump = "state=Ui Automation[eventTypes=TYPES_ALL_MASK]"
|
||||
|
||||
assertTrue(containsModernUiAutomation(dump))
|
||||
assertTrue(containsUiAutomation(dump))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyOrTruncatedDumpIsNotAutomation() {
|
||||
assertFalse(containsLegacyUiAutomation(""))
|
||||
assertFalse(containsUiAutomation(""))
|
||||
assertFalse(
|
||||
containsLegacyUiAutomation(
|
||||
containsUiAutomation(
|
||||
"User state[attributes:{id=0, currentUser=true, Service[",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package li.songe.gkd.priv
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class CompatWindowManagerTest {
|
||||
@Test
|
||||
fun namedSecureFlagIsDetectedForFocusedWindow() {
|
||||
val dump = windowDump(
|
||||
focusedWindowFlags = "LAYOUT_IN_SCREEN|SECURE|DRAWS_SYSTEM_BAR_BACKGROUNDS",
|
||||
)
|
||||
|
||||
assertTrue(parseFocusedWindowSecure(dump, TARGET_APP_ID) == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun namedFlagsWithoutSecureAreNotProtected() {
|
||||
val dump = windowDump(
|
||||
focusedWindowFlags = "LAYOUT_IN_SCREEN|DRAWS_SYSTEM_BAR_BACKGROUNDS",
|
||||
)
|
||||
|
||||
assertFalse(parseFocusedWindowSecure(dump, TARGET_APP_ID) ?: true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun legacyHexFlagsAreDecoded() {
|
||||
assertTrue(
|
||||
parseFocusedWindowSecure(
|
||||
windowDump(focusedWindowFlags = "#81812100"),
|
||||
TARGET_APP_ID,
|
||||
) == true,
|
||||
)
|
||||
assertFalse(
|
||||
parseFocusedWindowSecure(
|
||||
windowDump(focusedWindowFlags = "#81810100"),
|
||||
TARGET_APP_ID,
|
||||
) ?: true,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun secureFlagOnAnotherWindowIsIgnored() {
|
||||
val dump = windowDump(
|
||||
focusedWindowFlags = "LAYOUT_IN_SCREEN",
|
||||
otherWindowFlags = "SECURE",
|
||||
)
|
||||
|
||||
assertEquals(false, parseFocusedWindowSecure(dump, TARGET_APP_ID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun focusChangeAndTruncatedDumpAreUnknown() {
|
||||
val dump = windowDump(focusedWindowFlags = "SECURE")
|
||||
|
||||
assertNull(parseFocusedWindowSecure(dump, "other.app"))
|
||||
assertNull(
|
||||
parseFocusedWindowSecure(
|
||||
"mCurrentFocus=Window{abc123 u0 $TARGET_APP_ID/.MainActivity}",
|
||||
TARGET_APP_ID,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun windowDump(
|
||||
focusedWindowFlags: String,
|
||||
otherWindowFlags: String = "LAYOUT_IN_SCREEN",
|
||||
) = """
|
||||
WINDOW MANAGER DISPLAY CONTENTS (dumpsys window displays)
|
||||
mCurrentFocus=Window{abc123 u0 $TARGET_APP_ID/.MainActivity}
|
||||
Window #0 Window{def456 u0 other.app/.OtherActivity}:
|
||||
mAttrs={(0,0)(fillxfill)
|
||||
fl=$otherWindowFlags
|
||||
pfl=NO_MOVE_ANIMATION}
|
||||
Window #1 Window{abc123 u0 $TARGET_APP_ID/.MainActivity}:
|
||||
mAttrs={(0,0)(fillxfill)
|
||||
fl=$focusedWindowFlags
|
||||
pfl=NO_MOVE_ANIMATION}
|
||||
""".trimIndent()
|
||||
|
||||
private companion object {
|
||||
const val TARGET_APP_ID = "target.app"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package li.songe.gkd.snapshot
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
|
||||
class SnapshotDirectoryTransactionTest {
|
||||
@get:Rule
|
||||
val temporaryFolder = TemporaryFolder()
|
||||
|
||||
@Test
|
||||
fun commitPublishesCompleteDirectory() = runBlocking {
|
||||
val root = temporaryFolder.root
|
||||
val target = root.resolve("123")
|
||||
|
||||
commitSnapshotDirectory(
|
||||
layout = SnapshotFileLayout(root),
|
||||
id = 123,
|
||||
write = { files -> files.snapshotFile.writeText("data") },
|
||||
publish = {},
|
||||
)
|
||||
|
||||
assertEquals("data", target.resolve("123.json").readText())
|
||||
assertFalse(root.resolve(".123.tmp").exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun commitRemovesStagingDirectoryWhenWriteFails() {
|
||||
val root = temporaryFolder.root
|
||||
val target = root.resolve("123")
|
||||
|
||||
assertThrows(IOException::class.java) {
|
||||
runBlocking {
|
||||
commitSnapshotDirectory(
|
||||
layout = SnapshotFileLayout(root),
|
||||
id = 123,
|
||||
write = { files ->
|
||||
files.snapshotFile.writeText("partial")
|
||||
throw IOException("write failed")
|
||||
},
|
||||
publish = { error("publish must not run") },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
assertFalse(target.exists())
|
||||
assertFalse(root.resolve(".123.tmp").exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun commitRemovesPublishedDirectoryWhenPublishFails() {
|
||||
val root = temporaryFolder.root
|
||||
val target = root.resolve("123")
|
||||
|
||||
assertThrows(IOException::class.java) {
|
||||
runBlocking {
|
||||
commitSnapshotDirectory(
|
||||
layout = SnapshotFileLayout(root),
|
||||
id = 123,
|
||||
write = { files -> files.snapshotFile.writeText("data") },
|
||||
publish = { throw IOException("publish failed") },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
assertFalse(target.exists())
|
||||
assertFalse(root.resolve(".123.tmp").exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun commitDoesNotOverwriteExistingSnapshot() {
|
||||
val root = temporaryFolder.root
|
||||
val target = temporaryFolder.newFolder("123")
|
||||
val existing = target.resolve("123.json").apply { writeText("existing") }
|
||||
|
||||
assertThrows(IOException::class.java) {
|
||||
runBlocking {
|
||||
commitSnapshotDirectory(
|
||||
layout = SnapshotFileLayout(root),
|
||||
id = 123,
|
||||
write = { error("write must not run") },
|
||||
publish = { error("publish must not run") },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(target.exists())
|
||||
assertEquals("existing", existing.readText())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancellationDuringPublishDoesNotRollBackCommittedDirectory() = runBlocking {
|
||||
val root = temporaryFolder.root
|
||||
val target = root.resolve("123")
|
||||
val publishStarted = CompletableDeferred<Unit>()
|
||||
val finishPublish = CompletableDeferred<Unit>()
|
||||
val task = async {
|
||||
commitSnapshotDirectory(
|
||||
layout = SnapshotFileLayout(root),
|
||||
id = 123,
|
||||
write = { files -> files.snapshotFile.writeText("data") },
|
||||
publish = {
|
||||
publishStarted.complete(Unit)
|
||||
finishPublish.await()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
publishStarted.await()
|
||||
task.cancel()
|
||||
finishPublish.complete(Unit)
|
||||
task.join()
|
||||
|
||||
assertEquals("data", target.resolve("123.json").readText())
|
||||
assertFalse(root.resolve(".123.tmp").exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancellationDuringWriteDoesNotPublishPartialDirectory() = runBlocking {
|
||||
val root = temporaryFolder.root
|
||||
val writeStarted = CountDownLatch(1)
|
||||
val finishWrite = CountDownLatch(1)
|
||||
var published = false
|
||||
val task = async(Dispatchers.Default) {
|
||||
commitSnapshotDirectory(
|
||||
layout = SnapshotFileLayout(root),
|
||||
id = 123,
|
||||
write = { files ->
|
||||
files.snapshotFile.writeText("partial")
|
||||
writeStarted.countDown()
|
||||
finishWrite.await()
|
||||
},
|
||||
publish = { published = true },
|
||||
)
|
||||
}
|
||||
|
||||
writeStarted.await()
|
||||
task.cancel()
|
||||
finishWrite.countDown()
|
||||
task.join()
|
||||
|
||||
assertFalse(published)
|
||||
assertFalse(root.resolve("123").exists())
|
||||
assertFalse(root.resolve(".123.tmp").exists())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package li.songe.gkd.snapshot
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
class SnapshotFileLayoutTest {
|
||||
@get:Rule
|
||||
val temporaryFolder = TemporaryFolder()
|
||||
|
||||
@Test
|
||||
fun committedEntryUsesCompatibleSnapshotPaths() {
|
||||
val root = temporaryFolder.root
|
||||
val files = SnapshotFileLayout(root).committed(123)
|
||||
val directory = root.resolve("123")
|
||||
|
||||
assertEquals(directory, files.directory)
|
||||
assertEquals(directory.resolve("123.json"), files.snapshotFile)
|
||||
assertEquals(directory.resolve("123.min.json"), files.minSnapshotFile)
|
||||
assertEquals(directory.resolve("123.webp"), files.webpFile)
|
||||
assertEquals(directory.resolve("123.png"), files.legacyPngFile)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun screenshotFileFallsBackToLegacyPng() {
|
||||
val files = SnapshotFileLayout(temporaryFolder.root).committed(123)
|
||||
|
||||
assertEquals(files.legacyPngFile, files.screenshotFile)
|
||||
|
||||
files.directory.mkdirs()
|
||||
files.legacyPngFile.writeBytes(
|
||||
byteArrayOf(
|
||||
0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
)
|
||||
)
|
||||
files.webpFile.writeText("invalid")
|
||||
|
||||
assertEquals(files.legacyPngFile, files.screenshotFile)
|
||||
|
||||
files.webpFile.writeBytes(
|
||||
byteArrayOf(
|
||||
0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50,
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(files.webpFile, files.screenshotFile)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user