feat: file management; streaming terminal
- 实现 FileManagerService,处理远程文件操作,包括列出目录、上传和下载文件 - 增加对目录快照和文件统计信息检索的支持 - 引入 MultiGroupsDropdown 控件,用于在 UI 中显示分组下拉选项,仿照 MIUI 自带的文件管理排序 Popup
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
||||
@@ -7,6 +7,7 @@ import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.Closeable
|
||||
import java.io.EOFException
|
||||
import java.io.IOException
|
||||
@@ -259,6 +260,7 @@ internal class DirectAdbConnection(
|
||||
Log.i(TAG, "handshake(): tcp connect -> $host:$port")
|
||||
socket.connect(InetSocketAddress(host, port), 10_000)
|
||||
socket.tcpNoDelay = true
|
||||
socket.keepAlive = true
|
||||
socket.soTimeout = 60_000
|
||||
rawIn = BufferedInputStream(socket.getInputStream(), 65_536)
|
||||
rawOut = socket.getOutputStream()
|
||||
@@ -355,6 +357,12 @@ internal class DirectAdbConnection(
|
||||
* - Implements SEND/DATA/DONE/OKAY sequence and throws IOException on failure.
|
||||
*/
|
||||
fun push(data: ByteArray, remotePath: String, unixMode: Int = 420) {
|
||||
data.inputStream().use { input ->
|
||||
push(input, remotePath, unixMode)
|
||||
}
|
||||
}
|
||||
|
||||
fun push(input: InputStream, remotePath: String, unixMode: Int = 420) {
|
||||
openStream("sync:")
|
||||
.use { stream ->
|
||||
val out = stream.outputStream
|
||||
@@ -366,13 +374,13 @@ internal class DirectAdbConnection(
|
||||
out.write(pathMode)
|
||||
|
||||
val chunkBuf = ByteArray(64 * 1024)
|
||||
var offset = 0
|
||||
while (offset < data.size) {
|
||||
val len = minOf(chunkBuf.size, data.size - offset)
|
||||
while (true) {
|
||||
val len = input.read(chunkBuf)
|
||||
if (len <= 0) break
|
||||
|
||||
out.write("DATA".toByteArray(Charsets.US_ASCII))
|
||||
out.writeIntLE(len)
|
||||
out.write(data, offset, len)
|
||||
offset += len
|
||||
out.write(chunkBuf, 0, len)
|
||||
}
|
||||
|
||||
out.write("DONE".toByteArray(Charsets.US_ASCII))
|
||||
@@ -392,6 +400,62 @@ internal class DirectAdbConnection(
|
||||
}
|
||||
}
|
||||
|
||||
fun pull(remotePath: String): ByteArray {
|
||||
val output = ByteArrayOutputStream()
|
||||
pull(remotePath, output)
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
fun pull(remotePath: String, output: OutputStream) {
|
||||
openStream("sync:")
|
||||
.use { stream ->
|
||||
val out = stream.outputStream
|
||||
val inp = stream.inputStream
|
||||
val pathBytes = remotePath.toByteArray(Charsets.UTF_8)
|
||||
|
||||
out.write("RECV".toByteArray(Charsets.US_ASCII))
|
||||
out.writeIntLE(pathBytes.size)
|
||||
out.write(pathBytes)
|
||||
out.flush()
|
||||
|
||||
while (true) {
|
||||
val idBuf = ByteArray(4).also { inp.readExact(it) }
|
||||
val msgLen = inp.readIntLE()
|
||||
when (val id = String(idBuf, Charsets.US_ASCII)) {
|
||||
"DATA" -> {
|
||||
val chunk = ByteArray(msgLen)
|
||||
inp.readExact(chunk)
|
||||
output.write(chunk)
|
||||
}
|
||||
|
||||
"DONE" -> {
|
||||
if (msgLen > 0) {
|
||||
inp.skip(msgLen.toLong())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
"FAIL" -> {
|
||||
val msg = if (msgLen > 0) {
|
||||
ByteArray(msgLen).also { inp.readExact(it) }
|
||||
.toString(Charsets.UTF_8)
|
||||
} else {
|
||||
"unknown error"
|
||||
}
|
||||
throw IOException("ADB pull failed: $msg")
|
||||
}
|
||||
|
||||
else -> {
|
||||
if (msgLen > 0) {
|
||||
inp.skip(msgLen.toLong())
|
||||
}
|
||||
throw IOException("ADB pull failed: unexpected sync id $id")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isAlive(): Boolean {
|
||||
val isClosed = socket.isClosed
|
||||
val isConnected = socket.isConnected
|
||||
|
||||
@@ -6,6 +6,8 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.nio.file.Path
|
||||
import kotlin.time.Duration
|
||||
|
||||
@@ -135,24 +137,54 @@ object NativeAdbService {
|
||||
return response
|
||||
}
|
||||
|
||||
suspend fun shellBatch(build: ShellBatchBuilder.() -> Unit): List<String> {
|
||||
val builder = ShellBatchBuilder().apply(build)
|
||||
if (builder.commands.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
val markers = List(builder.commands.size) { index ->
|
||||
"__SCRCPY_BATCH_${System.nanoTime()}_${index}__"
|
||||
}
|
||||
val script = buildString {
|
||||
builder.commands.forEachIndexed { index, command ->
|
||||
append(command)
|
||||
append("; printf '\\n")
|
||||
append(markers[index])
|
||||
append("\\n'")
|
||||
if (index != builder.commands.lastIndex) {
|
||||
append("; ")
|
||||
}
|
||||
}
|
||||
}
|
||||
val response = shell(script)
|
||||
val outputs = ArrayList<String>(builder.commands.size)
|
||||
var remaining = response
|
||||
markers.forEach { marker ->
|
||||
val token = "\n$marker\n"
|
||||
val markerIndex = remaining.indexOf(token)
|
||||
.takeIf { it >= 0 }
|
||||
?: throw IllegalStateException("Shell batch marker missing: $marker")
|
||||
outputs += remaining.substring(0, markerIndex).trimEnd('\r', '\n')
|
||||
remaining = remaining.substring(markerIndex + token.length)
|
||||
}
|
||||
return outputs
|
||||
}
|
||||
|
||||
suspend fun startApp(
|
||||
packageName: String,
|
||||
displayId: Int? = null,
|
||||
forceStop: Boolean = false,
|
||||
): String {
|
||||
val conn = snapshotConnection()
|
||||
val normalizedPackageName = packageName.trim()
|
||||
require(normalizedPackageName.isNotBlank()) { "package name is blank" }
|
||||
|
||||
if (forceStop) {
|
||||
conn.shell(
|
||||
"am force-stop ${quoteShellArg(normalizedPackageName)}"
|
||||
)
|
||||
}
|
||||
|
||||
val resolveOutput = conn.shell(
|
||||
val resolveCommand =
|
||||
"cmd package resolve-activity --brief ${quoteShellArg(normalizedPackageName)}"
|
||||
)
|
||||
val resolveOutputIndex = if (forceStop) 1 else 0
|
||||
val batchResult = shellBatch {
|
||||
if (forceStop) command("am force-stop ${quoteShellArg(normalizedPackageName)}")
|
||||
command(resolveCommand)
|
||||
}
|
||||
val resolveOutput = batchResult.getOrElse(resolveOutputIndex) { "" }
|
||||
val componentName = resolveOutput
|
||||
.lineSequence()
|
||||
.map(String::trim)
|
||||
@@ -166,7 +198,7 @@ object NativeAdbService {
|
||||
?.let { " --display $it" }
|
||||
.orEmpty()
|
||||
val command = "am start-activity$displayArg -n ${quoteShellArg(componentName)}"
|
||||
val response = conn.shell(command)
|
||||
val response = shell(command)
|
||||
Log.d(TAG, "startApp(): package=$normalizedPackageName component=$componentName")
|
||||
return response
|
||||
}
|
||||
@@ -175,10 +207,34 @@ object NativeAdbService {
|
||||
return snapshotConnection().openStream("shell:$command")
|
||||
}
|
||||
|
||||
suspend fun ensureConnectionResponsive() {
|
||||
val conn = snapshotConnection()
|
||||
try {
|
||||
conn.shell("true")
|
||||
} catch (error: Exception) {
|
||||
mutex.withLock {
|
||||
if (connection === conn) disconnectInternal()
|
||||
}
|
||||
throw IllegalStateException("ADB connection is no longer available", error)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun push(localPath: Path, remotePath: String) {
|
||||
snapshotConnection().push(localPath.toFile().readBytes(), remotePath)
|
||||
}
|
||||
|
||||
suspend fun push(input: InputStream, remotePath: String, unixMode: Int = 420) {
|
||||
snapshotConnection().push(input, remotePath, unixMode)
|
||||
}
|
||||
|
||||
suspend fun pull(remotePath: String): ByteArray {
|
||||
return snapshotConnection().pull(remotePath)
|
||||
}
|
||||
|
||||
suspend fun pull(remotePath: String, output: OutputStream) {
|
||||
snapshotConnection().pull(remotePath, output)
|
||||
}
|
||||
|
||||
suspend fun openAbstractSocket(name: String): AdbSocketStream {
|
||||
return snapshotConnection().openStream("localabstract:$name")
|
||||
}
|
||||
@@ -207,5 +263,13 @@ object NativeAdbService {
|
||||
return "'" + value.replace("'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
class ShellBatchBuilder internal constructor() {
|
||||
internal val commands = mutableListOf<String>()
|
||||
|
||||
fun command(command: String) {
|
||||
commands += command
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "NativeAdbService"
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.MoreVert
|
||||
@@ -26,14 +25,11 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import io.github.miuzarte.scrcpyforandroid.StreamActivity
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||
import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics
|
||||
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
|
||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
||||
@@ -86,13 +82,11 @@ import top.yukonga.miuix.kmp.basic.ListPopupDefaults
|
||||
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.basic.ScrollBehavior
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TextField
|
||||
import top.yukonga.miuix.kmp.basic.TopAppBar
|
||||
import top.yukonga.miuix.kmp.blur.layerBackdrop
|
||||
import top.yukonga.miuix.kmp.overlay.OverlayListPopup
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.textStyles
|
||||
|
||||
private const val ADB_CONNECT_TIMEOUT_MS = 12_000L
|
||||
private const val ADB_KEEPALIVE_INTERVAL_MS = 3_000L
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Devices
|
||||
import androidx.compose.material.icons.rounded.Folder
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material.icons.rounded.Terminal
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -28,6 +29,7 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -103,6 +105,7 @@ private enum class MainBottomTabDestination(
|
||||
) {
|
||||
Devices(label = "设备", icon = Icons.Rounded.Devices),
|
||||
Terminal(label = "终端", icon = Icons.Rounded.Terminal),
|
||||
Files(label = "文件", icon = Icons.Rounded.Folder),
|
||||
Settings(label = "设置", icon = Icons.Rounded.Settings);
|
||||
}
|
||||
|
||||
@@ -141,7 +144,7 @@ fun MainScreen() {
|
||||
initialPage = MainBottomTabDestination.Devices.ordinal,
|
||||
pageCount = { tabs.size })
|
||||
var selectedTabIndex by rememberSaveable {
|
||||
mutableStateOf(MainBottomTabDestination.Devices.ordinal)
|
||||
mutableIntStateOf(MainBottomTabDestination.Devices.ordinal)
|
||||
}
|
||||
var pagerNavigationJob by remember { mutableStateOf<Job?>(null) }
|
||||
var isPagerNavigating by remember { mutableStateOf(false) }
|
||||
@@ -150,6 +153,8 @@ fun MainScreen() {
|
||||
val currentRootScreen = rootBackStack.lastOrNull() as? RootScreen ?: RootScreen.Home
|
||||
var showReorderDevices by rememberSaveable { mutableStateOf(false) }
|
||||
var lastExitBackPressAtMs by rememberSaveable { mutableLongStateOf(0L) }
|
||||
var fileTabCanNavigateUp by remember { mutableStateOf(false) }
|
||||
var fileTabNavigateUp by remember { mutableStateOf<(() -> Boolean)?>(null) }
|
||||
|
||||
// Scroll behaviors
|
||||
val devicesPageScrollBehavior = MiuixScrollBehavior(
|
||||
@@ -323,24 +328,32 @@ fun MainScreen() {
|
||||
}
|
||||
|
||||
fun handleBackNavigation() {
|
||||
if (rootBackStack.size > 1) {
|
||||
rootNavigator.pop()
|
||||
} else if (selectedTabIndex != MainBottomTabDestination.Devices.ordinal) {
|
||||
navigateToTab(MainBottomTabDestination.Devices)
|
||||
} else {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (now - lastExitBackPressAtMs > 2_000L) {
|
||||
lastExitBackPressAtMs = now
|
||||
Toast.makeText(context, "再按一次返回键退出", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
lastExitBackPressAtMs = 0L
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { scrcpy.stop() }
|
||||
runCatching { NativeAdbService.disconnect() }
|
||||
when {
|
||||
rootBackStack.size > 1 -> rootNavigator.pop()
|
||||
|
||||
selectedTabIndex == MainBottomTabDestination.Files.ordinal
|
||||
&& fileTabCanNavigateUp
|
||||
&& fileTabNavigateUp?.invoke() == true
|
||||
-> return
|
||||
|
||||
selectedTabIndex != MainBottomTabDestination.Devices.ordinal
|
||||
-> navigateToTab(MainBottomTabDestination.Devices)
|
||||
|
||||
else -> {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (now - lastExitBackPressAtMs > 2_000L) {
|
||||
lastExitBackPressAtMs = now
|
||||
Toast.makeText(context, "再按一次返回键退出", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
lastExitBackPressAtMs = 0L
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { scrcpy.stop() }
|
||||
runCatching { NativeAdbService.disconnect() }
|
||||
}
|
||||
activity?.finish()
|
||||
}
|
||||
activity?.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -453,6 +466,12 @@ fun MainScreen() {
|
||||
bottomInnerPadding = bottomInnerPadding,
|
||||
)
|
||||
|
||||
MainBottomTabDestination.Files -> FileManagerScreen(
|
||||
bottomInnerPadding = bottomInnerPadding,
|
||||
onCanNavigateUpChange = { fileTabCanNavigateUp = it },
|
||||
onNavigateUpActionChange = { fileTabNavigateUp = it },
|
||||
)
|
||||
|
||||
MainBottomTabDestination.Settings -> SettingsScreen(
|
||||
scrollBehavior = settingsPageScrollBehavior,
|
||||
bottomInnerPadding = bottomInnerPadding,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user