From ed768584c6c9e3d97370cc94b3aaa83904f2ae4e Mon Sep 17 00:00:00 2001
From: Miuzarte <982809597@qq.com>
Date: Wed, 22 Apr 2026 02:43:17 +0800
Subject: [PATCH] feat: file management; streaming terminal
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 实现 FileManagerService,处理远程文件操作,包括列出目录、上传和下载文件
- 增加对目录快照和文件统计信息检索的支持
- 引入 MultiGroupsDropdown 控件,用于在 UI 中显示分组下拉选项,仿照 MIUI 自带的文件管理排序 Popup
---
.gitignore | 2 +
CHANGELOG.md | 106 ++
README.md | 7 +-
TODO.md | 2 +
app/src/main/AndroidManifest.xml | 1 +
.../nativecore/DirectAdbClient.kt | 74 +-
.../nativecore/NativeAdbService.kt | 86 +-
.../scrcpyforandroid/pages/DeviceTabScreen.kt | 6 -
.../pages/FileManagerScreen.kt | 1054 +++++++++++++++++
.../scrcpyforandroid/pages/MainScreen.kt | 55 +-
.../scrcpyforandroid/pages/SettingsScreen.kt | 46 +
.../services/DeviceConnectionLogic.kt | 32 +-
.../services/FileManagerService.kt | 809 +++++++++++++
.../scrcpyforandroid/storage/AppSettings.kt | 16 +
.../widgets/AppBottomSheets.kt | 2 +-
.../widgets/MultiGroupsDropdown.kt | 35 +
.../widgets/TerminalInputView.kt | 9 +-
17 files changed, 2284 insertions(+), 58 deletions(-)
create mode 100644 CHANGELOG.md
create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerScreen.kt
create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/FileManagerService.kt
create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/MultiGroupsDropdown.kt
diff --git a/.gitignore b/.gitignore
index 89e2ea9..986a919 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,4 +36,6 @@ google-services.json
.kiro/
+.kotlin/
+
scrcpy.help*
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..0e1b78f
--- /dev/null
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 74872a4..4e31191 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/TODO.md b/TODO.md
index e8f4a7c..9c988e1 100644
--- a/TODO.md
+++ b/TODO.md
@@ -2,6 +2,8 @@
# TODO
+fix wake lock
+
## PARAMS
### LOWER PRIORITY
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d6d586e..c31fdac 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -9,6 +9,7 @@
+
$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
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt
index 0d10850..5e61de8 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt
@@ -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 {
+ 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(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()
+
+ fun command(command: String) {
+ commands += command
+ }
+ }
+
private const val TAG = "NativeAdbService"
}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt
index 8b34592..9b3bb55 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt
@@ -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
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerScreen.kt
new file mode 100644
index 0000000..c035e81
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerScreen.kt
@@ -0,0 +1,1054 @@
+package io.github.miuzarte.scrcpyforandroid.pages
+
+import android.content.Intent
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+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.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material.icons.automirrored.rounded.InsertDriveFile
+import androidx.compose.material.icons.rounded.Android
+import androidx.compose.material.icons.rounded.Archive
+import androidx.compose.material.icons.rounded.AudioFile
+import androidx.compose.material.icons.rounded.Description
+import androidx.compose.material.icons.rounded.Download
+import androidx.compose.material.icons.rounded.Folder
+import androidx.compose.material.icons.rounded.Image
+import androidx.compose.material.icons.rounded.Link
+import androidx.compose.material.icons.rounded.MoreVert
+import androidx.compose.material.icons.rounded.Movie
+import androidx.compose.material.icons.rounded.RawOff
+import androidx.compose.material.icons.rounded.RawOn
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
+import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
+import io.github.miuzarte.scrcpyforandroid.services.DirectoryDownloadSnapshot
+import io.github.miuzarte.scrcpyforandroid.services.DirectorySnapshotSession
+import io.github.miuzarte.scrcpyforandroid.services.FileManagerService
+import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
+import io.github.miuzarte.scrcpyforandroid.services.RemoteFileEntry
+import io.github.miuzarte.scrcpyforandroid.services.RemoteFileKind
+import io.github.miuzarte.scrcpyforandroid.services.RemoteFileStat
+import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
+import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar
+import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
+import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
+import io.github.miuzarte.scrcpyforandroid.widgets.MultiGroupsDropdown
+import io.github.miuzarte.scrcpyforandroid.widgets.MultiGroupsDropdownGroup
+import io.github.miuzarte.scrcpyforandroid.widgets.PopupMenuItem
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import top.yukonga.miuix.kmp.basic.ButtonDefaults
+import top.yukonga.miuix.kmp.basic.Card
+import top.yukonga.miuix.kmp.basic.Icon
+import top.yukonga.miuix.kmp.basic.IconButton
+import top.yukonga.miuix.kmp.basic.ListPopupColumn
+import top.yukonga.miuix.kmp.basic.ListPopupDefaults
+import top.yukonga.miuix.kmp.basic.PullToRefresh
+import top.yukonga.miuix.kmp.basic.Scaffold
+import top.yukonga.miuix.kmp.basic.SmallTopAppBar
+import top.yukonga.miuix.kmp.basic.Text
+import top.yukonga.miuix.kmp.basic.TextButton
+import top.yukonga.miuix.kmp.basic.TextField
+import top.yukonga.miuix.kmp.basic.rememberPullToRefreshState
+import top.yukonga.miuix.kmp.blur.layerBackdrop
+import top.yukonga.miuix.kmp.icon.MiuixIcons
+import top.yukonga.miuix.kmp.icon.extended.Tune
+import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet
+import top.yukonga.miuix.kmp.overlay.OverlayDialog
+import top.yukonga.miuix.kmp.overlay.OverlayListPopup
+import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
+
+private const val ROOT_REMOTE_PATH = "/"
+private const val INITIAL_REMOTE_PATH = "/storage/emulated/0"
+
+private enum class FileManagerSortField {
+ NAME,
+ SIZE,
+ TIME,
+ EXTENSION,
+}
+
+private data class FileManagerScrollPosition(
+ val index: Int,
+ val offset: Int,
+)
+
+private sealed interface PendingTreeDownload {
+ data class File(
+ val remotePath: String,
+ val fileName: String,
+ ) : PendingTreeDownload
+
+ data class Directory(
+ val snapshot: DirectoryDownloadSnapshot,
+ ) : PendingTreeDownload
+}
+
+@Composable
+fun FileManagerScreen(
+ bottomInnerPadding: Dp,
+ onCanNavigateUpChange: (Boolean) -> Unit = {},
+ onNavigateUpActionChange: (((() -> Boolean)?) -> Unit)? = null,
+) {
+ val context = LocalContext.current
+ val snackbar = LocalSnackbarController.current
+ val blurBackdrop = rememberBlurBackdrop(LocalEnableBlur.current)
+ val blurActive = blurBackdrop != null
+ val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
+ val pullToRefreshState = rememberPullToRefreshState()
+ val listState = rememberLazyListState()
+ val asBundle by appSettings.bundleState.collectAsState()
+
+ val pathStack = remember {
+ mutableStateListOf().apply {
+ addAll(buildPathStack(INITIAL_REMOTE_PATH))
+ }
+ }
+ val currentPath = pathStack.lastOrNull() ?: INITIAL_REMOTE_PATH
+ val directoryCache = remember { mutableStateMapOf>() }
+ val directoryScrollCache = remember { mutableStateMapOf() }
+ var loading by rememberSaveable { mutableStateOf(false) }
+ var isRefreshing by rememberSaveable { mutableStateOf(false) }
+ var errorText by rememberSaveable { mutableStateOf(null) }
+ var showMenu by rememberSaveable { mutableStateOf(false) }
+ var showSortMenu by rememberSaveable { mutableStateOf(false) }
+ var showPathDialog by rememberSaveable { mutableStateOf(false) }
+ var showCreateFolderDialog by rememberSaveable { mutableStateOf(false) }
+ var showDetailsSheet by rememberSaveable { mutableStateOf(false) }
+ var showRawDetails by rememberSaveable { mutableStateOf(false) }
+ var pathInput by rememberSaveable { mutableStateOf(INITIAL_REMOTE_PATH) }
+ var newFolderName by rememberSaveable { mutableStateOf("") }
+ var selectedEntry by remember { mutableStateOf(null) }
+ var selectedStat by remember { mutableStateOf(null) }
+ var selectedTargetStat by remember { mutableStateOf(null) }
+ var selectedSnapshot by remember { mutableStateOf(null) }
+ var detailLoading by rememberSaveable { mutableStateOf(false) }
+ var pendingTreeDownload by remember { mutableStateOf(null) }
+ var activeSnapshotSession by remember { mutableStateOf(null) }
+
+ val sortField = remember(asBundle.fileManagerSortBy) {
+ runCatching { FileManagerSortField.valueOf(asBundle.fileManagerSortBy) }
+ .getOrDefault(FileManagerSortField.NAME)
+ }
+ val sortDescending = asBundle.fileManagerSortDescending
+ val cachedEntries = directoryCache[currentPath]
+ val displayedEntries = remember(cachedEntries, sortField, sortDescending) {
+ sortEntries(cachedEntries.orEmpty(), sortField, sortDescending)
+ }
+
+ fun clearDetails() {
+ selectedEntry = null
+ selectedStat = null
+ selectedTargetStat = null
+ selectedSnapshot = null
+ detailLoading = false
+ showRawDetails = false
+ val session = activeSnapshotSession
+ activeSnapshotSession = null
+ if (session != null) {
+ taskScope.launch {
+ runCatching { session.interrupt() }
+ }
+ }
+ }
+
+ fun dismissDetails() {
+ showDetailsSheet = false
+ }
+
+ fun rememberCurrentScrollPosition() {
+ directoryScrollCache[currentPath] = FileManagerScrollPosition(
+ index = listState.firstVisibleItemIndex,
+ offset = listState.firstVisibleItemScrollOffset,
+ )
+ }
+
+ fun navigateUp(): Boolean {
+ if (pathStack.size <= 1) {
+ return false
+ }
+ rememberCurrentScrollPosition()
+ pathStack.removeAt(pathStack.lastIndex)
+ return true
+ }
+
+ fun jumpToPath(rawPath: String) {
+ val normalized = normalizePath(rawPath)
+ rememberCurrentScrollPosition()
+ pathStack.clear()
+ pathStack.addAll(buildPathStack(normalized))
+ }
+
+ suspend fun reloadCurrentDirectory(force: Boolean) {
+ val cached = directoryCache[currentPath]
+ if (cached != null && !force) {
+ errorText = null
+ return
+ }
+ loading = cached == null
+ errorText = null
+ val result = runCatching {
+ FileManagerService.listDirectory(currentPath)
+ }
+ loading = false
+ isRefreshing = false
+ result.onSuccess { directoryCache[currentPath] = it }
+ .onFailure { error ->
+ errorText = error.message ?: error.javaClass.simpleName
+ }
+ }
+
+ suspend fun startDownloadToTree(treeUri: Uri, request: PendingTreeDownload) {
+ when (request) {
+ is PendingTreeDownload.File -> {
+ FileManagerService.downloadFileToTree(
+ context = context,
+ treeUri = treeUri,
+ remotePath = request.remotePath,
+ fileName = request.fileName,
+ )
+ }
+
+ is PendingTreeDownload.Directory -> {
+ FileManagerService.downloadDirectoryToTree(
+ context = context,
+ treeUri = treeUri,
+ snapshot = request.snapshot,
+ )
+ }
+ }
+ }
+
+ fun requestDownload(entry: RemoteFileEntry) {
+ val snapshot = selectedSnapshot
+ dismissDetails()
+ snackbar.show("开始下载")
+ taskScope.launch {
+ if (entry.isDirectory) {
+ if (snapshot == null) {
+ withContext(Dispatchers.Main) {
+ snackbar.show("目录信息仍在加载,请稍后重试")
+ }
+ return@launch
+ }
+ val directSaved = FileManagerService.downloadDirectoryToPublicDownloads(snapshot)
+ if (directSaved) {
+ withContext(Dispatchers.Main) { snackbar.show("已下载到 Download/Scrcpy") }
+ } else {
+ withContext(Dispatchers.Main) {
+ pendingTreeDownload = PendingTreeDownload.Directory(snapshot)
+ snackbar.show("无法直接写入 Download/Scrcpy,请选择保存目录")
+ }
+ }
+ } else {
+ val directSaved = FileManagerService.downloadFileToPublicDownloads(
+ remotePath = entry.fullPath,
+ fileName = entry.name,
+ )
+ if (directSaved) {
+ withContext(Dispatchers.Main) { snackbar.show("已下载到 Download/Scrcpy") }
+ } else {
+ withContext(Dispatchers.Main) {
+ pendingTreeDownload = PendingTreeDownload.File(entry.fullPath, entry.name)
+ snackbar.show("无法直接写入 Download/Scrcpy,请选择保存目录")
+ }
+ }
+ }
+ }
+ }
+
+ fun updateSort(sortBy: FileManagerSortField? = null, descending: Boolean? = null) {
+ rememberCurrentScrollPosition()
+ taskScope.launch {
+ appSettings.updateBundle {
+ it.copy(
+ fileManagerSortBy = (sortBy ?: sortField).name,
+ fileManagerSortDescending = descending ?: sortDescending,
+ )
+ }
+ }
+ }
+
+ fun openPathDialog() {
+ pathInput = currentPath
+ showPathDialog = true
+ }
+
+ fun openCreateFolderDialog() {
+ newFolderName = ""
+ showCreateFolderDialog = true
+ }
+
+ val uploadLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ if (uri == null) return@rememberLauncherForActivityResult
+ taskScope.launch {
+ val result = runCatching {
+ context.contentResolver.takePersistableUriPermission(
+ uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION
+ )
+ FileManagerService.uploadFile(context, uri, currentPath)
+ }
+ withContext(Dispatchers.Main) {
+ result.onSuccess {
+ snackbar.show("已上传到 $currentPath")
+ directoryCache.remove(currentPath)
+ isRefreshing = true
+ }.onFailure { error ->
+ snackbar.show("上传失败: ${error.message ?: error.javaClass.simpleName}")
+ }
+ }
+ }
+ }
+
+ val treeLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
+ val request = pendingTreeDownload
+ pendingTreeDownload = null
+ if (uri == null || request == null) return@rememberLauncherForActivityResult
+ taskScope.launch {
+ val result = runCatching {
+ context.contentResolver.takePersistableUriPermission(
+ uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ )
+ startDownloadToTree(uri, request)
+ }
+ withContext(Dispatchers.Main) {
+ result.onSuccess {
+ snackbar.show("下载完成")
+ }.onFailure { error ->
+ snackbar.show("下载失败: ${error.message ?: error.javaClass.simpleName}")
+ }
+ }
+ }
+ }
+
+ LaunchedEffect(currentPath) {
+ reloadCurrentDirectory(force = false)
+ }
+
+ LaunchedEffect(currentPath, sortField, sortDescending, displayedEntries.size, loading) {
+ if (loading || displayedEntries.isEmpty()) return@LaunchedEffect
+
+ val scrollPosition = directoryScrollCache[currentPath] ?: return@LaunchedEffect
+
+ val targetIndex = scrollPosition.index.coerceIn(0, displayedEntries.lastIndex)
+ listState.scrollToItem(targetIndex, scrollPosition.offset)
+ }
+
+ LaunchedEffect(isRefreshing) {
+ if (isRefreshing) reloadCurrentDirectory(force = true)
+ }
+
+ LaunchedEffect(pendingTreeDownload) {
+ if (pendingTreeDownload != null) treeLauncher.launch(null)
+ }
+
+ DisposableEffect(pathStack.size) {
+ val canNavigateUp = pathStack.size > 1
+ onCanNavigateUpChange(canNavigateUp)
+ onNavigateUpActionChange?.invoke(::navigateUp)
+ onDispose {
+ onCanNavigateUpChange(false)
+ onNavigateUpActionChange?.invoke(null)
+ }
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ clearDetails()
+ taskScope.cancel()
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ BlurredBar(backdrop = blurBackdrop) {
+ SmallTopAppBar(
+ title = "文件",
+ color = if (blurActive) Color.Transparent else colorScheme.surface,
+ navigationIcon = {
+ IconButton(
+ onClick = ::navigateUp,
+ enabled = pathStack.size > 1,
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
+ contentDescription = "上一层",
+ )
+ }
+ },
+ bottomContent = {
+ Text(
+ text = currentPath,
+ modifier = Modifier
+ .fillMaxWidth()
+ .combinedClickable(
+ onClick = ::openPathDialog,
+ onLongClick = ::openPathDialog,
+ )
+ .padding(
+ start = UiSpacing.PageHorizontal,
+ end = UiSpacing.PageHorizontal,
+ bottom = UiSpacing.Medium,
+ ),
+ softWrap = false,
+ overflow = TextOverflow.Ellipsis,
+ color = colorScheme.onSurfaceVariantSummary,
+ )
+ },
+ actions = {
+ Box {
+ IconButton(
+ onClick = { showSortMenu = true },
+ holdDownState = showSortMenu,
+ ) {
+ Icon(
+ imageVector = MiuixIcons.Tune,
+ contentDescription = "排序",
+ )
+ }
+
+ OverlayListPopup(
+ show = showSortMenu,
+ popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
+ onDismissRequest = { showSortMenu = false },
+ ) {
+ ListPopupColumn {
+ MultiGroupsDropdown(
+ groups = listOf(
+ MultiGroupsDropdownGroup(
+ options = listOf(
+ "文件名",
+ "大小",
+ "时间",
+ "扩展名"
+ ),
+ selectedIndex = when (sortField) {
+ FileManagerSortField.NAME -> 0
+ FileManagerSortField.SIZE -> 1
+ FileManagerSortField.TIME -> 2
+ FileManagerSortField.EXTENSION -> 3
+ },
+ onSelectedIndexChange = { index ->
+ updateSort(
+ sortBy = when (index) {
+ 1 -> FileManagerSortField.SIZE
+ 2 -> FileManagerSortField.TIME
+ 3 -> FileManagerSortField.EXTENSION
+ else -> FileManagerSortField.NAME
+ }
+ )
+ },
+ ),
+ MultiGroupsDropdownGroup(
+ options = listOf("正序", "倒序"),
+ selectedIndex = if (sortDescending) 1 else 0,
+ onSelectedIndexChange = { index ->
+ updateSort(descending = index == 1)
+ },
+ ),
+ ),
+ )
+ }
+ }
+ }
+
+ Box {
+ IconButton(
+ onClick = { showMenu = true },
+ holdDownState = showMenu,
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.MoreVert,
+ contentDescription = "更多",
+ )
+ }
+
+ OverlayListPopup(
+ show = showMenu,
+ popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
+ onDismissRequest = { showMenu = false },
+ ) {
+ ListPopupColumn {
+ PopupMenuItem(
+ text = "创建文件夹",
+ optionSize = 2,
+ index = 0,
+ onSelectedIndexChange = {
+ showMenu = false
+ openCreateFolderDialog()
+ },
+ )
+ PopupMenuItem(
+ text = "上传文件到该目录",
+ optionSize = 2,
+ index = 1,
+ onSelectedIndexChange = {
+ showMenu = false
+ uploadLauncher.launch(arrayOf("*/*"))
+ },
+ )
+ }
+ }
+ }
+ },
+ )
+ }
+ },
+ ) { pagePadding ->
+ Box(
+ modifier =
+ if (blurActive) Modifier.layerBackdrop(blurBackdrop)
+ else Modifier,
+ ) {
+ PullToRefresh(
+ isRefreshing = isRefreshing,
+ onRefresh = { isRefreshing = true },
+ pullToRefreshState = pullToRefreshState,
+ refreshTexts = listOf("下拉刷新", "释放刷新", "正在刷新...", "刷新完成"),
+ contentPadding = PaddingValues(
+ top = pagePadding.calculateTopPadding() + 12.dp,
+ ),
+ ) {
+ LazyColumn(
+ contentPadding = pagePadding,
+ bottomInnerPadding = bottomInnerPadding,
+ state = listState,
+ ) {
+ if (loading && cachedEntries == null) {
+ item { FileManagerStatusCard("加载中") }
+ } else if (errorText != null && cachedEntries == null) {
+ item { FileManagerStatusCard("加载失败: $errorText") }
+ } else if (displayedEntries.isEmpty()) {
+ item { FileManagerStatusCard("空目录") }
+ } else {
+ items(displayedEntries) { entry ->
+ FileManagerItemCard(
+ entry = entry,
+ summary = FileManagerService.formatSummary(entry),
+ onClick = {
+ when {
+ entry.isDirectory -> {
+ rememberCurrentScrollPosition()
+ pathStack.add(normalizePath(entry.fullPath))
+ }
+
+ entry.kind == RemoteFileKind.Link || entry.symlinkTarget != null -> {
+ taskScope.launch {
+ val targetPath = resolveLinkTarget(entry)
+ if (targetPath == null) {
+ withContext(Dispatchers.Main) {
+ snackbar.show("链接目标不可用,长按查看信息")
+ }
+ return@launch
+ }
+ val result = runCatching {
+ FileManagerService.stat(targetPath)
+ }
+ withContext(Dispatchers.Main) {
+ result.onSuccess { targetStat ->
+ if (isDirectoryStat(targetStat)) {
+ jumpToPath(targetPath)
+ } else {
+ snackbar.show("链接目标不是文件夹,长按查看信息")
+ }
+ }.onFailure { error ->
+ snackbar.show("读取链接目标失败: ${error.message ?: error.javaClass.simpleName}")
+ }
+ }
+ }
+ }
+
+ else -> snackbar.show("长按可查看文件详情")
+ }
+ },
+ onLongClick = {
+ clearDetails()
+ selectedEntry = entry
+ showDetailsSheet = true
+ detailLoading = true
+ taskScope.launch {
+ val statResult = runCatching {
+ FileManagerService.stat(entry.fullPath)
+ }
+ val linkTargetPath = resolveLinkTarget(entry)
+ val targetStatResult =
+ if (linkTargetPath != null)
+ runCatching { FileManagerService.stat(linkTargetPath) }
+ else null
+
+ val snapshotResult =
+ if (entry.isDirectory)
+ runCatching {
+ val session = DirectorySnapshotSession.open()
+ withContext(Dispatchers.Main) {
+ activeSnapshotSession = session
+ }
+ session.load(entry.fullPath)
+ }
+ else null
+
+ withContext(Dispatchers.Main) {
+ detailLoading = false
+ statResult
+ .onSuccess { selectedStat = it }
+ .onFailure { error ->
+ snackbar.show("读取详情失败: ${error.message ?: error.javaClass.simpleName}")
+ if (selectedEntry === entry)
+ selectedEntry = null
+ }
+ targetStatResult
+ ?.onSuccess { selectedTargetStat = it }
+ snapshotResult
+ ?.onSuccess { selectedSnapshot = it }
+ ?.onFailure { error ->
+ snackbar.show("目录扫描失败: ${error.message ?: error.javaClass.simpleName}")
+ }
+ }
+ }
+ },
+ )
+ }
+ }
+ }
+ }
+
+ val entry = selectedEntry
+ if (entry != null || showDetailsSheet) {
+ FileDetailsBottomSheet(
+ show = showDetailsSheet,
+ content = when {
+ detailLoading -> "正在加载详情"
+ entry != null && selectedStat != null -> buildDetailsText(
+ stat = selectedStat!!,
+ targetStat = selectedTargetStat,
+ directorySnapshot =
+ if (entry.isDirectory) selectedSnapshot
+ else null,
+ showRaw = showRawDetails,
+ )
+
+ else -> "暂无详情"
+ },
+ onDismissRequest = ::dismissDetails,
+ onDismissFinished = ::clearDetails,
+ onToggleRaw = { showRawDetails = !showRawDetails },
+ showingRaw = showRawDetails,
+ onDownload = { entry?.let(::requestDownload) },
+ downloadEnabled = entry != null
+ && !detailLoading
+ && (!entry.isDirectory || selectedSnapshot != null),
+ )
+ }
+
+ PathJumpDialog(
+ show = showPathDialog,
+ path = pathInput,
+ onPathChange = { pathInput = it },
+ onDismissRequest = { showPathDialog = false },
+ onConfirm = {
+ showPathDialog = false
+ jumpToPath(pathInput)
+ },
+ )
+
+ CreateFolderDialog(
+ show = showCreateFolderDialog,
+ folderName = newFolderName,
+ onFolderNameChange = { newFolderName = it },
+ onDismissRequest = { showCreateFolderDialog = false },
+ onConfirm = {
+ showCreateFolderDialog = false
+ val folderName = newFolderName.trim()
+ if (folderName.isBlank())
+ snackbar.show("文件夹名称不能为空")
+ else taskScope.launch {
+ val result = runCatching {
+ FileManagerService.createDirectory(currentPath, folderName)
+ }
+ withContext(Dispatchers.Main) {
+ result.onSuccess {
+ snackbar.show("已创建文件夹")
+ directoryCache.remove(currentPath)
+ isRefreshing = true
+ }.onFailure { error ->
+ snackbar.show("创建失败: ${error.message ?: error.javaClass.simpleName}")
+ }
+ }
+ }
+ },
+ )
+ }
+ }
+}
+
+@Composable
+private fun FileManagerStatusCard(message: String) {
+ Card {
+ Text(
+ text = message,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ color = colorScheme.onSurfaceVariantSummary,
+ )
+ }
+}
+
+@Composable
+private fun FileManagerItemCard(
+ entry: RemoteFileEntry,
+ summary: String,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .combinedClickable(
+ onClick = onClick,
+ onLongClick = onLongClick,
+ ),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = iconForEntry(entry),
+ contentDescription = entry.name,
+ )
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = UiSpacing.Medium),
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ text = entry.name,
+ maxLines = 2,
+ softWrap = false,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = summary,
+ fontSize = 13.sp,
+ color = colorScheme.onSurfaceVariantSummary,
+ maxLines = 1,
+ softWrap = false,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun FileDetailsBottomSheet(
+ show: Boolean,
+ content: String,
+ onDismissRequest: () -> Unit,
+ onDismissFinished: (() -> Unit)? = null,
+ onToggleRaw: () -> Unit,
+ showingRaw: Boolean,
+ onDownload: () -> Unit,
+ downloadEnabled: Boolean,
+ title: String = "详细信息",
+) {
+ OverlayBottomSheet(
+ show = show,
+ title = title,
+ defaultWindowInsetsPadding = false,
+ onDismissRequest = onDismissRequest,
+ onDismissFinished = onDismissFinished,
+ startAction = {
+ IconButton(
+ onClick = onToggleRaw,
+ ) {
+ Icon(
+ imageVector =
+ if (!showingRaw) Icons.Rounded.RawOff
+ else Icons.Rounded.RawOn,
+ contentDescription =
+ if (!showingRaw) "显示原文"
+ else "显示解析",
+ )
+ }
+ },
+ endAction = {
+ IconButton(
+ onClick = onDownload,
+ enabled = downloadEnabled,
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Download,
+ contentDescription = "下载",
+ )
+ }
+ },
+ ) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight(2f / 3f),
+ ) {
+ item {
+ TextField(
+ value = content,
+ onValueChange = {},
+ readOnly = true,
+ modifier = Modifier.fillMaxWidth(),
+ useLabelAsPlaceholder = true,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun PathJumpDialog(
+ show: Boolean,
+ path: String,
+ onPathChange: (String) -> Unit,
+ onDismissRequest: () -> Unit,
+ onConfirm: () -> Unit,
+) {
+ OverlayDialog(
+ show = show,
+ title = "跳转路径",
+ defaultWindowInsetsPadding = false,
+ onDismissRequest = onDismissRequest,
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
+ ) {
+ TextField(
+ value = path,
+ onValueChange = onPathChange,
+ label = "/storage/emulated/0",
+ useLabelAsPlaceholder = true,
+ )
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal),
+ ) {
+ TextButton(
+ text = "取消",
+ onClick = onDismissRequest,
+ modifier = Modifier.weight(1f),
+ )
+ TextButton(
+ text = "确定",
+ onClick = onConfirm,
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.textButtonColorsPrimary(),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun CreateFolderDialog(
+ show: Boolean,
+ folderName: String,
+ onFolderNameChange: (String) -> Unit,
+ onDismissRequest: () -> Unit,
+ onConfirm: () -> Unit,
+) {
+ OverlayDialog(
+ show = show,
+ title = "创建文件夹",
+ defaultWindowInsetsPadding = false,
+ onDismissRequest = onDismissRequest,
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
+ ) {
+ TextField(
+ value = folderName,
+ onValueChange = onFolderNameChange,
+ label = "新建文件夹",
+ useLabelAsPlaceholder = true,
+ )
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal),
+ ) {
+ TextButton(
+ text = "取消",
+ onClick = onDismissRequest,
+ modifier = Modifier.weight(1f),
+ )
+ TextButton(
+ text = "确定",
+ onClick = onConfirm,
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.textButtonColorsPrimary(),
+ )
+ }
+ }
+ }
+}
+
+private fun iconForEntry(entry: RemoteFileEntry): ImageVector = when (entry.kind) {
+ RemoteFileKind.Directory -> Icons.Rounded.Folder
+ RemoteFileKind.Image -> Icons.Rounded.Image
+ RemoteFileKind.Video -> Icons.Rounded.Movie
+ RemoteFileKind.Audio -> Icons.Rounded.AudioFile
+ RemoteFileKind.Archive -> Icons.Rounded.Archive
+ RemoteFileKind.Apk -> Icons.Rounded.Android
+ RemoteFileKind.Text -> Icons.Rounded.Description
+ RemoteFileKind.Link -> Icons.Rounded.Link
+ RemoteFileKind.Other -> Icons.AutoMirrored.Rounded.InsertDriveFile
+}
+
+private fun buildPathStack(path: String): List {
+ val normalized = normalizePath(path)
+ if (normalized == ROOT_REMOTE_PATH) return listOf(ROOT_REMOTE_PATH)
+
+ val parts = normalized.trim('/').split('/').filter { it.isNotBlank() }
+ val stack = mutableListOf(ROOT_REMOTE_PATH)
+ var current = ""
+ parts.forEach { part ->
+ current += "/$part"
+ stack += current
+ }
+ return stack
+}
+
+private fun normalizePath(path: String): String {
+ val trimmed = path.trim()
+ if (trimmed.isBlank()) {
+ return ROOT_REMOTE_PATH
+ }
+ return "/" + trimmed
+ .trim('/')
+ .split('/')
+ .filter { it.isNotBlank() }
+ .joinToString("/")
+ .ifBlank { ROOT_REMOTE_PATH.removePrefix("/") }
+}
+
+private fun sortEntries(
+ entries: List,
+ field: FileManagerSortField,
+ descending: Boolean,
+): List {
+ val contentComparator = when (field) {
+ FileManagerSortField.NAME
+ -> compareBy { it.name.lowercase() }
+
+ FileManagerSortField.SIZE
+ -> compareBy { it.sizeBytes ?: -1L }
+ .thenBy { it.name.lowercase() }
+
+ FileManagerSortField.TIME
+ -> compareBy { it.modifiedAt }
+ .thenBy { it.name.lowercase() }
+
+ FileManagerSortField.EXTENSION
+ -> compareBy { extensionSortBucket(it) }
+ .thenBy { extensionSortKey(it) }
+ .thenBy { it.name.lowercase() }
+ }
+ if (field == FileManagerSortField.EXTENSION) {
+ val extensionComparator =
+ if (descending)
+ compareBy { extensionSortBucket(it) }
+ .then(compareByDescending { extensionSortKey(it) })
+ .thenBy { it.name.lowercase() }
+ else contentComparator
+ return entries.sortedWith(extensionComparator)
+ }
+ val orderComparator =
+ if (descending) contentComparator.reversed()
+ else contentComparator
+ return entries.sortedWith(
+ compareByDescending { it.isDirectory }
+ .then(orderComparator)
+ )
+}
+
+private fun resolveLinkTarget(entry: RemoteFileEntry): String? {
+ val target = entry.symlinkTarget?.trim()?.takeIf { it.isNotBlank() } ?: return null
+ return if (target.startsWith("/")) normalizePath(target)
+ else normalizePath(entry.fullPath.substringBeforeLast('/', "") + "/" + target)
+}
+
+private fun isDirectoryStat(stat: RemoteFileStat): Boolean {
+ return stat.permissions?.startsWith("d") == true
+ || stat.typeLabel?.contains("directory", ignoreCase = true) == true
+}
+
+private fun buildDetailsText(
+ stat: RemoteFileStat,
+ targetStat: RemoteFileStat?,
+ directorySnapshot: DirectoryDownloadSnapshot?,
+ showRaw: Boolean,
+): String {
+ val details = StringBuilder(
+ if (showRaw) stat.rawOutput
+ else FileManagerService.formatStatDetails(stat, directorySnapshot)
+ )
+ if (targetStat != null) {
+ details.append("\n\n目标信息\n")
+ details.append(
+ if (showRaw) targetStat.rawOutput
+ else FileManagerService.formatStatDetails(targetStat)
+ )
+ }
+ return details.toString()
+}
+
+private fun extensionSortBucket(entry: RemoteFileEntry): Int {
+ return if (entry.isDirectory || extensionSortKey(entry).isEmpty()) 0 else 1
+}
+
+private fun extensionSortKey(entry: RemoteFileEntry): String {
+ if (entry.isDirectory) return ""
+ return entry.name.substringAfterLast('.', "").lowercase()
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt
index 4161a98..e5c287d 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt
@@ -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(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,
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt
index 256908c..737b62c 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt
@@ -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),
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt
index daebe77..27fb2cb 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt
@@ -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,
)
}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/FileManagerService.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/FileManagerService.kt
new file mode 100644
index 0000000..0126956
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/FileManagerService.kt
@@ -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,
+ val files: List,
+)
+
+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 = 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 { 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()
+ 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(""))
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt
index 6d9f31e..34e404f 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt
@@ -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),
)
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/AppBottomSheets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/AppBottomSheets.kt
index be0b344..b8872aa 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/AppBottomSheets.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/AppBottomSheets.kt
@@ -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
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/MultiGroupsDropdown.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/MultiGroupsDropdown.kt
new file mode 100644
index 0000000..1962b49
--- /dev/null
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/MultiGroupsDropdown.kt
@@ -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,
+ val selectedIndex: Int,
+ val onSelectedIndexChange: (Int) -> Unit,
+)
+
+@Composable
+fun MultiGroupsDropdown(
+ groups: List,
+ 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))
+ }
+ }
+}
diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/TerminalInputView.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/TerminalInputView.kt
index 6c7ae21..ebcb2e2 100644
--- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/TerminalInputView.kt
+++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/TerminalInputView.kt
@@ -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)
}