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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.storage.Storage
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import java.io.BufferedInputStream import java.io.BufferedInputStream
import java.io.ByteArrayOutputStream
import java.io.Closeable import java.io.Closeable
import java.io.EOFException import java.io.EOFException
import java.io.IOException import java.io.IOException
@@ -259,6 +260,7 @@ internal class DirectAdbConnection(
Log.i(TAG, "handshake(): tcp connect -> $host:$port") Log.i(TAG, "handshake(): tcp connect -> $host:$port")
socket.connect(InetSocketAddress(host, port), 10_000) socket.connect(InetSocketAddress(host, port), 10_000)
socket.tcpNoDelay = true socket.tcpNoDelay = true
socket.keepAlive = true
socket.soTimeout = 60_000 socket.soTimeout = 60_000
rawIn = BufferedInputStream(socket.getInputStream(), 65_536) rawIn = BufferedInputStream(socket.getInputStream(), 65_536)
rawOut = socket.getOutputStream() rawOut = socket.getOutputStream()
@@ -355,6 +357,12 @@ internal class DirectAdbConnection(
* - Implements SEND/DATA/DONE/OKAY sequence and throws IOException on failure. * - Implements SEND/DATA/DONE/OKAY sequence and throws IOException on failure.
*/ */
fun push(data: ByteArray, remotePath: String, unixMode: Int = 420) { 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:") openStream("sync:")
.use { stream -> .use { stream ->
val out = stream.outputStream val out = stream.outputStream
@@ -366,13 +374,13 @@ internal class DirectAdbConnection(
out.write(pathMode) out.write(pathMode)
val chunkBuf = ByteArray(64 * 1024) val chunkBuf = ByteArray(64 * 1024)
var offset = 0 while (true) {
while (offset < data.size) { val len = input.read(chunkBuf)
val len = minOf(chunkBuf.size, data.size - offset) if (len <= 0) break
out.write("DATA".toByteArray(Charsets.US_ASCII)) out.write("DATA".toByteArray(Charsets.US_ASCII))
out.writeIntLE(len) out.writeIntLE(len)
out.write(data, offset, len) out.write(chunkBuf, 0, len)
offset += len
} }
out.write("DONE".toByteArray(Charsets.US_ASCII)) 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 { fun isAlive(): Boolean {
val isClosed = socket.isClosed val isClosed = socket.isClosed
val isConnected = socket.isConnected val isConnected = socket.isConnected

View File

@@ -6,6 +6,8 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import java.io.InputStream
import java.io.OutputStream
import java.nio.file.Path import java.nio.file.Path
import kotlin.time.Duration import kotlin.time.Duration
@@ -135,24 +137,54 @@ object NativeAdbService {
return response 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( suspend fun startApp(
packageName: String, packageName: String,
displayId: Int? = null, displayId: Int? = null,
forceStop: Boolean = false, forceStop: Boolean = false,
): String { ): String {
val conn = snapshotConnection()
val normalizedPackageName = packageName.trim() val normalizedPackageName = packageName.trim()
require(normalizedPackageName.isNotBlank()) { "package name is blank" } require(normalizedPackageName.isNotBlank()) { "package name is blank" }
val resolveCommand =
if (forceStop) {
conn.shell(
"am force-stop ${quoteShellArg(normalizedPackageName)}"
)
}
val resolveOutput = conn.shell(
"cmd package resolve-activity --brief ${quoteShellArg(normalizedPackageName)}" "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 val componentName = resolveOutput
.lineSequence() .lineSequence()
.map(String::trim) .map(String::trim)
@@ -166,7 +198,7 @@ object NativeAdbService {
?.let { " --display $it" } ?.let { " --display $it" }
.orEmpty() .orEmpty()
val command = "am start-activity$displayArg -n ${quoteShellArg(componentName)}" 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") Log.d(TAG, "startApp(): package=$normalizedPackageName component=$componentName")
return response return response
} }
@@ -175,10 +207,34 @@ object NativeAdbService {
return snapshotConnection().openStream("shell:$command") 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) { suspend fun push(localPath: Path, remotePath: String) {
snapshotConnection().push(localPath.toFile().readBytes(), remotePath) 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 { suspend fun openAbstractSocket(name: String): AdbSocketStream {
return snapshotConnection().openStream("localabstract:$name") return snapshotConnection().openStream("localabstract:$name")
} }
@@ -207,5 +263,13 @@ object NativeAdbService {
return "'" + value.replace("'", "'\\''") + "'" return "'" + value.replace("'", "'\\''") + "'"
} }
class ShellBatchBuilder internal constructor() {
internal val commands = mutableListOf<String>()
fun command(command: String) {
commands += command
}
}
private const val TAG = "NativeAdbService" 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.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.MoreVert 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.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import io.github.miuzarte.scrcpyforandroid.StreamActivity 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.haptics.LocalAppHaptics
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut 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.PopupPositionProvider
import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.ScrollBehavior 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.TextField
import top.yukonga.miuix.kmp.basic.TopAppBar import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.blur.layerBackdrop import top.yukonga.miuix.kmp.blur.layerBackdrop
import top.yukonga.miuix.kmp.overlay.OverlayListPopup import top.yukonga.miuix.kmp.overlay.OverlayListPopup
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme 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_CONNECT_TIMEOUT_MS = 12_000L
private const val ADB_KEEPALIVE_INTERVAL_MS = 3_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.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Devices 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.Settings
import androidx.compose.material.icons.rounded.Terminal import androidx.compose.material.icons.rounded.Terminal
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -28,6 +29,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -103,6 +105,7 @@ private enum class MainBottomTabDestination(
) { ) {
Devices(label = "设备", icon = Icons.Rounded.Devices), Devices(label = "设备", icon = Icons.Rounded.Devices),
Terminal(label = "终端", icon = Icons.Rounded.Terminal), Terminal(label = "终端", icon = Icons.Rounded.Terminal),
Files(label = "文件", icon = Icons.Rounded.Folder),
Settings(label = "设置", icon = Icons.Rounded.Settings); Settings(label = "设置", icon = Icons.Rounded.Settings);
} }
@@ -141,7 +144,7 @@ fun MainScreen() {
initialPage = MainBottomTabDestination.Devices.ordinal, initialPage = MainBottomTabDestination.Devices.ordinal,
pageCount = { tabs.size }) pageCount = { tabs.size })
var selectedTabIndex by rememberSaveable { var selectedTabIndex by rememberSaveable {
mutableStateOf(MainBottomTabDestination.Devices.ordinal) mutableIntStateOf(MainBottomTabDestination.Devices.ordinal)
} }
var pagerNavigationJob by remember { mutableStateOf<Job?>(null) } var pagerNavigationJob by remember { mutableStateOf<Job?>(null) }
var isPagerNavigating by remember { mutableStateOf(false) } var isPagerNavigating by remember { mutableStateOf(false) }
@@ -150,6 +153,8 @@ fun MainScreen() {
val currentRootScreen = rootBackStack.lastOrNull() as? RootScreen ?: RootScreen.Home val currentRootScreen = rootBackStack.lastOrNull() as? RootScreen ?: RootScreen.Home
var showReorderDevices by rememberSaveable { mutableStateOf(false) } var showReorderDevices by rememberSaveable { mutableStateOf(false) }
var lastExitBackPressAtMs by rememberSaveable { mutableLongStateOf(0L) } var lastExitBackPressAtMs by rememberSaveable { mutableLongStateOf(0L) }
var fileTabCanNavigateUp by remember { mutableStateOf(false) }
var fileTabNavigateUp by remember { mutableStateOf<(() -> Boolean)?>(null) }
// Scroll behaviors // Scroll behaviors
val devicesPageScrollBehavior = MiuixScrollBehavior( val devicesPageScrollBehavior = MiuixScrollBehavior(
@@ -323,24 +328,32 @@ fun MainScreen() {
} }
fun handleBackNavigation() { fun handleBackNavigation() {
if (rootBackStack.size > 1) { when {
rootNavigator.pop() rootBackStack.size > 1 -> rootNavigator.pop()
} else if (selectedTabIndex != MainBottomTabDestination.Devices.ordinal) {
navigateToTab(MainBottomTabDestination.Devices) selectedTabIndex == MainBottomTabDestination.Files.ordinal
} else { && fileTabCanNavigateUp
val now = SystemClock.elapsedRealtime() && fileTabNavigateUp?.invoke() == true
if (now - lastExitBackPressAtMs > 2_000L) { -> return
lastExitBackPressAtMs = now
Toast.makeText(context, "再按一次返回键退出", Toast.LENGTH_SHORT).show() selectedTabIndex != MainBottomTabDestination.Devices.ordinal
return -> navigateToTab(MainBottomTabDestination.Devices)
}
lastExitBackPressAtMs = 0L else -> {
scope.launch { val now = SystemClock.elapsedRealtime()
withContext(Dispatchers.IO) { if (now - lastExitBackPressAtMs > 2_000L) {
runCatching { scrcpy.stop() } lastExitBackPressAtMs = now
runCatching { NativeAdbService.disconnect() } 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, bottomInnerPadding = bottomInnerPadding,
) )
MainBottomTabDestination.Files -> FileManagerScreen(
bottomInnerPadding = bottomInnerPadding,
onCanNavigateUpChange = { fileTabCanNavigateUp = it },
onNavigateUpActionChange = { fileTabNavigateUp = it },
)
MainBottomTabDestination.Settings -> SettingsScreen( MainBottomTabDestination.Settings -> SettingsScreen(
scrollBehavior = settingsPageScrollBehavior, scrollBehavior = settingsPageScrollBehavior,
bottomInnerPadding = bottomInnerPadding, bottomInnerPadding = bottomInnerPadding,

View File

@@ -29,6 +29,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.core.net.toUri
import io.github.miuzarte.scrcpyforandroid.BuildConfig import io.github.miuzarte.scrcpyforandroid.BuildConfig
import io.github.miuzarte.scrcpyforandroid.LockscreenPasswordActivity import io.github.miuzarte.scrcpyforandroid.LockscreenPasswordActivity
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes 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 top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.system.exitProcess import kotlin.system.exitProcess
import android.provider.Settings as AndroidSettings
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
@@ -480,6 +482,50 @@ fun SettingsPage(
item { item {
SectionSmallTitle("ADB") SectionSmallTitle("ADB")
Card { 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( Column(
modifier = Modifier.padding(vertical = UiSpacing.Large), modifier = Modifier.padding(vertical = UiSpacing.Large),
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical), verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),

View File

@@ -18,8 +18,8 @@ internal data class ConnectedDeviceInfo(
* Fetch basic device properties from an already-connected device. * Fetch basic device properties from an already-connected device.
* *
* Notes: * Notes:
* - This function issues multiple `shell getprop` commands via [adbService.shell]. * - This function issues multiple `getprop` commands over a single reusable shell session
* Each call may block on native I/O, so it should be called from a coroutine context. * to avoid reopening a new adb shell stream for every property.
* - Returns a lightweight [ConnectedDeviceInfo] structure with commonly-used properties. * - Returns a lightweight [ConnectedDeviceInfo] structure with commonly-used properties.
*/ */
internal suspend fun fetchConnectedDeviceInfo( internal suspend fun fetchConnectedDeviceInfo(
@@ -27,17 +27,25 @@ internal suspend fun fetchConnectedDeviceInfo(
host: String, host: String,
port: Int port: Int
): ConnectedDeviceInfo = withContext(Dispatchers.IO) { ): ConnectedDeviceInfo = withContext(Dispatchers.IO) {
suspend fun prop(name: String): String = runCatching { val values = runCatching {
adbService.shell("getprop $name").trim() adbService.shellBatch {
}.getOrDefault("") 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( ConnectedDeviceInfo(
model = prop("ro.product.model").ifBlank { "$host:$port" }, model = values.getOrNull(0).orEmpty().trim().ifBlank { "$host:$port" },
serial = prop("ro.serialno"), serial = values.getOrNull(1).orEmpty().trim(),
manufacturer = prop("ro.product.manufacturer"), manufacturer = values.getOrNull(2).orEmpty().trim(),
brand = prop("ro.product.brand"), brand = values.getOrNull(3).orEmpty().trim(),
device = prop("ro.product.device"), device = values.getOrNull(4).orEmpty().trim(),
androidRelease = prop("ro.build.version.release"), androidRelease = values.getOrNull(5).orEmpty().trim(),
sdkInt = prop("ro.build.version.sdk").toIntOrNull() ?: -1, 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"), booleanPreferencesKey("realtime_clipboard_sync_to_device"),
true 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( val LAST_UPDATE_CHECK_AT = Pair(
longPreferencesKey("last_update_check_at"), longPreferencesKey("last_update_check_at"),
0L 0L
@@ -172,6 +180,8 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
val adbAutoLoadAppListOnConnect by setting(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT) val adbAutoLoadAppListOnConnect by setting(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT)
val passwordRequireAuth by setting(PASSWORD_REQUIRE_AUTH) val passwordRequireAuth by setting(PASSWORD_REQUIRE_AUTH)
val realtimeClipboardSyncToDevice by setting(REALTIME_CLIPBOARD_SYNC_TO_DEVICE) 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) val lastUpdateCheckAt by setting(LAST_UPDATE_CHECK_AT)
@Parcelize @Parcelize
@@ -202,6 +212,8 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
val adbAutoLoadAppListOnConnect: Boolean, val adbAutoLoadAppListOnConnect: Boolean,
val passwordRequireAuth: Boolean, val passwordRequireAuth: Boolean,
val realtimeClipboardSyncToDevice: Boolean, val realtimeClipboardSyncToDevice: Boolean,
val fileManagerSortBy: String,
val fileManagerSortDescending: Boolean,
val lastUpdateCheckAt: Long, val lastUpdateCheckAt: Long,
) : Parcelable { ) : 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(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT) { bundle: Bundle -> bundle.adbAutoLoadAppListOnConnect },
bundleField(PASSWORD_REQUIRE_AUTH) { bundle: Bundle -> bundle.passwordRequireAuth }, bundleField(PASSWORD_REQUIRE_AUTH) { bundle: Bundle -> bundle.passwordRequireAuth },
bundleField(REALTIME_CLIPBOARD_SYNC_TO_DEVICE) { bundle: Bundle -> bundle.realtimeClipboardSyncToDevice }, 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 }, 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), adbAutoLoadAppListOnConnect = preferences.read(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT),
passwordRequireAuth = preferences.read(PASSWORD_REQUIRE_AUTH), passwordRequireAuth = preferences.read(PASSWORD_REQUIRE_AUTH),
realtimeClipboardSyncToDevice = preferences.read(REALTIME_CLIPBOARD_SYNC_TO_DEVICE), 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), 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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn 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, defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) { ) : View(context, attrs, defStyleAttr) {
interface InputCallbacks { interface InputCallbacks {
fun handleKeyEvent(event: KeyEvent): Boolean
fun handleCommitText(text: CharSequence): Boolean fun handleCommitText(text: CharSequence): Boolean
fun handleDeleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean fun handleDeleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean
fun handleKeyEvent(event: KeyEvent): Boolean
} }
var inputCallbacks: InputCallbacks? = null var inputCallbacks: InputCallbacks? = null
@@ -66,9 +66,10 @@ class TerminalInputView @JvmOverloads constructor(
} }
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean { override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
if (inputCallbacks?.handleDeleteSurroundingText(beforeLength, afterLength) == true) { if (inputCallbacks
return true ?.handleDeleteSurroundingText(beforeLength, afterLength) == true
} ) return true
return super.deleteSurroundingText(beforeLength, afterLength) return super.deleteSurroundingText(beforeLength, afterLength)
} }