feat: file management; streaming terminal

- 实现 FileManagerService,处理远程文件操作,包括列出目录、上传和下载文件
- 增加对目录快照和文件统计信息检索的支持
- 引入 MultiGroupsDropdown 控件,用于在 UI 中显示分组下拉选项,仿照 MIUI 自带的文件管理排序 Popup
This commit is contained in:
Miuzarte
2026-04-22 02:43:17 +08:00
parent 9bbf30696a
commit ed768584c6
17 changed files with 2284 additions and 58 deletions

2
.gitignore vendored
View File

@@ -36,4 +36,6 @@ google-services.json
.kiro/
.kotlin/
scrcpy.help*

106
CHANGELOG.md Normal file
View File

@@ -0,0 +1,106 @@
# Change Log
~~什么我居然开始写更新日志了~~
## 0.2.0
- 新增: 流式终端界面(支持在应用内直接操作终端)
- 新增: 文件管理界面
- 新增: 原生应用设置页跳转,引导调整后台电池策略
- 改进: Dock 导航交互与可用性
## 0.1.9
- 新增: 串流时自动拉起输入法
- 新增: 剪贴板同步
## 0.1.8
- 新增: 支持 `--new-display``--start-app`
- 修复: 串流时“最近任务”不可用的问题
- 维护: 依赖升级
## 0.1.7
- 重构: 低延迟音频处理从 JNI 侧迁移到 Kotlin/应用侧,降低 JNI 路径负担
- 改进: 音频相关实现结构与设置项联动
## 0.1.6
- 修复: 输入法双重 padding 导致的布局问题
- 重构: UI 刷新TopAppBar 与 Dock 视觉重做,交互与页面样式统一)
## 0.1.5
- 新增: 生物识别认证与锁屏密码自动填充
- 修复: 快速连接卡片输入缓冲与设备 IP/端口修改体验
## 0.1.4
- 新增: 连接后可选执行 `--list-app`
## 0.1.3
- 新增: 最近任务快捷入口
- 修复: `START_APP_CUSTOM` 配置未保存的问题
## 0.1.2
- 新增: 多套 scrcpy 参数配置档profiles
- 改进: 多配置档管理体验
## 0.1.1
- 新增: 签名发布能力
- 新增: 画中画PiP
- 新增: 全屏悬浮“快速球”
- 新增: ADB 连接后自动启动 scrcpy
- 新增: 连接状态跟随与自动全屏
- 新增: PreviewCard 自动聚焦
- 新增: 电源 Wake Lock 管理
- 新增: 关于页面与动态模糊效果
- 新增: 基于 GitHub API 的版本检查
- 改进: 补充更多 scrcpy 选项
- 重构: 导航与触感反馈迁移到 CompositionLocal
- 重构: PiP 相关优化
- 修复: 视频显示由 TextureView 切换为 SurfaceView 以提升稳定性
- 维护: 升级 `miuix` 到 0.9.0
## 0.0.6
- 修复: 视频方向处理
- 重构: 设置系统迁移到 DataStore含历史数据迁移
- 重构: 状态管理改为 Parcelable Bundle 方案
- 重构: Snackbar 与设置基类实现优化
- 重构: 补充更多 scrcpy 参数并清理无用类/常量
## 0.0.5
- 新增/增强: 设置页与虚拟按键管理能力
- 修复: 应用版本信息异常
- 修复: 双击返回退出行为问题
- 文档: 注释与 README 更新
## 0.0.4
- 重构: 触控实现重写
- 新增: 串流过程中自动横竖屏切换
- 改进: 全屏控制页手势行为调整(关闭预测性返回)
## 0.0.3
- 新增: ADB 配对
- 新增: mDNS 设备发现
## 0.0.2
- 新增: 全屏虚拟按键外观控制
- 新增: 构建 ABI 矩阵控制
- 重构: 代码组织与项目文档结构
- 新增: 发布说明模板
## 0.0.1
- 首个可用版本发布
- 构建: 工作流支持收集全部 APK 产物
- 构建: Gradle 升级到 9.4.0

View File

@@ -61,7 +61,11 @@
## FAQ
1. 虚拟屏不显示输入法 / 输入法显示在主屏幕
1. 切到后台后 ADB 断连
- 将国产 ROM 中的 `省电策略` 调整至 `无限制`
- 将安卓原生设置中的 `允许后台使用` 启用并设置为 `无限制` (应用设置页中有入口)
2. 虚拟屏不显示输入法 / 输入法显示在主屏幕
-`--display-ime-policy` 设置为 `local`
- 自行在悬浮球中拉起本机输入法
@@ -88,6 +92,7 @@ specific abi:
- 界面组件: [YuKongA/miuix](https://github.com/compose-miuix-ui/miuix)
- 界面设计参考: [tiann/KernelSU/manager](https://github.com/tiann/KernelSU/tree/main/manager), [miuix/example](https://github.com/compose-miuix-ui/miuix/tree/main/example)
- 画中画实现参考: [ClassicOldSong/moonlight-android](https://github.com/ClassicOldSong/moonlight-android)
- 原生应用设置页跳转: [YifePlayte/WOMMO](https://github.com/YifePlayte/WOMMO)
## License

View File

@@ -2,6 +2,8 @@
# TODO
fix wake lock
## PARAMS
### LOWER PRIORITY

View File

@@ -9,6 +9,7 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application
android:allowBackup="true"

View File

@@ -7,6 +7,7 @@ import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import kotlinx.coroutines.runBlocking
import java.io.BufferedInputStream
import java.io.ByteArrayOutputStream
import java.io.Closeable
import java.io.EOFException
import java.io.IOException
@@ -259,6 +260,7 @@ internal class DirectAdbConnection(
Log.i(TAG, "handshake(): tcp connect -> $host:$port")
socket.connect(InetSocketAddress(host, port), 10_000)
socket.tcpNoDelay = true
socket.keepAlive = true
socket.soTimeout = 60_000
rawIn = BufferedInputStream(socket.getInputStream(), 65_536)
rawOut = socket.getOutputStream()
@@ -355,6 +357,12 @@ internal class DirectAdbConnection(
* - Implements SEND/DATA/DONE/OKAY sequence and throws IOException on failure.
*/
fun push(data: ByteArray, remotePath: String, unixMode: Int = 420) {
data.inputStream().use { input ->
push(input, remotePath, unixMode)
}
}
fun push(input: InputStream, remotePath: String, unixMode: Int = 420) {
openStream("sync:")
.use { stream ->
val out = stream.outputStream
@@ -366,13 +374,13 @@ internal class DirectAdbConnection(
out.write(pathMode)
val chunkBuf = ByteArray(64 * 1024)
var offset = 0
while (offset < data.size) {
val len = minOf(chunkBuf.size, data.size - offset)
while (true) {
val len = input.read(chunkBuf)
if (len <= 0) break
out.write("DATA".toByteArray(Charsets.US_ASCII))
out.writeIntLE(len)
out.write(data, offset, len)
offset += len
out.write(chunkBuf, 0, len)
}
out.write("DONE".toByteArray(Charsets.US_ASCII))
@@ -392,6 +400,62 @@ internal class DirectAdbConnection(
}
}
fun pull(remotePath: String): ByteArray {
val output = ByteArrayOutputStream()
pull(remotePath, output)
return output.toByteArray()
}
fun pull(remotePath: String, output: OutputStream) {
openStream("sync:")
.use { stream ->
val out = stream.outputStream
val inp = stream.inputStream
val pathBytes = remotePath.toByteArray(Charsets.UTF_8)
out.write("RECV".toByteArray(Charsets.US_ASCII))
out.writeIntLE(pathBytes.size)
out.write(pathBytes)
out.flush()
while (true) {
val idBuf = ByteArray(4).also { inp.readExact(it) }
val msgLen = inp.readIntLE()
when (val id = String(idBuf, Charsets.US_ASCII)) {
"DATA" -> {
val chunk = ByteArray(msgLen)
inp.readExact(chunk)
output.write(chunk)
}
"DONE" -> {
if (msgLen > 0) {
inp.skip(msgLen.toLong())
}
return
}
"FAIL" -> {
val msg = if (msgLen > 0) {
ByteArray(msgLen).also { inp.readExact(it) }
.toString(Charsets.UTF_8)
} else {
"unknown error"
}
throw IOException("ADB pull failed: $msg")
}
else -> {
if (msgLen > 0) {
inp.skip(msgLen.toLong())
}
throw IOException("ADB pull failed: unexpected sync id $id")
}
}
}
}
}
fun isAlive(): Boolean {
val isClosed = socket.isClosed
val isConnected = socket.isConnected

View File

@@ -6,6 +6,8 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.io.InputStream
import java.io.OutputStream
import java.nio.file.Path
import kotlin.time.Duration
@@ -135,24 +137,54 @@ object NativeAdbService {
return response
}
suspend fun shellBatch(build: ShellBatchBuilder.() -> Unit): List<String> {
val builder = ShellBatchBuilder().apply(build)
if (builder.commands.isEmpty()) {
return emptyList()
}
val markers = List(builder.commands.size) { index ->
"__SCRCPY_BATCH_${System.nanoTime()}_${index}__"
}
val script = buildString {
builder.commands.forEachIndexed { index, command ->
append(command)
append("; printf '\\n")
append(markers[index])
append("\\n'")
if (index != builder.commands.lastIndex) {
append("; ")
}
}
}
val response = shell(script)
val outputs = ArrayList<String>(builder.commands.size)
var remaining = response
markers.forEach { marker ->
val token = "\n$marker\n"
val markerIndex = remaining.indexOf(token)
.takeIf { it >= 0 }
?: throw IllegalStateException("Shell batch marker missing: $marker")
outputs += remaining.substring(0, markerIndex).trimEnd('\r', '\n')
remaining = remaining.substring(markerIndex + token.length)
}
return outputs
}
suspend fun startApp(
packageName: String,
displayId: Int? = null,
forceStop: Boolean = false,
): String {
val conn = snapshotConnection()
val normalizedPackageName = packageName.trim()
require(normalizedPackageName.isNotBlank()) { "package name is blank" }
if (forceStop) {
conn.shell(
"am force-stop ${quoteShellArg(normalizedPackageName)}"
)
}
val resolveOutput = conn.shell(
val resolveCommand =
"cmd package resolve-activity --brief ${quoteShellArg(normalizedPackageName)}"
)
val resolveOutputIndex = if (forceStop) 1 else 0
val batchResult = shellBatch {
if (forceStop) command("am force-stop ${quoteShellArg(normalizedPackageName)}")
command(resolveCommand)
}
val resolveOutput = batchResult.getOrElse(resolveOutputIndex) { "" }
val componentName = resolveOutput
.lineSequence()
.map(String::trim)
@@ -166,7 +198,7 @@ object NativeAdbService {
?.let { " --display $it" }
.orEmpty()
val command = "am start-activity$displayArg -n ${quoteShellArg(componentName)}"
val response = conn.shell(command)
val response = shell(command)
Log.d(TAG, "startApp(): package=$normalizedPackageName component=$componentName")
return response
}
@@ -175,10 +207,34 @@ object NativeAdbService {
return snapshotConnection().openStream("shell:$command")
}
suspend fun ensureConnectionResponsive() {
val conn = snapshotConnection()
try {
conn.shell("true")
} catch (error: Exception) {
mutex.withLock {
if (connection === conn) disconnectInternal()
}
throw IllegalStateException("ADB connection is no longer available", error)
}
}
suspend fun push(localPath: Path, remotePath: String) {
snapshotConnection().push(localPath.toFile().readBytes(), remotePath)
}
suspend fun push(input: InputStream, remotePath: String, unixMode: Int = 420) {
snapshotConnection().push(input, remotePath, unixMode)
}
suspend fun pull(remotePath: String): ByteArray {
return snapshotConnection().pull(remotePath)
}
suspend fun pull(remotePath: String, output: OutputStream) {
snapshotConnection().pull(remotePath, output)
}
suspend fun openAbstractSocket(name: String): AdbSocketStream {
return snapshotConnection().openStream("localabstract:$name")
}
@@ -207,5 +263,13 @@ object NativeAdbService {
return "'" + value.replace("'", "'\\''") + "'"
}
class ShellBatchBuilder internal constructor() {
internal val commands = mutableListOf<String>()
fun command(command: String) {
commands += command
}
}
private const val TAG = "NativeAdbService"
}

View File

@@ -6,7 +6,6 @@ import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.MoreVert
@@ -26,14 +25,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import io.github.miuzarte.scrcpyforandroid.StreamActivity
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
@@ -86,13 +82,11 @@ import top.yukonga.miuix.kmp.basic.ListPopupDefaults
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.blur.layerBackdrop
import top.yukonga.miuix.kmp.overlay.OverlayListPopup
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import top.yukonga.miuix.kmp.theme.MiuixTheme.textStyles
private const val ADB_CONNECT_TIMEOUT_MS = 12_000L
private const val ADB_KEEPALIVE_INTERVAL_MS = 3_000L

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,7 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Devices
import androidx.compose.material.icons.rounded.Folder
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Terminal
import androidx.compose.runtime.Composable
@@ -28,6 +29,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
@@ -103,6 +105,7 @@ private enum class MainBottomTabDestination(
) {
Devices(label = "设备", icon = Icons.Rounded.Devices),
Terminal(label = "终端", icon = Icons.Rounded.Terminal),
Files(label = "文件", icon = Icons.Rounded.Folder),
Settings(label = "设置", icon = Icons.Rounded.Settings);
}
@@ -141,7 +144,7 @@ fun MainScreen() {
initialPage = MainBottomTabDestination.Devices.ordinal,
pageCount = { tabs.size })
var selectedTabIndex by rememberSaveable {
mutableStateOf(MainBottomTabDestination.Devices.ordinal)
mutableIntStateOf(MainBottomTabDestination.Devices.ordinal)
}
var pagerNavigationJob by remember { mutableStateOf<Job?>(null) }
var isPagerNavigating by remember { mutableStateOf(false) }
@@ -150,6 +153,8 @@ fun MainScreen() {
val currentRootScreen = rootBackStack.lastOrNull() as? RootScreen ?: RootScreen.Home
var showReorderDevices by rememberSaveable { mutableStateOf(false) }
var lastExitBackPressAtMs by rememberSaveable { mutableLongStateOf(0L) }
var fileTabCanNavigateUp by remember { mutableStateOf(false) }
var fileTabNavigateUp by remember { mutableStateOf<(() -> Boolean)?>(null) }
// Scroll behaviors
val devicesPageScrollBehavior = MiuixScrollBehavior(
@@ -323,24 +328,32 @@ fun MainScreen() {
}
fun handleBackNavigation() {
if (rootBackStack.size > 1) {
rootNavigator.pop()
} else if (selectedTabIndex != MainBottomTabDestination.Devices.ordinal) {
navigateToTab(MainBottomTabDestination.Devices)
} else {
val now = SystemClock.elapsedRealtime()
if (now - lastExitBackPressAtMs > 2_000L) {
lastExitBackPressAtMs = now
Toast.makeText(context, "再按一次返回键退出", Toast.LENGTH_SHORT).show()
return
}
lastExitBackPressAtMs = 0L
scope.launch {
withContext(Dispatchers.IO) {
runCatching { scrcpy.stop() }
runCatching { NativeAdbService.disconnect() }
when {
rootBackStack.size > 1 -> rootNavigator.pop()
selectedTabIndex == MainBottomTabDestination.Files.ordinal
&& fileTabCanNavigateUp
&& fileTabNavigateUp?.invoke() == true
-> return
selectedTabIndex != MainBottomTabDestination.Devices.ordinal
-> navigateToTab(MainBottomTabDestination.Devices)
else -> {
val now = SystemClock.elapsedRealtime()
if (now - lastExitBackPressAtMs > 2_000L) {
lastExitBackPressAtMs = now
Toast.makeText(context, "再按一次返回键退出", Toast.LENGTH_SHORT).show()
return
}
lastExitBackPressAtMs = 0L
scope.launch {
withContext(Dispatchers.IO) {
runCatching { scrcpy.stop() }
runCatching { NativeAdbService.disconnect() }
}
activity?.finish()
}
activity?.finish()
}
}
}
@@ -453,6 +466,12 @@ fun MainScreen() {
bottomInnerPadding = bottomInnerPadding,
)
MainBottomTabDestination.Files -> FileManagerScreen(
bottomInnerPadding = bottomInnerPadding,
onCanNavigateUpChange = { fileTabCanNavigateUp = it },
onNavigateUpActionChange = { fileTabNavigateUp = it },
)
MainBottomTabDestination.Settings -> SettingsScreen(
scrollBehavior = settingsPageScrollBehavior,
bottomInnerPadding = bottomInnerPadding,

View File

@@ -29,6 +29,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.core.net.toUri
import io.github.miuzarte.scrcpyforandroid.BuildConfig
import io.github.miuzarte.scrcpyforandroid.LockscreenPasswordActivity
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes
@@ -68,6 +69,7 @@ import top.yukonga.miuix.kmp.preference.SwitchPreference
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import kotlin.math.roundToInt
import kotlin.system.exitProcess
import android.provider.Settings as AndroidSettings
@Composable
fun SettingsScreen(
@@ -480,6 +482,50 @@ fun SettingsPage(
item {
SectionSmallTitle("ADB")
Card {
ArrowPreference(
title = "调整后台电池策略",
summary =
"""
解决 Scrcpy 切换到后台时无法联网导致 ADB 断连
应用的电池使用情况 -> 允许后台使用 -> 无限制
国产ROM魔改的电源设置一般都可在对应的魔改应用设置中找到
""".trimIndent(),
onClick = {
val appInfoArgs = android.os.Bundle().apply {
putString("package", context.packageName)
putInt("uid", context.applicationInfo.uid)
}
val appDetailsIntent = Intent(Intent.ACTION_MAIN).apply {
setClassName(
"com.android.settings",
"com.android.settings.SubSettings"
)
putExtra(
":settings:show_fragment",
"com.android.settings.applications.appinfo.AppInfoDashboardFragment"
)
putExtra(
":settings:show_fragment_title",
"应用信息"
)
putExtra(":settings:show_fragment_args", appInfoArgs)
putExtra("package", context.packageName)
putExtra("uid", context.applicationInfo.uid)
putExtra("android.provider.extra.APP_PACKAGE", context.packageName)
}
val requestIntent = Intent(
AndroidSettings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
"package:${context.packageName}".toUri()
)
val fallbackIntent = Intent(
AndroidSettings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS
)
runCatching { context.startActivity(appDetailsIntent) }
.recoverCatching { context.startActivity(requestIntent) }
.recoverCatching { context.startActivity(fallbackIntent) }
.onFailure { snackbar.show("无法打开设置") }
},
)
Column(
modifier = Modifier.padding(vertical = UiSpacing.Large),
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),

View File

@@ -18,8 +18,8 @@ internal data class ConnectedDeviceInfo(
* Fetch basic device properties from an already-connected device.
*
* Notes:
* - This function issues multiple `shell getprop` commands via [adbService.shell].
* Each call may block on native I/O, so it should be called from a coroutine context.
* - This function issues multiple `getprop` commands over a single reusable shell session
* to avoid reopening a new adb shell stream for every property.
* - Returns a lightweight [ConnectedDeviceInfo] structure with commonly-used properties.
*/
internal suspend fun fetchConnectedDeviceInfo(
@@ -27,17 +27,25 @@ internal suspend fun fetchConnectedDeviceInfo(
host: String,
port: Int
): ConnectedDeviceInfo = withContext(Dispatchers.IO) {
suspend fun prop(name: String): String = runCatching {
adbService.shell("getprop $name").trim()
}.getOrDefault("")
val values = runCatching {
adbService.shellBatch {
command("getprop ro.product.model")
command("getprop ro.serialno")
command("getprop ro.product.manufacturer")
command("getprop ro.product.brand")
command("getprop ro.product.device")
command("getprop ro.build.version.release")
command("getprop ro.build.version.sdk")
}
}.getOrDefault(emptyList())
ConnectedDeviceInfo(
model = prop("ro.product.model").ifBlank { "$host:$port" },
serial = prop("ro.serialno"),
manufacturer = prop("ro.product.manufacturer"),
brand = prop("ro.product.brand"),
device = prop("ro.product.device"),
androidRelease = prop("ro.build.version.release"),
sdkInt = prop("ro.build.version.sdk").toIntOrNull() ?: -1,
model = values.getOrNull(0).orEmpty().trim().ifBlank { "$host:$port" },
serial = values.getOrNull(1).orEmpty().trim(),
manufacturer = values.getOrNull(2).orEmpty().trim(),
brand = values.getOrNull(3).orEmpty().trim(),
device = values.getOrNull(4).orEmpty().trim(),
androidRelease = values.getOrNull(5).orEmpty().trim(),
sdkInt = values.getOrNull(6).orEmpty().trim().toIntOrNull() ?: -1,
)
}

View File

@@ -0,0 +1,809 @@
package io.github.miuzarte.scrcpyforandroid.services
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.os.Environment
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import io.github.miuzarte.scrcpyforandroid.nativecore.AdbSocketStream
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.Closeable
import java.io.File
import java.io.IOException
import java.nio.charset.StandardCharsets
import java.text.DecimalFormat
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
enum class RemoteFileKind {
Directory,
Image,
Video,
Audio,
Archive,
Apk,
Text,
Link,
Other,
}
data class RemoteFileEntry(
val inode: Long?,
val permissions: String,
val hardLinks: Int?,
val owner: String?,
val group: String?,
val sizeBytes: Long?,
val modifiedAt: LocalDateTime?,
val name: String,
val fullPath: String,
val symlinkTarget: String? = null,
val kind: RemoteFileKind,
val isDirectory: Boolean,
)
data class RemoteFileStat(
val path: String,
val name: String,
val typeLabel: String?,
val sizeBytes: Long?,
val blocks: Long?,
val ioBlockBytes: Long?,
val inode: Long?,
val hardLinks: Int?,
val octalMode: String?,
val permissions: String?,
val uid: Long?,
val uidName: String?,
val gid: Long?,
val gidName: String?,
val accessTime: String?,
val modifyTime: String?,
val changeTime: String?,
val device: String?,
val deviceType: String?,
val symlinkTarget: String?,
val rawOutput: String,
) {
val title: String
get() = name.ifBlank { path }
}
data class DirectoryDownloadSnapshot(
val remoteRootPath: String,
val totalBytes: Long?,
val directories: List<String>,
val files: List<String>,
)
object FileManagerService {
private val listTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
private val displayTimeFormatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss")
private val listLineRegex = Regex(
"""^\s*(\d+)\s+([\-bcdlps][rwxstST-]{9})\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\s+(.+)$"""
)
private val sizeFormatter = DecimalFormat("0.00")
suspend fun listDirectory(path: String): List<RemoteFileEntry> = withContext(Dispatchers.IO) {
val command = "ls -aFil ${quoteShellArg(pathForListCommand(path))}"
val output = NativeAdbService.shell(command)
output.lineSequence()
.map(String::trimEnd)
.filter { it.isNotBlank() }
.filterNot { it.startsWith("total ") }
.mapNotNull { parseListEntry(path, it) }
.filterNot { it.name == "." || it.name == ".." }
.sortedWith(
compareByDescending<RemoteFileEntry> { it.isDirectory }
.thenBy { it.name.lowercase() }
)
.toList()
}
suspend fun stat(path: String): RemoteFileStat = withContext(Dispatchers.IO) {
val output = NativeAdbService.shell("stat ${quoteShellArg(path)}")
parseStat(path, output)
}
suspend fun uploadFile(
context: Context,
uri: Uri,
remoteDirectory: String,
): String = withContext(Dispatchers.IO) {
NativeAdbService.ensureConnectionResponsive()
val fileName = queryDisplayName(context.contentResolver, uri)
?: throw IOException("无法读取文件名")
val remotePath = joinRemotePath(remoteDirectory, fileName)
context.contentResolver.openInputStream(uri).use { input ->
requireNotNull(input) { "无法读取选择的文件" }
NativeAdbService.push(input, remotePath)
}
return@withContext remotePath
}
suspend fun createDirectory(
parentDirectory: String,
directoryName: String,
): String = withContext(Dispatchers.IO) {
val sanitizedName = directoryName.trim().trim('/').takeIf { it.isNotBlank() }
?: throw IOException("文件夹名称不能为空")
val remotePath = joinRemotePath(parentDirectory, sanitizedName)
NativeAdbService.ensureConnectionResponsive()
NativeAdbService.shell("mkdir -p ${quoteShellArg(remotePath)}")
return@withContext remotePath
}
suspend fun downloadFileToPublicDownloads(
remotePath: String,
fileName: String,
): Boolean = withContext(Dispatchers.IO) {
runCatching {
NativeAdbService.ensureConnectionResponsive()
val targetRoot = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
"Scrcpy"
)
ensureDirectoryExists(targetRoot)
val targetFile = uniqueFile(targetRoot, fileName)
targetFile.outputStream().use { output ->
NativeAdbService.pull(remotePath, output)
}
}.isSuccess
}
suspend fun downloadDirectoryToPublicDownloads(
snapshot: DirectoryDownloadSnapshot,
): Boolean = withContext(Dispatchers.IO) {
runCatching {
NativeAdbService.ensureConnectionResponsive()
val rootName = snapshot.remoteRootPath.substringAfterLast('/').ifBlank { "Scrcpy" }
val publicRoot = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
"Scrcpy"
)
ensureDirectoryExists(publicRoot)
val destinationRoot = uniqueDirectory(publicRoot, rootName)
ensureDirectoryExists(destinationRoot)
snapshot.directories.sortedBy { it.length }.forEach { relativePath ->
ensureDirectoryExists(File(destinationRoot, relativePath))
}
snapshot.files.forEach { relativePath ->
val targetFile = File(destinationRoot, relativePath)
ensureDirectoryExists(targetFile.parentFile)
targetFile.outputStream().use { output ->
NativeAdbService.pull(
joinRemotePath(snapshot.remoteRootPath, relativePath),
output
)
}
}
}.isSuccess
}
suspend fun downloadFileToTree(
context: Context,
treeUri: Uri,
remotePath: String,
fileName: String,
) = withContext(Dispatchers.IO) {
NativeAdbService.ensureConnectionResponsive()
val rootDocument = DocumentsContract.buildDocumentUriUsingTree(
treeUri,
DocumentsContract.getTreeDocumentId(treeUri)
)
val target = createUniqueDocument(context, rootDocument, fileName, guessMimeType(fileName))
context.contentResolver.openOutputStream(target, "w").use { output ->
requireNotNull(output) { "无法写入目标文件" }
NativeAdbService.pull(remotePath, output)
}
}
suspend fun downloadDirectoryToTree(
context: Context,
treeUri: Uri,
snapshot: DirectoryDownloadSnapshot,
) = withContext(Dispatchers.IO) {
NativeAdbService.ensureConnectionResponsive()
val rootDocument = DocumentsContract.buildDocumentUriUsingTree(
treeUri,
DocumentsContract.getTreeDocumentId(treeUri)
)
val folderName = snapshot.remoteRootPath.substringAfterLast('/').ifBlank { "Scrcpy" }
val folderRoot = createUniqueDirectoryDocument(context, rootDocument, folderName)
snapshot.directories.sortedBy { it.length }.forEach { relativePath ->
ensureTreeDirectory(context, folderRoot, relativePath)
}
snapshot.files.forEach { relativePath ->
val parentRelativePath = relativePath.substringBeforeLast('/', "")
val fileName = relativePath.substringAfterLast('/')
val parentDocument = ensureTreeDirectory(context, folderRoot, parentRelativePath)
val target =
createUniqueDocument(context, parentDocument, fileName, guessMimeType(fileName))
context.contentResolver.openOutputStream(target, "w").use { output ->
requireNotNull(output) { "无法写入目标文件" }
NativeAdbService.pull(joinRemotePath(snapshot.remoteRootPath, relativePath), output)
}
}
}
fun formatSummary(entry: RemoteFileEntry): String {
val timeText = entry.modifiedAt?.format(displayTimeFormatter) ?: "未知时间"
return if (entry.isDirectory || entry.sizeBytes == null) {
timeText
} else {
"$timeText ${formatSize(entry.sizeBytes)}"
}
}
fun formatStatDetails(
stat: RemoteFileStat,
directorySnapshot: DirectoryDownloadSnapshot? = null,
): String {
val lines = mutableListOf<String>()
val resolvedSizeBytes = directorySnapshot?.totalBytes ?: stat.sizeBytes
lines += "路径: ${stat.path}"
stat.typeLabel?.let { lines += "类型: $it" }
resolvedSizeBytes?.let { lines += "大小: ${formatSize(it)} ($it B)" }
stat.blocks?.let { lines += "块数: $it" }
stat.ioBlockBytes?.let { lines += "IO 块大小: ${it}B" }
stat.inode?.let { lines += "inode: $it" }
stat.hardLinks?.let { lines += "硬链接: $it" }
if (!stat.octalMode.isNullOrBlank() || !stat.permissions.isNullOrBlank()) {
lines += "权限: ${stat.octalMode ?: "?"}/${stat.permissions ?: "?"}"
}
if (stat.uid != null || stat.uidName != null) {
lines += "Uid: ${stat.uid ?: "?"}/${stat.uidName ?: "?"}"
}
if (stat.gid != null || stat.gidName != null) {
lines += "Gid: ${stat.gid ?: "?"}/${stat.gidName ?: "?"}"
}
stat.device?.let { lines += "设备: $it" }
stat.deviceType?.let { lines += "设备类型: $it" }
stat.accessTime?.let { lines += "访问时间: $it" }
stat.modifyTime?.let { lines += "修改时间: $it" }
stat.changeTime?.let { lines += "变更时间: $it" }
stat.symlinkTarget?.let { lines += "链接目标: $it" }
directorySnapshot?.let {
lines += "目录数: ${it.directories.size}"
lines += "文件数: ${it.files.size}"
}
return lines.joinToString(separator = "\n")
}
fun formatSize(bytes: Long): String {
if (bytes < 1024) {
return "${bytes}B"
}
val units = listOf("K", "M", "G", "T")
var value = bytes.toDouble()
var index = -1
while (value >= 1024 && index < units.lastIndex) {
value /= 1024.0
index++
}
return "${sizeFormatter.format(value)}${units[index]}"
}
fun guessKind(name: String, isDirectory: Boolean, permissions: String): RemoteFileKind {
if (isDirectory) {
return RemoteFileKind.Directory
}
if (permissions.startsWith("l")) {
return RemoteFileKind.Link
}
return when (name.substringAfterLast('.', "").lowercase()) {
"png", "jpg", "jpeg", "gif", "webp", "bmp", "heic", "svg" -> RemoteFileKind.Image
"mp4", "mkv", "avi", "webm", "mov", "ts", "m4v" -> RemoteFileKind.Video
"mp3", "flac", "aac", "ogg", "wav", "m4a" -> RemoteFileKind.Audio
"zip", "rar", "7z", "tar", "gz", "xz", "tgz" -> RemoteFileKind.Archive
"apk", "apks", "xapk" -> RemoteFileKind.Apk
"txt", "log", "json", "jsonc", "hjson", "xml", "yml", "yaml", "toml", "md",
"go", "kt", "java", "c", "cpp", "h", "hpp", "py", "sh", "bat",
-> RemoteFileKind.Text
else -> RemoteFileKind.Other
}
}
fun queryDisplayName(contentResolver: ContentResolver, uri: Uri): String? {
return contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { cursor ->
if (!cursor.moveToFirst()) {
return@use null
}
cursor.getString(0)
}
}
fun quoteShellArg(value: String): String {
return "'" + value.replace("'", "'\\''") + "'"
}
private fun parseListEntry(parentPath: String, rawLine: String): RemoteFileEntry? {
val match = listLineRegex.matchEntire(rawLine) ?: return null
val permissions = match.groupValues[2]
val dateTime = runCatching {
LocalDateTime.parse(
"${match.groupValues[7]} ${match.groupValues[8]}",
listTimeFormatter
)
}.getOrNull()
val nameField = match.groupValues[9]
val rawName = nameField.substringBefore(" -> ")
val symlinkTarget = nameField.substringAfter(" -> ", missingDelimiterValue = "")
.takeIf { it.isNotBlank() }
val cleanedName = stripListSuffix(unescapeLsName(rawName), permissions)
val fullPath = joinRemotePath(parentPath, cleanedName)
val isDirectory = permissions.startsWith("d") || rawName.endsWith("/")
return RemoteFileEntry(
inode = match.groupValues[1].toLongOrNull(),
permissions = permissions,
hardLinks = match.groupValues[3].toIntOrNull(),
owner = match.groupValues[4].ifBlank { null },
group = match.groupValues[5].ifBlank { null },
sizeBytes = match.groupValues[6].toLongOrNull(),
modifiedAt = dateTime,
name = cleanedName,
fullPath = fullPath,
symlinkTarget = symlinkTarget,
kind = guessKind(cleanedName, isDirectory, permissions),
isDirectory = isDirectory,
)
}
private fun parseStat(path: String, rawOutput: String): RemoteFileStat {
var sizeBytes: Long? = null
var blocks: Long? = null
var ioBlockBytes: Long? = null
var typeLabel: String? = null
var inode: Long? = null
var hardLinks: Int? = null
var octalMode: String? = null
var permissions: String? = null
var uid: Long? = null
var uidName: String? = null
var gid: Long? = null
var gidName: String? = null
var accessTime: String? = null
var modifyTime: String? = null
var changeTime: String? = null
var device: String? = null
var deviceType: String? = null
var symlinkTarget: String? = null
var statPath = path
rawOutput.lineSequence().forEach { line ->
val trimmed = line.trim()
when {
trimmed.startsWith("File:") -> {
statPath = trimmed.removePrefix("File:").trim()
if (" -> " in statPath) {
val parts = statPath.split(" -> ", limit = 2)
statPath = parts[0].trim()
symlinkTarget = parts.getOrNull(1)?.trim()
}
}
trimmed.startsWith("Size:") -> {
val match =
Regex("""Size:\s*(\d+)\s+Blocks:\s*(\d+)\s+IO Blocks:\s*(\d+)\s+(.+)""")
.find(trimmed)
sizeBytes = match?.groupValues?.getOrNull(1)?.toLongOrNull()
blocks = match?.groupValues?.getOrNull(2)?.toLongOrNull()
ioBlockBytes = match?.groupValues?.getOrNull(3)?.toLongOrNull()
typeLabel = match?.groupValues?.getOrNull(4)?.trim()
}
trimmed.startsWith("Device:") -> {
val match =
Regex("""Device:\s*(\S+)\s+Inode:\s*(\d+)\s+Links:\s*(\d+)\s+Device type:\s*(.+)""")
.find(trimmed)
device = match?.groupValues?.getOrNull(1)
inode = match?.groupValues?.getOrNull(2)?.toLongOrNull()
hardLinks = match?.groupValues?.getOrNull(3)?.toIntOrNull()
deviceType = match?.groupValues?.getOrNull(4)?.trim()
}
trimmed.startsWith("Access: (") -> {
val modeMatch = Regex("""Access:\s+\(([^/]+)/([^)]+)\)""").find(trimmed)
octalMode = modeMatch?.groupValues?.getOrNull(1)
permissions = modeMatch?.groupValues?.getOrNull(2)
val uidMatch = Regex("""Uid:\s+\(\s*(\d+)/\s*([^)]+)\)""").find(trimmed)
uid = uidMatch?.groupValues?.getOrNull(1)?.toLongOrNull()
uidName = uidMatch?.groupValues?.getOrNull(2)?.trim()
val gidMatch = Regex("""Gid:\s+\(\s*(\d+)/\s*([^)]+)\)""").find(trimmed)
gid = gidMatch?.groupValues?.getOrNull(1)?.toLongOrNull()
gidName = gidMatch?.groupValues?.getOrNull(2)?.trim()
}
trimmed.startsWith("Access:") -> accessTime = trimmed.removePrefix("Access:").trim()
trimmed.startsWith("Modify:") -> modifyTime = trimmed.removePrefix("Modify:").trim()
trimmed.startsWith("Change:") -> changeTime = trimmed.removePrefix("Change:").trim()
}
}
return RemoteFileStat(
path = statPath,
name = statPath.substringAfterLast('/'),
typeLabel = typeLabel,
sizeBytes = sizeBytes,
blocks = blocks,
ioBlockBytes = ioBlockBytes,
inode = inode,
hardLinks = hardLinks,
octalMode = octalMode,
permissions = permissions,
uid = uid,
uidName = uidName,
gid = gid,
gidName = gidName,
accessTime = accessTime,
modifyTime = modifyTime,
changeTime = changeTime,
device = device,
deviceType = deviceType,
symlinkTarget = symlinkTarget,
rawOutput = rawOutput.trim(),
)
}
private fun stripListSuffix(name: String, permissions: String): String {
if (name.isEmpty()) {
return name
}
val suffix = name.last()
return when {
permissions.startsWith("d") && suffix == '/' -> name.dropLast(1)
suffix in listOf('*', '@', '|', '=', '>') -> name.dropLast(1)
else -> name
}
}
private fun unescapeLsName(value: String): String {
val builder = StringBuilder(value.length)
var index = 0
while (index < value.length) {
val ch = value[index]
if (ch == '\\' && index + 1 < value.length) {
builder.append(value[index + 1])
index += 2
} else {
builder.append(ch)
index++
}
}
return builder.toString()
}
private fun joinRemotePath(parent: String, child: String): String {
val normalizedParent = parent.trimEnd('/').ifBlank { "/" }
val normalizedChild = child.trimStart('/')
return when (normalizedParent) {
"/" -> "/$normalizedChild"
else -> "$normalizedParent/$normalizedChild"
}
}
private fun pathForListCommand(path: String): String {
val normalized = path.trimEnd('/').ifBlank { "/" }
return if (normalized == "/") {
normalized
} else {
"$normalized/."
}
}
private fun guessMimeType(name: String): String {
val extension = name.substringAfterLast('.', "").lowercase()
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
?: "application/octet-stream"
}
private fun ensureDirectoryExists(directory: File?) {
requireNotNull(directory) { "目录不存在" }
if (directory.exists()) {
require(directory.isDirectory) { "目标不是文件夹: ${directory.absolutePath}" }
return
}
if (!directory.mkdirs()) {
throw IOException("无法创建目录: ${directory.absolutePath}")
}
}
private fun uniqueFile(directory: File, fileName: String): File {
val baseName = fileName.substringBeforeLast('.', fileName)
val extension = fileName.substringAfterLast('.', "")
var candidate = File(directory, fileName)
var index = 1
while (candidate.exists()) {
val suffix = " ($index)"
candidate = if (extension.isBlank() || baseName == fileName) {
File(directory, "$fileName$suffix")
} else {
File(directory, "$baseName$suffix.$extension")
}
index++
}
return candidate
}
private fun uniqueDirectory(parent: File, baseName: String): File {
var candidate = File(parent, baseName)
var index = 1
while (candidate.exists()) {
candidate = File(parent, "$baseName ($index)")
index++
}
return candidate
}
private fun createUniqueDirectoryDocument(
context: Context,
parentDocument: Uri,
baseName: String
): Uri {
var name = baseName
var index = 1
while (findChildDocument(
context,
parentDocument,
name,
DocumentsContract.Document.MIME_TYPE_DIR
) != null
) {
name = "$baseName ($index)"
index++
}
return DocumentsContract.createDocument(
context.contentResolver,
parentDocument,
DocumentsContract.Document.MIME_TYPE_DIR,
name,
) ?: throw IOException("无法创建目录: $name")
}
private fun createUniqueDocument(
context: Context,
parentDocument: Uri,
baseName: String,
mimeType: String,
): Uri {
val fileBase = baseName.substringBeforeLast('.', baseName)
val extension = baseName.substringAfterLast('.', "")
var candidate = baseName
var index = 1
while (findChildDocument(context, parentDocument, candidate, mimeType) != null) {
candidate = if (extension.isBlank() || fileBase == baseName) {
"$baseName ($index)"
} else {
"$fileBase ($index).$extension"
}
index++
}
return DocumentsContract.createDocument(
context.contentResolver,
parentDocument,
mimeType,
candidate,
) ?: throw IOException("无法创建文件: $candidate")
}
private fun ensureTreeDirectory(
context: Context,
rootDocument: Uri,
relativePath: String
): Uri {
var current = rootDocument
if (relativePath.isBlank()) {
return current
}
relativePath.split('/')
.filter { it.isNotBlank() }
.forEach { segment ->
val existing = findChildDocument(
context,
current,
segment,
DocumentsContract.Document.MIME_TYPE_DIR
)
current = existing ?: DocumentsContract.createDocument(
context.contentResolver,
current,
DocumentsContract.Document.MIME_TYPE_DIR,
segment,
) ?: throw IOException("无法创建目录: $segment")
}
return current
}
private fun findChildDocument(
context: Context,
parentDocument: Uri,
displayName: String,
mimeType: String,
): Uri? {
val parentId = DocumentsContract.getDocumentId(parentDocument)
val childrenUri =
DocumentsContract.buildChildDocumentsUriUsingTree(parentDocument, parentId)
val resolver = context.contentResolver
resolver.query(
childrenUri,
arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
),
null,
null,
null,
)?.use { cursor ->
while (cursor.moveToNext()) {
val childDisplayName = cursor.getString(1)
val childMimeType = cursor.getString(2)
if (childDisplayName == displayName && childMimeType == mimeType) {
return DocumentsContract.buildDocumentUriUsingTree(
parentDocument,
cursor.getString(0)
)
}
}
}
return null
}
}
class DirectorySnapshotSession private constructor(
private val shellSession: InteractiveShellSession,
) : Closeable {
suspend fun load(path: String): DirectoryDownloadSnapshot {
val normalizedPath = path.trimEnd('/').ifBlank { "/" }
val totalBytes = parseDuBytes(
shellSession.execute("du -sb ${FileManagerService.quoteShellArg(normalizedPath)}")
)
val directories = shellSession.execute(
"find ${FileManagerService.quoteShellArg(normalizedPath)} -type d"
)
.lineSequence()
.map(String::trim)
.filter { it.isNotBlank() && it != normalizedPath }
.map { relativePath(normalizedPath, it) }
.filter { it.isNotBlank() }
.toList()
val files = shellSession.execute(
"find ${FileManagerService.quoteShellArg(normalizedPath)} -type f"
)
.lineSequence()
.map(String::trim)
.filter { it.isNotBlank() && it != normalizedPath }
.map { relativePath(normalizedPath, it) }
.filter { it.isNotBlank() }
.toList()
return DirectoryDownloadSnapshot(
remoteRootPath = normalizedPath,
totalBytes = totalBytes,
directories = directories,
files = files,
)
}
suspend fun interrupt() {
shellSession.interrupt()
close()
}
override fun close() {
shellSession.close()
}
companion object {
suspend fun open(): DirectorySnapshotSession {
return DirectorySnapshotSession(InteractiveShellSession.open())
}
private fun parseDuBytes(output: String): Long? {
return output.lineSequence()
.map(String::trim).firstNotNullOfOrNull { line ->
Regex("""^(\d+)(?:\s+.*)?$""")
.find(line)
?.groupValues
?.getOrNull(1)
?.toLongOrNull()
}
}
private fun relativePath(root: String, fullPath: String): String {
val prefix = root.trimEnd('/') + "/"
return if (fullPath.startsWith(prefix)) {
fullPath.removePrefix(prefix)
} else {
fullPath
}
}
}
}
private class InteractiveShellSession private constructor(
private val stream: AdbSocketStream,
) : Closeable {
private val mutex = Mutex()
suspend fun execute(command: String): String = withContext(Dispatchers.IO) {
mutex.withLock {
val marker = "__SCRCPY_FILEMANAGER_${System.nanoTime()}__"
val commandBytes = buildString {
append("(")
append(command)
append(")\n")
append("printf '\\n")
append(marker)
append(":%d\\n' $?\n")
}.toByteArray(StandardCharsets.UTF_8)
stream.outputStream.write(commandBytes)
stream.outputStream.flush()
readUntilMarker(marker)
}
}
suspend fun interrupt() = withContext(Dispatchers.IO) {
runCatching {
stream.outputStream.write(byteArrayOf(3))
stream.outputStream.flush()
}
}
override fun close() {
runCatching { stream.close() }
}
private fun readUntilMarker(marker: String): String {
val builder = StringBuilder()
val buffer = ByteArray(4096)
val markerPrefix = "\n$marker:"
while (true) {
val count = stream.inputStream.read(buffer)
if (count <= 0) {
throw IOException("远端 shell 已关闭")
}
builder.append(String(buffer, 0, count, StandardCharsets.UTF_8))
val markerStart = builder.indexOf(markerPrefix)
if (markerStart >= 0) {
val markerEnd = builder.indexOf("\n", markerStart + markerPrefix.length)
if (markerEnd >= 0) {
val statusText =
builder.substring(markerStart + markerPrefix.length, markerEnd).trim()
val status = statusText.toIntOrNull() ?: 1
val output = builder.substring(0, markerStart).trimEnd('\r', '\n')
if (status != 0) {
throw IOException(output.ifBlank { "命令执行失败 ($status)" })
}
return output
}
}
}
}
companion object {
suspend fun open(): InteractiveShellSession {
return withContext(Dispatchers.IO) {
InteractiveShellSession(NativeAdbService.openShellStream(""))
}
}
}
}

View File

@@ -133,6 +133,14 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
booleanPreferencesKey("realtime_clipboard_sync_to_device"),
true
)
val FILE_MANAGER_SORT_BY = Pair(
stringPreferencesKey("file_manager_sort_by"),
"NAME"
)
val FILE_MANAGER_SORT_DESCENDING = Pair(
booleanPreferencesKey("file_manager_sort_descending"),
false
)
val LAST_UPDATE_CHECK_AT = Pair(
longPreferencesKey("last_update_check_at"),
0L
@@ -172,6 +180,8 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
val adbAutoLoadAppListOnConnect by setting(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT)
val passwordRequireAuth by setting(PASSWORD_REQUIRE_AUTH)
val realtimeClipboardSyncToDevice by setting(REALTIME_CLIPBOARD_SYNC_TO_DEVICE)
val fileManagerSortBy by setting(FILE_MANAGER_SORT_BY)
val fileManagerSortDescending by setting(FILE_MANAGER_SORT_DESCENDING)
val lastUpdateCheckAt by setting(LAST_UPDATE_CHECK_AT)
@Parcelize
@@ -202,6 +212,8 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
val adbAutoLoadAppListOnConnect: Boolean,
val passwordRequireAuth: Boolean,
val realtimeClipboardSyncToDevice: Boolean,
val fileManagerSortBy: String,
val fileManagerSortDescending: Boolean,
val lastUpdateCheckAt: Long,
) : Parcelable {
}
@@ -233,6 +245,8 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
bundleField(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT) { bundle: Bundle -> bundle.adbAutoLoadAppListOnConnect },
bundleField(PASSWORD_REQUIRE_AUTH) { bundle: Bundle -> bundle.passwordRequireAuth },
bundleField(REALTIME_CLIPBOARD_SYNC_TO_DEVICE) { bundle: Bundle -> bundle.realtimeClipboardSyncToDevice },
bundleField(FILE_MANAGER_SORT_BY) { bundle: Bundle -> bundle.fileManagerSortBy },
bundleField(FILE_MANAGER_SORT_DESCENDING) { bundle: Bundle -> bundle.fileManagerSortDescending },
bundleField(LAST_UPDATE_CHECK_AT) { bundle: Bundle -> bundle.lastUpdateCheckAt },
)
@@ -267,6 +281,8 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
adbAutoLoadAppListOnConnect = preferences.read(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT),
passwordRequireAuth = preferences.read(PASSWORD_REQUIRE_AUTH),
realtimeClipboardSyncToDevice = preferences.read(REALTIME_CLIPBOARD_SYNC_TO_DEVICE),
fileManagerSortBy = preferences.read(FILE_MANAGER_SORT_BY),
fileManagerSortDescending = preferences.read(FILE_MANAGER_SORT_DESCENDING),
lastUpdateCheckAt = preferences.read(LAST_UPDATE_CHECK_AT),
)

View File

@@ -4,8 +4,8 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn

View File

@@ -0,0 +1,35 @@
package io.github.miuzarte.scrcpyforandroid.widgets
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import top.yukonga.miuix.kmp.basic.DropdownImpl
import top.yukonga.miuix.kmp.basic.HorizontalDivider
data class MultiGroupsDropdownGroup(
val options: List<String>,
val selectedIndex: Int,
val onSelectedIndexChange: (Int) -> Unit,
)
@Composable
fun MultiGroupsDropdown(
groups: List<MultiGroupsDropdownGroup>,
modifier: Modifier = Modifier,
) {
groups.forEachIndexed { groupIndex, group ->
group.options.forEachIndexed { optionIndex, option ->
DropdownImpl(
text = option,
optionSize = group.options.size,
isSelected = optionIndex == group.selectedIndex,
index = optionIndex,
onSelectedIndexChange = group.onSelectedIndexChange,
)
}
if (groupIndex != groups.lastIndex) {
HorizontalDivider(modifier = modifier.padding(horizontal = 20.dp))
}
}
}

View File

@@ -15,9 +15,9 @@ class TerminalInputView @JvmOverloads constructor(
defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {
interface InputCallbacks {
fun handleKeyEvent(event: KeyEvent): Boolean
fun handleCommitText(text: CharSequence): Boolean
fun handleDeleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean
fun handleKeyEvent(event: KeyEvent): Boolean
}
var inputCallbacks: InputCallbacks? = null
@@ -66,9 +66,10 @@ class TerminalInputView @JvmOverloads constructor(
}
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
if (inputCallbacks?.handleDeleteSurroundingText(beforeLength, afterLength) == true) {
return true
}
if (inputCallbacks
?.handleDeleteSurroundingText(beforeLength, afterLength) == true
) return true
return super.deleteSurroundingText(beforeLength, afterLength)
}