feat: 优化快照流程与相关界面

This commit is contained in:
二刺螈
2026-08-25 22:05:45 +08:00
parent 049a257c2f
commit 7ff1a31635
54 changed files with 2365 additions and 1115 deletions

View File

@@ -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()

View File

@@ -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()
}
}
}

View File

@@ -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(

View File

@@ -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",
)

View File

@@ -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) &&

View File

@@ -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
}
}

View File

@@ -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()

View File

@@ -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,
)
}
}

View File

@@ -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()

View File

@@ -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("快照不存在或已被删除")

View File

@@ -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
}
}

View File

@@ -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())

View File

@@ -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
}
}

View File

@@ -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()

View 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()
}
}
}

View File

@@ -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")
}
}

View File

@@ -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)
}

View File

@@ -0,0 +1,14 @@
package li.songe.gkd.snapshot
enum class SnapshotScreenshotStatus {
Captured,
Unavailable,
LikelyProtected,
;
fun detailText(): String? = when (this) {
Captured -> null
Unavailable -> "未获取到屏幕画面"
LikelyProtected -> "当前界面可能受截图保护"
}
}

View 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")
}
}
}

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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() {

View File

@@ -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 = "取消")
}
},
)
}

View File

@@ -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) }
}
}

View File

@@ -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)

View 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 = "取消")
}
},
)
}

View 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) }
}
}

View File

@@ -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)
}
}

View File

@@ -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() }

View 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,
)
}

View File

@@ -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),
)
}
}

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)

View File

@@ -89,4 +89,4 @@ fun SettingItem(
)
}
}
}
}

View File

@@ -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(

View File

@@ -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 = "关闭")
}

View File

@@ -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

View File

@@ -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 }

View File

@@ -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
}

View File

@@ -0,0 +1,3 @@
package li.songe.gkd.ui.style
const val TABULAR_NUMBERS_FONT_FEATURE = "tnum"

View 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
}

View File

@@ -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)

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}

View 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
}
}
}

View File

@@ -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[",
),
)

View File

@@ -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"
}
}

View File

@@ -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())
}
}

View File

@@ -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)
}
}