diff --git a/.gitignore b/.gitignore index 89e2ea9..986a919 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,6 @@ google-services.json .kiro/ +.kotlin/ + scrcpy.help* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0e1b78f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,106 @@ +# Change Log + +~~什么我居然开始写更新日志了~~ + +## 0.2.0 + +- 新增: 流式终端界面(支持在应用内直接操作终端) +- 新增: 文件管理界面 +- 新增: 原生应用设置页跳转,引导调整后台电池策略 +- 改进: Dock 导航交互与可用性 + +## 0.1.9 + +- 新增: 串流时自动拉起输入法 +- 新增: 剪贴板同步 + +## 0.1.8 + +- 新增: 支持 `--new-display` 与 `--start-app` +- 修复: 串流时“最近任务”不可用的问题 +- 维护: 依赖升级 + +## 0.1.7 + +- 重构: 低延迟音频处理从 JNI 侧迁移到 Kotlin/应用侧,降低 JNI 路径负担 +- 改进: 音频相关实现结构与设置项联动 + +## 0.1.6 + +- 修复: 输入法双重 padding 导致的布局问题 +- 重构: UI 刷新(TopAppBar 与 Dock 视觉重做,交互与页面样式统一) + +## 0.1.5 + +- 新增: 生物识别认证与锁屏密码自动填充 +- 修复: 快速连接卡片输入缓冲与设备 IP/端口修改体验 + +## 0.1.4 + +- 新增: 连接后可选执行 `--list-app` + +## 0.1.3 + +- 新增: 最近任务快捷入口 +- 修复: `START_APP_CUSTOM` 配置未保存的问题 + +## 0.1.2 + +- 新增: 多套 scrcpy 参数配置档(profiles) +- 改进: 多配置档管理体验 + +## 0.1.1 + +- 新增: 签名发布能力 +- 新增: 画中画(PiP) +- 新增: 全屏悬浮“快速球” +- 新增: ADB 连接后自动启动 scrcpy +- 新增: 连接状态跟随与自动全屏 +- 新增: PreviewCard 自动聚焦 +- 新增: 电源 Wake Lock 管理 +- 新增: 关于页面与动态模糊效果 +- 新增: 基于 GitHub API 的版本检查 +- 改进: 补充更多 scrcpy 选项 +- 重构: 导航与触感反馈迁移到 CompositionLocal +- 重构: PiP 相关优化 +- 修复: 视频显示由 TextureView 切换为 SurfaceView 以提升稳定性 +- 维护: 升级 `miuix` 到 0.9.0 + +## 0.0.6 + +- 修复: 视频方向处理 +- 重构: 设置系统迁移到 DataStore(含历史数据迁移) +- 重构: 状态管理改为 Parcelable Bundle 方案 +- 重构: Snackbar 与设置基类实现优化 +- 重构: 补充更多 scrcpy 参数并清理无用类/常量 + +## 0.0.5 + +- 新增/增强: 设置页与虚拟按键管理能力 +- 修复: 应用版本信息异常 +- 修复: 双击返回退出行为问题 +- 文档: 注释与 README 更新 + +## 0.0.4 + +- 重构: 触控实现重写 +- 新增: 串流过程中自动横竖屏切换 +- 改进: 全屏控制页手势行为调整(关闭预测性返回) + +## 0.0.3 + +- 新增: ADB 配对 +- 新增: mDNS 设备发现 + +## 0.0.2 + +- 新增: 全屏虚拟按键外观控制 +- 新增: 构建 ABI 矩阵控制 +- 重构: 代码组织与项目文档结构 +- 新增: 发布说明模板 + +## 0.0.1 + +- 首个可用版本发布 +- 构建: 工作流支持收集全部 APK 产物 +- 构建: Gradle 升级到 9.4.0 diff --git a/README.md b/README.md index 74872a4..4e31191 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,11 @@ ## FAQ -1. 虚拟屏不显示输入法 / 输入法显示在主屏幕 +1. 切到后台后 ADB 断连 + - 将国产 ROM 中的 `省电策略` 调整至 `无限制` + - 将安卓原生设置中的 `允许后台使用` 启用并设置为 `无限制` (应用设置页中有入口) + +2. 虚拟屏不显示输入法 / 输入法显示在主屏幕 - 将 `--display-ime-policy` 设置为 `local` - 自行在悬浮球中拉起本机输入法 @@ -88,6 +92,7 @@ specific abi: - 界面组件: [YuKongA/miuix](https://github.com/compose-miuix-ui/miuix) - 界面设计参考: [tiann/KernelSU/manager](https://github.com/tiann/KernelSU/tree/main/manager), [miuix/example](https://github.com/compose-miuix-ui/miuix/tree/main/example) - 画中画实现参考: [ClassicOldSong/moonlight-android](https://github.com/ClassicOldSong/moonlight-android) +- 原生应用设置页跳转: [YifePlayte/WOMMO](https://github.com/YifePlayte/WOMMO) ## License diff --git a/TODO.md b/TODO.md index e8f4a7c..9c988e1 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,8 @@ # TODO +fix wake lock + ## PARAMS ### LOWER PRIORITY diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d6d586e..c31fdac 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ + $host:$port") socket.connect(InetSocketAddress(host, port), 10_000) socket.tcpNoDelay = true + socket.keepAlive = true socket.soTimeout = 60_000 rawIn = BufferedInputStream(socket.getInputStream(), 65_536) rawOut = socket.getOutputStream() @@ -355,6 +357,12 @@ internal class DirectAdbConnection( * - Implements SEND/DATA/DONE/OKAY sequence and throws IOException on failure. */ fun push(data: ByteArray, remotePath: String, unixMode: Int = 420) { + data.inputStream().use { input -> + push(input, remotePath, unixMode) + } + } + + fun push(input: InputStream, remotePath: String, unixMode: Int = 420) { openStream("sync:") .use { stream -> val out = stream.outputStream @@ -366,13 +374,13 @@ internal class DirectAdbConnection( out.write(pathMode) val chunkBuf = ByteArray(64 * 1024) - var offset = 0 - while (offset < data.size) { - val len = minOf(chunkBuf.size, data.size - offset) + while (true) { + val len = input.read(chunkBuf) + if (len <= 0) break + out.write("DATA".toByteArray(Charsets.US_ASCII)) out.writeIntLE(len) - out.write(data, offset, len) - offset += len + out.write(chunkBuf, 0, len) } out.write("DONE".toByteArray(Charsets.US_ASCII)) @@ -392,6 +400,62 @@ internal class DirectAdbConnection( } } + fun pull(remotePath: String): ByteArray { + val output = ByteArrayOutputStream() + pull(remotePath, output) + return output.toByteArray() + } + + fun pull(remotePath: String, output: OutputStream) { + openStream("sync:") + .use { stream -> + val out = stream.outputStream + val inp = stream.inputStream + val pathBytes = remotePath.toByteArray(Charsets.UTF_8) + + out.write("RECV".toByteArray(Charsets.US_ASCII)) + out.writeIntLE(pathBytes.size) + out.write(pathBytes) + out.flush() + + while (true) { + val idBuf = ByteArray(4).also { inp.readExact(it) } + val msgLen = inp.readIntLE() + when (val id = String(idBuf, Charsets.US_ASCII)) { + "DATA" -> { + val chunk = ByteArray(msgLen) + inp.readExact(chunk) + output.write(chunk) + } + + "DONE" -> { + if (msgLen > 0) { + inp.skip(msgLen.toLong()) + } + return + } + + "FAIL" -> { + val msg = if (msgLen > 0) { + ByteArray(msgLen).also { inp.readExact(it) } + .toString(Charsets.UTF_8) + } else { + "unknown error" + } + throw IOException("ADB pull failed: $msg") + } + + else -> { + if (msgLen > 0) { + inp.skip(msgLen.toLong()) + } + throw IOException("ADB pull failed: unexpected sync id $id") + } + } + } + } + } + fun isAlive(): Boolean { val isClosed = socket.isClosed val isConnected = socket.isConnected diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt index 0d10850..5e61de8 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/NativeAdbService.kt @@ -6,6 +6,8 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import java.io.InputStream +import java.io.OutputStream import java.nio.file.Path import kotlin.time.Duration @@ -135,24 +137,54 @@ object NativeAdbService { return response } + suspend fun shellBatch(build: ShellBatchBuilder.() -> Unit): List { + val builder = ShellBatchBuilder().apply(build) + if (builder.commands.isEmpty()) { + return emptyList() + } + val markers = List(builder.commands.size) { index -> + "__SCRCPY_BATCH_${System.nanoTime()}_${index}__" + } + val script = buildString { + builder.commands.forEachIndexed { index, command -> + append(command) + append("; printf '\\n") + append(markers[index]) + append("\\n'") + if (index != builder.commands.lastIndex) { + append("; ") + } + } + } + val response = shell(script) + val outputs = ArrayList(builder.commands.size) + var remaining = response + markers.forEach { marker -> + val token = "\n$marker\n" + val markerIndex = remaining.indexOf(token) + .takeIf { it >= 0 } + ?: throw IllegalStateException("Shell batch marker missing: $marker") + outputs += remaining.substring(0, markerIndex).trimEnd('\r', '\n') + remaining = remaining.substring(markerIndex + token.length) + } + return outputs + } + suspend fun startApp( packageName: String, displayId: Int? = null, forceStop: Boolean = false, ): String { - val conn = snapshotConnection() val normalizedPackageName = packageName.trim() require(normalizedPackageName.isNotBlank()) { "package name is blank" } - - if (forceStop) { - conn.shell( - "am force-stop ${quoteShellArg(normalizedPackageName)}" - ) - } - - val resolveOutput = conn.shell( + val resolveCommand = "cmd package resolve-activity --brief ${quoteShellArg(normalizedPackageName)}" - ) + val resolveOutputIndex = if (forceStop) 1 else 0 + val batchResult = shellBatch { + if (forceStop) command("am force-stop ${quoteShellArg(normalizedPackageName)}") + command(resolveCommand) + } + val resolveOutput = batchResult.getOrElse(resolveOutputIndex) { "" } val componentName = resolveOutput .lineSequence() .map(String::trim) @@ -166,7 +198,7 @@ object NativeAdbService { ?.let { " --display $it" } .orEmpty() val command = "am start-activity$displayArg -n ${quoteShellArg(componentName)}" - val response = conn.shell(command) + val response = shell(command) Log.d(TAG, "startApp(): package=$normalizedPackageName component=$componentName") return response } @@ -175,10 +207,34 @@ object NativeAdbService { return snapshotConnection().openStream("shell:$command") } + suspend fun ensureConnectionResponsive() { + val conn = snapshotConnection() + try { + conn.shell("true") + } catch (error: Exception) { + mutex.withLock { + if (connection === conn) disconnectInternal() + } + throw IllegalStateException("ADB connection is no longer available", error) + } + } + suspend fun push(localPath: Path, remotePath: String) { snapshotConnection().push(localPath.toFile().readBytes(), remotePath) } + suspend fun push(input: InputStream, remotePath: String, unixMode: Int = 420) { + snapshotConnection().push(input, remotePath, unixMode) + } + + suspend fun pull(remotePath: String): ByteArray { + return snapshotConnection().pull(remotePath) + } + + suspend fun pull(remotePath: String, output: OutputStream) { + snapshotConnection().pull(remotePath, output) + } + suspend fun openAbstractSocket(name: String): AdbSocketStream { return snapshotConnection().openStream("localabstract:$name") } @@ -207,5 +263,13 @@ object NativeAdbService { return "'" + value.replace("'", "'\\''") + "'" } + class ShellBatchBuilder internal constructor() { + internal val commands = mutableListOf() + + fun command(command: String) { + commands += command + } + } + private const val TAG = "NativeAdbService" } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt index 8b34592..9b3bb55 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt @@ -6,7 +6,6 @@ import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.MoreVert @@ -26,14 +25,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp -import androidx.fragment.app.FragmentActivity import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import io.github.miuzarte.scrcpyforandroid.StreamActivity -import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.haptics.LocalAppHaptics import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut @@ -86,13 +82,11 @@ import top.yukonga.miuix.kmp.basic.ListPopupDefaults import top.yukonga.miuix.kmp.basic.PopupPositionProvider import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.ScrollBehavior -import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.TextField import top.yukonga.miuix.kmp.basic.TopAppBar import top.yukonga.miuix.kmp.blur.layerBackdrop import top.yukonga.miuix.kmp.overlay.OverlayListPopup import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme -import top.yukonga.miuix.kmp.theme.MiuixTheme.textStyles private const val ADB_CONNECT_TIMEOUT_MS = 12_000L private const val ADB_KEEPALIVE_INTERVAL_MS = 3_000L diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerScreen.kt new file mode 100644 index 0000000..c035e81 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FileManagerScreen.kt @@ -0,0 +1,1054 @@ +package io.github.miuzarte.scrcpyforandroid.pages + +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.InsertDriveFile +import androidx.compose.material.icons.rounded.Android +import androidx.compose.material.icons.rounded.Archive +import androidx.compose.material.icons.rounded.AudioFile +import androidx.compose.material.icons.rounded.Description +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.Movie +import androidx.compose.material.icons.rounded.RawOff +import androidx.compose.material.icons.rounded.RawOn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing +import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn +import io.github.miuzarte.scrcpyforandroid.services.DirectoryDownloadSnapshot +import io.github.miuzarte.scrcpyforandroid.services.DirectorySnapshotSession +import io.github.miuzarte.scrcpyforandroid.services.FileManagerService +import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController +import io.github.miuzarte.scrcpyforandroid.services.RemoteFileEntry +import io.github.miuzarte.scrcpyforandroid.services.RemoteFileKind +import io.github.miuzarte.scrcpyforandroid.services.RemoteFileStat +import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings +import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar +import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur +import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop +import io.github.miuzarte.scrcpyforandroid.widgets.MultiGroupsDropdown +import io.github.miuzarte.scrcpyforandroid.widgets.MultiGroupsDropdownGroup +import io.github.miuzarte.scrcpyforandroid.widgets.PopupMenuItem +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import top.yukonga.miuix.kmp.basic.ButtonDefaults +import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.ListPopupColumn +import top.yukonga.miuix.kmp.basic.ListPopupDefaults +import top.yukonga.miuix.kmp.basic.PullToRefresh +import top.yukonga.miuix.kmp.basic.Scaffold +import top.yukonga.miuix.kmp.basic.SmallTopAppBar +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.basic.TextButton +import top.yukonga.miuix.kmp.basic.TextField +import top.yukonga.miuix.kmp.basic.rememberPullToRefreshState +import top.yukonga.miuix.kmp.blur.layerBackdrop +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.extended.Tune +import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet +import top.yukonga.miuix.kmp.overlay.OverlayDialog +import top.yukonga.miuix.kmp.overlay.OverlayListPopup +import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme + +private const val ROOT_REMOTE_PATH = "/" +private const val INITIAL_REMOTE_PATH = "/storage/emulated/0" + +private enum class FileManagerSortField { + NAME, + SIZE, + TIME, + EXTENSION, +} + +private data class FileManagerScrollPosition( + val index: Int, + val offset: Int, +) + +private sealed interface PendingTreeDownload { + data class File( + val remotePath: String, + val fileName: String, + ) : PendingTreeDownload + + data class Directory( + val snapshot: DirectoryDownloadSnapshot, + ) : PendingTreeDownload +} + +@Composable +fun FileManagerScreen( + bottomInnerPadding: Dp, + onCanNavigateUpChange: (Boolean) -> Unit = {}, + onNavigateUpActionChange: (((() -> Boolean)?) -> Unit)? = null, +) { + val context = LocalContext.current + val snackbar = LocalSnackbarController.current + val blurBackdrop = rememberBlurBackdrop(LocalEnableBlur.current) + val blurActive = blurBackdrop != null + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } + val pullToRefreshState = rememberPullToRefreshState() + val listState = rememberLazyListState() + val asBundle by appSettings.bundleState.collectAsState() + + val pathStack = remember { + mutableStateListOf().apply { + addAll(buildPathStack(INITIAL_REMOTE_PATH)) + } + } + val currentPath = pathStack.lastOrNull() ?: INITIAL_REMOTE_PATH + val directoryCache = remember { mutableStateMapOf>() } + val directoryScrollCache = remember { mutableStateMapOf() } + var loading by rememberSaveable { mutableStateOf(false) } + var isRefreshing by rememberSaveable { mutableStateOf(false) } + var errorText by rememberSaveable { mutableStateOf(null) } + var showMenu by rememberSaveable { mutableStateOf(false) } + var showSortMenu by rememberSaveable { mutableStateOf(false) } + var showPathDialog by rememberSaveable { mutableStateOf(false) } + var showCreateFolderDialog by rememberSaveable { mutableStateOf(false) } + var showDetailsSheet by rememberSaveable { mutableStateOf(false) } + var showRawDetails by rememberSaveable { mutableStateOf(false) } + var pathInput by rememberSaveable { mutableStateOf(INITIAL_REMOTE_PATH) } + var newFolderName by rememberSaveable { mutableStateOf("") } + var selectedEntry by remember { mutableStateOf(null) } + var selectedStat by remember { mutableStateOf(null) } + var selectedTargetStat by remember { mutableStateOf(null) } + var selectedSnapshot by remember { mutableStateOf(null) } + var detailLoading by rememberSaveable { mutableStateOf(false) } + var pendingTreeDownload by remember { mutableStateOf(null) } + var activeSnapshotSession by remember { mutableStateOf(null) } + + val sortField = remember(asBundle.fileManagerSortBy) { + runCatching { FileManagerSortField.valueOf(asBundle.fileManagerSortBy) } + .getOrDefault(FileManagerSortField.NAME) + } + val sortDescending = asBundle.fileManagerSortDescending + val cachedEntries = directoryCache[currentPath] + val displayedEntries = remember(cachedEntries, sortField, sortDescending) { + sortEntries(cachedEntries.orEmpty(), sortField, sortDescending) + } + + fun clearDetails() { + selectedEntry = null + selectedStat = null + selectedTargetStat = null + selectedSnapshot = null + detailLoading = false + showRawDetails = false + val session = activeSnapshotSession + activeSnapshotSession = null + if (session != null) { + taskScope.launch { + runCatching { session.interrupt() } + } + } + } + + fun dismissDetails() { + showDetailsSheet = false + } + + fun rememberCurrentScrollPosition() { + directoryScrollCache[currentPath] = FileManagerScrollPosition( + index = listState.firstVisibleItemIndex, + offset = listState.firstVisibleItemScrollOffset, + ) + } + + fun navigateUp(): Boolean { + if (pathStack.size <= 1) { + return false + } + rememberCurrentScrollPosition() + pathStack.removeAt(pathStack.lastIndex) + return true + } + + fun jumpToPath(rawPath: String) { + val normalized = normalizePath(rawPath) + rememberCurrentScrollPosition() + pathStack.clear() + pathStack.addAll(buildPathStack(normalized)) + } + + suspend fun reloadCurrentDirectory(force: Boolean) { + val cached = directoryCache[currentPath] + if (cached != null && !force) { + errorText = null + return + } + loading = cached == null + errorText = null + val result = runCatching { + FileManagerService.listDirectory(currentPath) + } + loading = false + isRefreshing = false + result.onSuccess { directoryCache[currentPath] = it } + .onFailure { error -> + errorText = error.message ?: error.javaClass.simpleName + } + } + + suspend fun startDownloadToTree(treeUri: Uri, request: PendingTreeDownload) { + when (request) { + is PendingTreeDownload.File -> { + FileManagerService.downloadFileToTree( + context = context, + treeUri = treeUri, + remotePath = request.remotePath, + fileName = request.fileName, + ) + } + + is PendingTreeDownload.Directory -> { + FileManagerService.downloadDirectoryToTree( + context = context, + treeUri = treeUri, + snapshot = request.snapshot, + ) + } + } + } + + fun requestDownload(entry: RemoteFileEntry) { + val snapshot = selectedSnapshot + dismissDetails() + snackbar.show("开始下载") + taskScope.launch { + if (entry.isDirectory) { + if (snapshot == null) { + withContext(Dispatchers.Main) { + snackbar.show("目录信息仍在加载,请稍后重试") + } + return@launch + } + val directSaved = FileManagerService.downloadDirectoryToPublicDownloads(snapshot) + if (directSaved) { + withContext(Dispatchers.Main) { snackbar.show("已下载到 Download/Scrcpy") } + } else { + withContext(Dispatchers.Main) { + pendingTreeDownload = PendingTreeDownload.Directory(snapshot) + snackbar.show("无法直接写入 Download/Scrcpy,请选择保存目录") + } + } + } else { + val directSaved = FileManagerService.downloadFileToPublicDownloads( + remotePath = entry.fullPath, + fileName = entry.name, + ) + if (directSaved) { + withContext(Dispatchers.Main) { snackbar.show("已下载到 Download/Scrcpy") } + } else { + withContext(Dispatchers.Main) { + pendingTreeDownload = PendingTreeDownload.File(entry.fullPath, entry.name) + snackbar.show("无法直接写入 Download/Scrcpy,请选择保存目录") + } + } + } + } + } + + fun updateSort(sortBy: FileManagerSortField? = null, descending: Boolean? = null) { + rememberCurrentScrollPosition() + taskScope.launch { + appSettings.updateBundle { + it.copy( + fileManagerSortBy = (sortBy ?: sortField).name, + fileManagerSortDescending = descending ?: sortDescending, + ) + } + } + } + + fun openPathDialog() { + pathInput = currentPath + showPathDialog = true + } + + fun openCreateFolderDialog() { + newFolderName = "" + showCreateFolderDialog = true + } + + val uploadLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + taskScope.launch { + val result = runCatching { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + FileManagerService.uploadFile(context, uri, currentPath) + } + withContext(Dispatchers.Main) { + result.onSuccess { + snackbar.show("已上传到 $currentPath") + directoryCache.remove(currentPath) + isRefreshing = true + }.onFailure { error -> + snackbar.show("上传失败: ${error.message ?: error.javaClass.simpleName}") + } + } + } + } + + val treeLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + val request = pendingTreeDownload + pendingTreeDownload = null + if (uri == null || request == null) return@rememberLauncherForActivityResult + taskScope.launch { + val result = runCatching { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + startDownloadToTree(uri, request) + } + withContext(Dispatchers.Main) { + result.onSuccess { + snackbar.show("下载完成") + }.onFailure { error -> + snackbar.show("下载失败: ${error.message ?: error.javaClass.simpleName}") + } + } + } + } + + LaunchedEffect(currentPath) { + reloadCurrentDirectory(force = false) + } + + LaunchedEffect(currentPath, sortField, sortDescending, displayedEntries.size, loading) { + if (loading || displayedEntries.isEmpty()) return@LaunchedEffect + + val scrollPosition = directoryScrollCache[currentPath] ?: return@LaunchedEffect + + val targetIndex = scrollPosition.index.coerceIn(0, displayedEntries.lastIndex) + listState.scrollToItem(targetIndex, scrollPosition.offset) + } + + LaunchedEffect(isRefreshing) { + if (isRefreshing) reloadCurrentDirectory(force = true) + } + + LaunchedEffect(pendingTreeDownload) { + if (pendingTreeDownload != null) treeLauncher.launch(null) + } + + DisposableEffect(pathStack.size) { + val canNavigateUp = pathStack.size > 1 + onCanNavigateUpChange(canNavigateUp) + onNavigateUpActionChange?.invoke(::navigateUp) + onDispose { + onCanNavigateUpChange(false) + onNavigateUpActionChange?.invoke(null) + } + } + + DisposableEffect(Unit) { + onDispose { + clearDetails() + taskScope.cancel() + } + } + + Scaffold( + topBar = { + BlurredBar(backdrop = blurBackdrop) { + SmallTopAppBar( + title = "文件", + color = if (blurActive) Color.Transparent else colorScheme.surface, + navigationIcon = { + IconButton( + onClick = ::navigateUp, + enabled = pathStack.size > 1, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = "上一层", + ) + } + }, + bottomContent = { + Text( + text = currentPath, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = ::openPathDialog, + onLongClick = ::openPathDialog, + ) + .padding( + start = UiSpacing.PageHorizontal, + end = UiSpacing.PageHorizontal, + bottom = UiSpacing.Medium, + ), + softWrap = false, + overflow = TextOverflow.Ellipsis, + color = colorScheme.onSurfaceVariantSummary, + ) + }, + actions = { + Box { + IconButton( + onClick = { showSortMenu = true }, + holdDownState = showSortMenu, + ) { + Icon( + imageVector = MiuixIcons.Tune, + contentDescription = "排序", + ) + } + + OverlayListPopup( + show = showSortMenu, + popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider, + onDismissRequest = { showSortMenu = false }, + ) { + ListPopupColumn { + MultiGroupsDropdown( + groups = listOf( + MultiGroupsDropdownGroup( + options = listOf( + "文件名", + "大小", + "时间", + "扩展名" + ), + selectedIndex = when (sortField) { + FileManagerSortField.NAME -> 0 + FileManagerSortField.SIZE -> 1 + FileManagerSortField.TIME -> 2 + FileManagerSortField.EXTENSION -> 3 + }, + onSelectedIndexChange = { index -> + updateSort( + sortBy = when (index) { + 1 -> FileManagerSortField.SIZE + 2 -> FileManagerSortField.TIME + 3 -> FileManagerSortField.EXTENSION + else -> FileManagerSortField.NAME + } + ) + }, + ), + MultiGroupsDropdownGroup( + options = listOf("正序", "倒序"), + selectedIndex = if (sortDescending) 1 else 0, + onSelectedIndexChange = { index -> + updateSort(descending = index == 1) + }, + ), + ), + ) + } + } + } + + Box { + IconButton( + onClick = { showMenu = true }, + holdDownState = showMenu, + ) { + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = "更多", + ) + } + + OverlayListPopup( + show = showMenu, + popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider, + onDismissRequest = { showMenu = false }, + ) { + ListPopupColumn { + PopupMenuItem( + text = "创建文件夹", + optionSize = 2, + index = 0, + onSelectedIndexChange = { + showMenu = false + openCreateFolderDialog() + }, + ) + PopupMenuItem( + text = "上传文件到该目录", + optionSize = 2, + index = 1, + onSelectedIndexChange = { + showMenu = false + uploadLauncher.launch(arrayOf("*/*")) + }, + ) + } + } + } + }, + ) + } + }, + ) { pagePadding -> + Box( + modifier = + if (blurActive) Modifier.layerBackdrop(blurBackdrop) + else Modifier, + ) { + PullToRefresh( + isRefreshing = isRefreshing, + onRefresh = { isRefreshing = true }, + pullToRefreshState = pullToRefreshState, + refreshTexts = listOf("下拉刷新", "释放刷新", "正在刷新...", "刷新完成"), + contentPadding = PaddingValues( + top = pagePadding.calculateTopPadding() + 12.dp, + ), + ) { + LazyColumn( + contentPadding = pagePadding, + bottomInnerPadding = bottomInnerPadding, + state = listState, + ) { + if (loading && cachedEntries == null) { + item { FileManagerStatusCard("加载中") } + } else if (errorText != null && cachedEntries == null) { + item { FileManagerStatusCard("加载失败: $errorText") } + } else if (displayedEntries.isEmpty()) { + item { FileManagerStatusCard("空目录") } + } else { + items(displayedEntries) { entry -> + FileManagerItemCard( + entry = entry, + summary = FileManagerService.formatSummary(entry), + onClick = { + when { + entry.isDirectory -> { + rememberCurrentScrollPosition() + pathStack.add(normalizePath(entry.fullPath)) + } + + entry.kind == RemoteFileKind.Link || entry.symlinkTarget != null -> { + taskScope.launch { + val targetPath = resolveLinkTarget(entry) + if (targetPath == null) { + withContext(Dispatchers.Main) { + snackbar.show("链接目标不可用,长按查看信息") + } + return@launch + } + val result = runCatching { + FileManagerService.stat(targetPath) + } + withContext(Dispatchers.Main) { + result.onSuccess { targetStat -> + if (isDirectoryStat(targetStat)) { + jumpToPath(targetPath) + } else { + snackbar.show("链接目标不是文件夹,长按查看信息") + } + }.onFailure { error -> + snackbar.show("读取链接目标失败: ${error.message ?: error.javaClass.simpleName}") + } + } + } + } + + else -> snackbar.show("长按可查看文件详情") + } + }, + onLongClick = { + clearDetails() + selectedEntry = entry + showDetailsSheet = true + detailLoading = true + taskScope.launch { + val statResult = runCatching { + FileManagerService.stat(entry.fullPath) + } + val linkTargetPath = resolveLinkTarget(entry) + val targetStatResult = + if (linkTargetPath != null) + runCatching { FileManagerService.stat(linkTargetPath) } + else null + + val snapshotResult = + if (entry.isDirectory) + runCatching { + val session = DirectorySnapshotSession.open() + withContext(Dispatchers.Main) { + activeSnapshotSession = session + } + session.load(entry.fullPath) + } + else null + + withContext(Dispatchers.Main) { + detailLoading = false + statResult + .onSuccess { selectedStat = it } + .onFailure { error -> + snackbar.show("读取详情失败: ${error.message ?: error.javaClass.simpleName}") + if (selectedEntry === entry) + selectedEntry = null + } + targetStatResult + ?.onSuccess { selectedTargetStat = it } + snapshotResult + ?.onSuccess { selectedSnapshot = it } + ?.onFailure { error -> + snackbar.show("目录扫描失败: ${error.message ?: error.javaClass.simpleName}") + } + } + } + }, + ) + } + } + } + } + + val entry = selectedEntry + if (entry != null || showDetailsSheet) { + FileDetailsBottomSheet( + show = showDetailsSheet, + content = when { + detailLoading -> "正在加载详情" + entry != null && selectedStat != null -> buildDetailsText( + stat = selectedStat!!, + targetStat = selectedTargetStat, + directorySnapshot = + if (entry.isDirectory) selectedSnapshot + else null, + showRaw = showRawDetails, + ) + + else -> "暂无详情" + }, + onDismissRequest = ::dismissDetails, + onDismissFinished = ::clearDetails, + onToggleRaw = { showRawDetails = !showRawDetails }, + showingRaw = showRawDetails, + onDownload = { entry?.let(::requestDownload) }, + downloadEnabled = entry != null + && !detailLoading + && (!entry.isDirectory || selectedSnapshot != null), + ) + } + + PathJumpDialog( + show = showPathDialog, + path = pathInput, + onPathChange = { pathInput = it }, + onDismissRequest = { showPathDialog = false }, + onConfirm = { + showPathDialog = false + jumpToPath(pathInput) + }, + ) + + CreateFolderDialog( + show = showCreateFolderDialog, + folderName = newFolderName, + onFolderNameChange = { newFolderName = it }, + onDismissRequest = { showCreateFolderDialog = false }, + onConfirm = { + showCreateFolderDialog = false + val folderName = newFolderName.trim() + if (folderName.isBlank()) + snackbar.show("文件夹名称不能为空") + else taskScope.launch { + val result = runCatching { + FileManagerService.createDirectory(currentPath, folderName) + } + withContext(Dispatchers.Main) { + result.onSuccess { + snackbar.show("已创建文件夹") + directoryCache.remove(currentPath) + isRefreshing = true + }.onFailure { error -> + snackbar.show("创建失败: ${error.message ?: error.javaClass.simpleName}") + } + } + } + }, + ) + } + } +} + +@Composable +private fun FileManagerStatusCard(message: String) { + Card { + Text( + text = message, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + color = colorScheme.onSurfaceVariantSummary, + ) + } +} + +@Composable +private fun FileManagerItemCard( + entry: RemoteFileEntry, + summary: String, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = iconForEntry(entry), + contentDescription = entry.name, + ) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(start = UiSpacing.Medium), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = entry.name, + maxLines = 2, + softWrap = false, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = summary, + fontSize = 13.sp, + color = colorScheme.onSurfaceVariantSummary, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +@Composable +private fun FileDetailsBottomSheet( + show: Boolean, + content: String, + onDismissRequest: () -> Unit, + onDismissFinished: (() -> Unit)? = null, + onToggleRaw: () -> Unit, + showingRaw: Boolean, + onDownload: () -> Unit, + downloadEnabled: Boolean, + title: String = "详细信息", +) { + OverlayBottomSheet( + show = show, + title = title, + defaultWindowInsetsPadding = false, + onDismissRequest = onDismissRequest, + onDismissFinished = onDismissFinished, + startAction = { + IconButton( + onClick = onToggleRaw, + ) { + Icon( + imageVector = + if (!showingRaw) Icons.Rounded.RawOff + else Icons.Rounded.RawOn, + contentDescription = + if (!showingRaw) "显示原文" + else "显示解析", + ) + } + }, + endAction = { + IconButton( + onClick = onDownload, + enabled = downloadEnabled, + ) { + Icon( + imageVector = Icons.Rounded.Download, + contentDescription = "下载", + ) + } + }, + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(2f / 3f), + ) { + item { + TextField( + value = content, + onValueChange = {}, + readOnly = true, + modifier = Modifier.fillMaxWidth(), + useLabelAsPlaceholder = true, + ) + } + } + } +} + +@Composable +private fun PathJumpDialog( + show: Boolean, + path: String, + onPathChange: (String) -> Unit, + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, +) { + OverlayDialog( + show = show, + title = "跳转路径", + defaultWindowInsetsPadding = false, + onDismissRequest = onDismissRequest, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical), + ) { + TextField( + value = path, + onValueChange = onPathChange, + label = "/storage/emulated/0", + useLabelAsPlaceholder = true, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal), + ) { + TextButton( + text = "取消", + onClick = onDismissRequest, + modifier = Modifier.weight(1f), + ) + TextButton( + text = "确定", + onClick = onConfirm, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.textButtonColorsPrimary(), + ) + } + } + } +} + +@Composable +private fun CreateFolderDialog( + show: Boolean, + folderName: String, + onFolderNameChange: (String) -> Unit, + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, +) { + OverlayDialog( + show = show, + title = "创建文件夹", + defaultWindowInsetsPadding = false, + onDismissRequest = onDismissRequest, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical), + ) { + TextField( + value = folderName, + onValueChange = onFolderNameChange, + label = "新建文件夹", + useLabelAsPlaceholder = true, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal), + ) { + TextButton( + text = "取消", + onClick = onDismissRequest, + modifier = Modifier.weight(1f), + ) + TextButton( + text = "确定", + onClick = onConfirm, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.textButtonColorsPrimary(), + ) + } + } + } +} + +private fun iconForEntry(entry: RemoteFileEntry): ImageVector = when (entry.kind) { + RemoteFileKind.Directory -> Icons.Rounded.Folder + RemoteFileKind.Image -> Icons.Rounded.Image + RemoteFileKind.Video -> Icons.Rounded.Movie + RemoteFileKind.Audio -> Icons.Rounded.AudioFile + RemoteFileKind.Archive -> Icons.Rounded.Archive + RemoteFileKind.Apk -> Icons.Rounded.Android + RemoteFileKind.Text -> Icons.Rounded.Description + RemoteFileKind.Link -> Icons.Rounded.Link + RemoteFileKind.Other -> Icons.AutoMirrored.Rounded.InsertDriveFile +} + +private fun buildPathStack(path: String): List { + val normalized = normalizePath(path) + if (normalized == ROOT_REMOTE_PATH) return listOf(ROOT_REMOTE_PATH) + + val parts = normalized.trim('/').split('/').filter { it.isNotBlank() } + val stack = mutableListOf(ROOT_REMOTE_PATH) + var current = "" + parts.forEach { part -> + current += "/$part" + stack += current + } + return stack +} + +private fun normalizePath(path: String): String { + val trimmed = path.trim() + if (trimmed.isBlank()) { + return ROOT_REMOTE_PATH + } + return "/" + trimmed + .trim('/') + .split('/') + .filter { it.isNotBlank() } + .joinToString("/") + .ifBlank { ROOT_REMOTE_PATH.removePrefix("/") } +} + +private fun sortEntries( + entries: List, + field: FileManagerSortField, + descending: Boolean, +): List { + val contentComparator = when (field) { + FileManagerSortField.NAME + -> compareBy { it.name.lowercase() } + + FileManagerSortField.SIZE + -> compareBy { it.sizeBytes ?: -1L } + .thenBy { it.name.lowercase() } + + FileManagerSortField.TIME + -> compareBy { it.modifiedAt } + .thenBy { it.name.lowercase() } + + FileManagerSortField.EXTENSION + -> compareBy { extensionSortBucket(it) } + .thenBy { extensionSortKey(it) } + .thenBy { it.name.lowercase() } + } + if (field == FileManagerSortField.EXTENSION) { + val extensionComparator = + if (descending) + compareBy { extensionSortBucket(it) } + .then(compareByDescending { extensionSortKey(it) }) + .thenBy { it.name.lowercase() } + else contentComparator + return entries.sortedWith(extensionComparator) + } + val orderComparator = + if (descending) contentComparator.reversed() + else contentComparator + return entries.sortedWith( + compareByDescending { it.isDirectory } + .then(orderComparator) + ) +} + +private fun resolveLinkTarget(entry: RemoteFileEntry): String? { + val target = entry.symlinkTarget?.trim()?.takeIf { it.isNotBlank() } ?: return null + return if (target.startsWith("/")) normalizePath(target) + else normalizePath(entry.fullPath.substringBeforeLast('/', "") + "/" + target) +} + +private fun isDirectoryStat(stat: RemoteFileStat): Boolean { + return stat.permissions?.startsWith("d") == true + || stat.typeLabel?.contains("directory", ignoreCase = true) == true +} + +private fun buildDetailsText( + stat: RemoteFileStat, + targetStat: RemoteFileStat?, + directorySnapshot: DirectoryDownloadSnapshot?, + showRaw: Boolean, +): String { + val details = StringBuilder( + if (showRaw) stat.rawOutput + else FileManagerService.formatStatDetails(stat, directorySnapshot) + ) + if (targetStat != null) { + details.append("\n\n目标信息\n") + details.append( + if (showRaw) targetStat.rawOutput + else FileManagerService.formatStatDetails(targetStat) + ) + } + return details.toString() +} + +private fun extensionSortBucket(entry: RemoteFileEntry): Int { + return if (entry.isDirectory || extensionSortKey(entry).isEmpty()) 0 else 1 +} + +private fun extensionSortKey(entry: RemoteFileEntry): String { + if (entry.isDirectory) return "" + return entry.name.substringAfterLast('.', "").lowercase() +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt index 4161a98..e5c287d 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Devices +import androidx.compose.material.icons.rounded.Folder import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Terminal import androidx.compose.runtime.Composable @@ -28,6 +29,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -103,6 +105,7 @@ private enum class MainBottomTabDestination( ) { Devices(label = "设备", icon = Icons.Rounded.Devices), Terminal(label = "终端", icon = Icons.Rounded.Terminal), + Files(label = "文件", icon = Icons.Rounded.Folder), Settings(label = "设置", icon = Icons.Rounded.Settings); } @@ -141,7 +144,7 @@ fun MainScreen() { initialPage = MainBottomTabDestination.Devices.ordinal, pageCount = { tabs.size }) var selectedTabIndex by rememberSaveable { - mutableStateOf(MainBottomTabDestination.Devices.ordinal) + mutableIntStateOf(MainBottomTabDestination.Devices.ordinal) } var pagerNavigationJob by remember { mutableStateOf(null) } var isPagerNavigating by remember { mutableStateOf(false) } @@ -150,6 +153,8 @@ fun MainScreen() { val currentRootScreen = rootBackStack.lastOrNull() as? RootScreen ?: RootScreen.Home var showReorderDevices by rememberSaveable { mutableStateOf(false) } var lastExitBackPressAtMs by rememberSaveable { mutableLongStateOf(0L) } + var fileTabCanNavigateUp by remember { mutableStateOf(false) } + var fileTabNavigateUp by remember { mutableStateOf<(() -> Boolean)?>(null) } // Scroll behaviors val devicesPageScrollBehavior = MiuixScrollBehavior( @@ -323,24 +328,32 @@ fun MainScreen() { } fun handleBackNavigation() { - if (rootBackStack.size > 1) { - rootNavigator.pop() - } else if (selectedTabIndex != MainBottomTabDestination.Devices.ordinal) { - navigateToTab(MainBottomTabDestination.Devices) - } else { - val now = SystemClock.elapsedRealtime() - if (now - lastExitBackPressAtMs > 2_000L) { - lastExitBackPressAtMs = now - Toast.makeText(context, "再按一次返回键退出", Toast.LENGTH_SHORT).show() - return - } - lastExitBackPressAtMs = 0L - scope.launch { - withContext(Dispatchers.IO) { - runCatching { scrcpy.stop() } - runCatching { NativeAdbService.disconnect() } + when { + rootBackStack.size > 1 -> rootNavigator.pop() + + selectedTabIndex == MainBottomTabDestination.Files.ordinal + && fileTabCanNavigateUp + && fileTabNavigateUp?.invoke() == true + -> return + + selectedTabIndex != MainBottomTabDestination.Devices.ordinal + -> navigateToTab(MainBottomTabDestination.Devices) + + else -> { + val now = SystemClock.elapsedRealtime() + if (now - lastExitBackPressAtMs > 2_000L) { + lastExitBackPressAtMs = now + Toast.makeText(context, "再按一次返回键退出", Toast.LENGTH_SHORT).show() + return + } + lastExitBackPressAtMs = 0L + scope.launch { + withContext(Dispatchers.IO) { + runCatching { scrcpy.stop() } + runCatching { NativeAdbService.disconnect() } + } + activity?.finish() } - activity?.finish() } } } @@ -453,6 +466,12 @@ fun MainScreen() { bottomInnerPadding = bottomInnerPadding, ) + MainBottomTabDestination.Files -> FileManagerScreen( + bottomInnerPadding = bottomInnerPadding, + onCanNavigateUpChange = { fileTabCanNavigateUp = it }, + onNavigateUpActionChange = { fileTabNavigateUp = it }, + ) + MainBottomTabDestination.Settings -> SettingsScreen( scrollBehavior = settingsPageScrollBehavior, bottomInnerPadding = bottomInnerPadding, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt index 256908c..737b62c 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp +import androidx.core.net.toUri import io.github.miuzarte.scrcpyforandroid.BuildConfig import io.github.miuzarte.scrcpyforandroid.LockscreenPasswordActivity import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes @@ -68,6 +69,7 @@ import top.yukonga.miuix.kmp.preference.SwitchPreference import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme import kotlin.math.roundToInt import kotlin.system.exitProcess +import android.provider.Settings as AndroidSettings @Composable fun SettingsScreen( @@ -480,6 +482,50 @@ fun SettingsPage( item { SectionSmallTitle("ADB") Card { + ArrowPreference( + title = "调整后台电池策略", + summary = + """ + 解决 Scrcpy 切换到后台时无法联网导致 ADB 断连 + 应用的电池使用情况 -> 允许后台使用 -> 无限制 + 国产ROM魔改的电源设置一般都可在对应的魔改应用设置中找到 + """.trimIndent(), + onClick = { + val appInfoArgs = android.os.Bundle().apply { + putString("package", context.packageName) + putInt("uid", context.applicationInfo.uid) + } + val appDetailsIntent = Intent(Intent.ACTION_MAIN).apply { + setClassName( + "com.android.settings", + "com.android.settings.SubSettings" + ) + putExtra( + ":settings:show_fragment", + "com.android.settings.applications.appinfo.AppInfoDashboardFragment" + ) + putExtra( + ":settings:show_fragment_title", + "应用信息" + ) + putExtra(":settings:show_fragment_args", appInfoArgs) + putExtra("package", context.packageName) + putExtra("uid", context.applicationInfo.uid) + putExtra("android.provider.extra.APP_PACKAGE", context.packageName) + } + val requestIntent = Intent( + AndroidSettings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + "package:${context.packageName}".toUri() + ) + val fallbackIntent = Intent( + AndroidSettings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS + ) + runCatching { context.startActivity(appDetailsIntent) } + .recoverCatching { context.startActivity(requestIntent) } + .recoverCatching { context.startActivity(fallbackIntent) } + .onFailure { snackbar.show("无法打开设置") } + }, + ) Column( modifier = Modifier.padding(vertical = UiSpacing.Large), verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical), diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt index daebe77..27fb2cb 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/DeviceConnectionLogic.kt @@ -18,8 +18,8 @@ internal data class ConnectedDeviceInfo( * Fetch basic device properties from an already-connected device. * * Notes: - * - This function issues multiple `shell getprop` commands via [adbService.shell]. - * Each call may block on native I/O, so it should be called from a coroutine context. + * - This function issues multiple `getprop` commands over a single reusable shell session + * to avoid reopening a new adb shell stream for every property. * - Returns a lightweight [ConnectedDeviceInfo] structure with commonly-used properties. */ internal suspend fun fetchConnectedDeviceInfo( @@ -27,17 +27,25 @@ internal suspend fun fetchConnectedDeviceInfo( host: String, port: Int ): ConnectedDeviceInfo = withContext(Dispatchers.IO) { - suspend fun prop(name: String): String = runCatching { - adbService.shell("getprop $name").trim() - }.getOrDefault("") + val values = runCatching { + adbService.shellBatch { + command("getprop ro.product.model") + command("getprop ro.serialno") + command("getprop ro.product.manufacturer") + command("getprop ro.product.brand") + command("getprop ro.product.device") + command("getprop ro.build.version.release") + command("getprop ro.build.version.sdk") + } + }.getOrDefault(emptyList()) ConnectedDeviceInfo( - model = prop("ro.product.model").ifBlank { "$host:$port" }, - serial = prop("ro.serialno"), - manufacturer = prop("ro.product.manufacturer"), - brand = prop("ro.product.brand"), - device = prop("ro.product.device"), - androidRelease = prop("ro.build.version.release"), - sdkInt = prop("ro.build.version.sdk").toIntOrNull() ?: -1, + model = values.getOrNull(0).orEmpty().trim().ifBlank { "$host:$port" }, + serial = values.getOrNull(1).orEmpty().trim(), + manufacturer = values.getOrNull(2).orEmpty().trim(), + brand = values.getOrNull(3).orEmpty().trim(), + device = values.getOrNull(4).orEmpty().trim(), + androidRelease = values.getOrNull(5).orEmpty().trim(), + sdkInt = values.getOrNull(6).orEmpty().trim().toIntOrNull() ?: -1, ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/FileManagerService.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/FileManagerService.kt new file mode 100644 index 0000000..0126956 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/FileManagerService.kt @@ -0,0 +1,809 @@ +package io.github.miuzarte.scrcpyforandroid.services + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.os.Environment +import android.provider.DocumentsContract +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap +import io.github.miuzarte.scrcpyforandroid.nativecore.AdbSocketStream +import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.Closeable +import java.io.File +import java.io.IOException +import java.nio.charset.StandardCharsets +import java.text.DecimalFormat +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +enum class RemoteFileKind { + Directory, + Image, + Video, + Audio, + Archive, + Apk, + Text, + Link, + Other, +} + +data class RemoteFileEntry( + val inode: Long?, + val permissions: String, + val hardLinks: Int?, + val owner: String?, + val group: String?, + val sizeBytes: Long?, + val modifiedAt: LocalDateTime?, + val name: String, + val fullPath: String, + val symlinkTarget: String? = null, + val kind: RemoteFileKind, + val isDirectory: Boolean, +) + +data class RemoteFileStat( + val path: String, + val name: String, + val typeLabel: String?, + val sizeBytes: Long?, + val blocks: Long?, + val ioBlockBytes: Long?, + val inode: Long?, + val hardLinks: Int?, + val octalMode: String?, + val permissions: String?, + val uid: Long?, + val uidName: String?, + val gid: Long?, + val gidName: String?, + val accessTime: String?, + val modifyTime: String?, + val changeTime: String?, + val device: String?, + val deviceType: String?, + val symlinkTarget: String?, + val rawOutput: String, +) { + val title: String + get() = name.ifBlank { path } +} + +data class DirectoryDownloadSnapshot( + val remoteRootPath: String, + val totalBytes: Long?, + val directories: List, + val files: List, +) + +object FileManagerService { + private val listTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") + private val displayTimeFormatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss") + private val listLineRegex = Regex( + """^\s*(\d+)\s+([\-bcdlps][rwxstST-]{9})\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\s+(.+)$""" + ) + private val sizeFormatter = DecimalFormat("0.00") + + suspend fun listDirectory(path: String): List = withContext(Dispatchers.IO) { + val command = "ls -aFil ${quoteShellArg(pathForListCommand(path))}" + val output = NativeAdbService.shell(command) + output.lineSequence() + .map(String::trimEnd) + .filter { it.isNotBlank() } + .filterNot { it.startsWith("total ") } + .mapNotNull { parseListEntry(path, it) } + .filterNot { it.name == "." || it.name == ".." } + .sortedWith( + compareByDescending { it.isDirectory } + .thenBy { it.name.lowercase() } + ) + .toList() + } + + suspend fun stat(path: String): RemoteFileStat = withContext(Dispatchers.IO) { + val output = NativeAdbService.shell("stat ${quoteShellArg(path)}") + parseStat(path, output) + } + + suspend fun uploadFile( + context: Context, + uri: Uri, + remoteDirectory: String, + ): String = withContext(Dispatchers.IO) { + NativeAdbService.ensureConnectionResponsive() + val fileName = queryDisplayName(context.contentResolver, uri) + ?: throw IOException("无法读取文件名") + val remotePath = joinRemotePath(remoteDirectory, fileName) + context.contentResolver.openInputStream(uri).use { input -> + requireNotNull(input) { "无法读取选择的文件" } + NativeAdbService.push(input, remotePath) + } + return@withContext remotePath + } + + suspend fun createDirectory( + parentDirectory: String, + directoryName: String, + ): String = withContext(Dispatchers.IO) { + val sanitizedName = directoryName.trim().trim('/').takeIf { it.isNotBlank() } + ?: throw IOException("文件夹名称不能为空") + val remotePath = joinRemotePath(parentDirectory, sanitizedName) + NativeAdbService.ensureConnectionResponsive() + NativeAdbService.shell("mkdir -p ${quoteShellArg(remotePath)}") + return@withContext remotePath + } + + suspend fun downloadFileToPublicDownloads( + remotePath: String, + fileName: String, + ): Boolean = withContext(Dispatchers.IO) { + runCatching { + NativeAdbService.ensureConnectionResponsive() + val targetRoot = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + "Scrcpy" + ) + ensureDirectoryExists(targetRoot) + val targetFile = uniqueFile(targetRoot, fileName) + targetFile.outputStream().use { output -> + NativeAdbService.pull(remotePath, output) + } + }.isSuccess + } + + suspend fun downloadDirectoryToPublicDownloads( + snapshot: DirectoryDownloadSnapshot, + ): Boolean = withContext(Dispatchers.IO) { + runCatching { + NativeAdbService.ensureConnectionResponsive() + val rootName = snapshot.remoteRootPath.substringAfterLast('/').ifBlank { "Scrcpy" } + val publicRoot = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + "Scrcpy" + ) + ensureDirectoryExists(publicRoot) + val destinationRoot = uniqueDirectory(publicRoot, rootName) + ensureDirectoryExists(destinationRoot) + snapshot.directories.sortedBy { it.length }.forEach { relativePath -> + ensureDirectoryExists(File(destinationRoot, relativePath)) + } + snapshot.files.forEach { relativePath -> + val targetFile = File(destinationRoot, relativePath) + ensureDirectoryExists(targetFile.parentFile) + targetFile.outputStream().use { output -> + NativeAdbService.pull( + joinRemotePath(snapshot.remoteRootPath, relativePath), + output + ) + } + } + }.isSuccess + } + + suspend fun downloadFileToTree( + context: Context, + treeUri: Uri, + remotePath: String, + fileName: String, + ) = withContext(Dispatchers.IO) { + NativeAdbService.ensureConnectionResponsive() + val rootDocument = DocumentsContract.buildDocumentUriUsingTree( + treeUri, + DocumentsContract.getTreeDocumentId(treeUri) + ) + val target = createUniqueDocument(context, rootDocument, fileName, guessMimeType(fileName)) + context.contentResolver.openOutputStream(target, "w").use { output -> + requireNotNull(output) { "无法写入目标文件" } + NativeAdbService.pull(remotePath, output) + } + } + + suspend fun downloadDirectoryToTree( + context: Context, + treeUri: Uri, + snapshot: DirectoryDownloadSnapshot, + ) = withContext(Dispatchers.IO) { + NativeAdbService.ensureConnectionResponsive() + val rootDocument = DocumentsContract.buildDocumentUriUsingTree( + treeUri, + DocumentsContract.getTreeDocumentId(treeUri) + ) + val folderName = snapshot.remoteRootPath.substringAfterLast('/').ifBlank { "Scrcpy" } + val folderRoot = createUniqueDirectoryDocument(context, rootDocument, folderName) + + snapshot.directories.sortedBy { it.length }.forEach { relativePath -> + ensureTreeDirectory(context, folderRoot, relativePath) + } + + snapshot.files.forEach { relativePath -> + val parentRelativePath = relativePath.substringBeforeLast('/', "") + val fileName = relativePath.substringAfterLast('/') + val parentDocument = ensureTreeDirectory(context, folderRoot, parentRelativePath) + val target = + createUniqueDocument(context, parentDocument, fileName, guessMimeType(fileName)) + context.contentResolver.openOutputStream(target, "w").use { output -> + requireNotNull(output) { "无法写入目标文件" } + NativeAdbService.pull(joinRemotePath(snapshot.remoteRootPath, relativePath), output) + } + } + } + + fun formatSummary(entry: RemoteFileEntry): String { + val timeText = entry.modifiedAt?.format(displayTimeFormatter) ?: "未知时间" + return if (entry.isDirectory || entry.sizeBytes == null) { + timeText + } else { + "$timeText ${formatSize(entry.sizeBytes)}" + } + } + + fun formatStatDetails( + stat: RemoteFileStat, + directorySnapshot: DirectoryDownloadSnapshot? = null, + ): String { + val lines = mutableListOf() + val resolvedSizeBytes = directorySnapshot?.totalBytes ?: stat.sizeBytes + lines += "路径: ${stat.path}" + stat.typeLabel?.let { lines += "类型: $it" } + resolvedSizeBytes?.let { lines += "大小: ${formatSize(it)} ($it B)" } + stat.blocks?.let { lines += "块数: $it" } + stat.ioBlockBytes?.let { lines += "IO 块大小: ${it}B" } + stat.inode?.let { lines += "inode: $it" } + stat.hardLinks?.let { lines += "硬链接: $it" } + if (!stat.octalMode.isNullOrBlank() || !stat.permissions.isNullOrBlank()) { + lines += "权限: ${stat.octalMode ?: "?"}/${stat.permissions ?: "?"}" + } + if (stat.uid != null || stat.uidName != null) { + lines += "Uid: ${stat.uid ?: "?"}/${stat.uidName ?: "?"}" + } + if (stat.gid != null || stat.gidName != null) { + lines += "Gid: ${stat.gid ?: "?"}/${stat.gidName ?: "?"}" + } + stat.device?.let { lines += "设备: $it" } + stat.deviceType?.let { lines += "设备类型: $it" } + stat.accessTime?.let { lines += "访问时间: $it" } + stat.modifyTime?.let { lines += "修改时间: $it" } + stat.changeTime?.let { lines += "变更时间: $it" } + stat.symlinkTarget?.let { lines += "链接目标: $it" } + directorySnapshot?.let { + lines += "目录数: ${it.directories.size}" + lines += "文件数: ${it.files.size}" + } + return lines.joinToString(separator = "\n") + } + + fun formatSize(bytes: Long): String { + if (bytes < 1024) { + return "${bytes}B" + } + val units = listOf("K", "M", "G", "T") + var value = bytes.toDouble() + var index = -1 + while (value >= 1024 && index < units.lastIndex) { + value /= 1024.0 + index++ + } + return "${sizeFormatter.format(value)}${units[index]}" + } + + fun guessKind(name: String, isDirectory: Boolean, permissions: String): RemoteFileKind { + if (isDirectory) { + return RemoteFileKind.Directory + } + if (permissions.startsWith("l")) { + return RemoteFileKind.Link + } + return when (name.substringAfterLast('.', "").lowercase()) { + "png", "jpg", "jpeg", "gif", "webp", "bmp", "heic", "svg" -> RemoteFileKind.Image + + "mp4", "mkv", "avi", "webm", "mov", "ts", "m4v" -> RemoteFileKind.Video + + "mp3", "flac", "aac", "ogg", "wav", "m4a" -> RemoteFileKind.Audio + + "zip", "rar", "7z", "tar", "gz", "xz", "tgz" -> RemoteFileKind.Archive + + "apk", "apks", "xapk" -> RemoteFileKind.Apk + + "txt", "log", "json", "jsonc", "hjson", "xml", "yml", "yaml", "toml", "md", + "go", "kt", "java", "c", "cpp", "h", "hpp", "py", "sh", "bat", + -> RemoteFileKind.Text + + else -> RemoteFileKind.Other + } + } + + fun queryDisplayName(contentResolver: ContentResolver, uri: Uri): String? { + return contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { cursor -> + if (!cursor.moveToFirst()) { + return@use null + } + cursor.getString(0) + } + } + + fun quoteShellArg(value: String): String { + return "'" + value.replace("'", "'\\''") + "'" + } + + private fun parseListEntry(parentPath: String, rawLine: String): RemoteFileEntry? { + val match = listLineRegex.matchEntire(rawLine) ?: return null + val permissions = match.groupValues[2] + val dateTime = runCatching { + LocalDateTime.parse( + "${match.groupValues[7]} ${match.groupValues[8]}", + listTimeFormatter + ) + }.getOrNull() + val nameField = match.groupValues[9] + val rawName = nameField.substringBefore(" -> ") + val symlinkTarget = nameField.substringAfter(" -> ", missingDelimiterValue = "") + .takeIf { it.isNotBlank() } + val cleanedName = stripListSuffix(unescapeLsName(rawName), permissions) + val fullPath = joinRemotePath(parentPath, cleanedName) + val isDirectory = permissions.startsWith("d") || rawName.endsWith("/") + return RemoteFileEntry( + inode = match.groupValues[1].toLongOrNull(), + permissions = permissions, + hardLinks = match.groupValues[3].toIntOrNull(), + owner = match.groupValues[4].ifBlank { null }, + group = match.groupValues[5].ifBlank { null }, + sizeBytes = match.groupValues[6].toLongOrNull(), + modifiedAt = dateTime, + name = cleanedName, + fullPath = fullPath, + symlinkTarget = symlinkTarget, + kind = guessKind(cleanedName, isDirectory, permissions), + isDirectory = isDirectory, + ) + } + + private fun parseStat(path: String, rawOutput: String): RemoteFileStat { + var sizeBytes: Long? = null + var blocks: Long? = null + var ioBlockBytes: Long? = null + var typeLabel: String? = null + var inode: Long? = null + var hardLinks: Int? = null + var octalMode: String? = null + var permissions: String? = null + var uid: Long? = null + var uidName: String? = null + var gid: Long? = null + var gidName: String? = null + var accessTime: String? = null + var modifyTime: String? = null + var changeTime: String? = null + var device: String? = null + var deviceType: String? = null + var symlinkTarget: String? = null + var statPath = path + + rawOutput.lineSequence().forEach { line -> + val trimmed = line.trim() + when { + trimmed.startsWith("File:") -> { + statPath = trimmed.removePrefix("File:").trim() + if (" -> " in statPath) { + val parts = statPath.split(" -> ", limit = 2) + statPath = parts[0].trim() + symlinkTarget = parts.getOrNull(1)?.trim() + } + } + + trimmed.startsWith("Size:") -> { + val match = + Regex("""Size:\s*(\d+)\s+Blocks:\s*(\d+)\s+IO Blocks:\s*(\d+)\s+(.+)""") + .find(trimmed) + sizeBytes = match?.groupValues?.getOrNull(1)?.toLongOrNull() + blocks = match?.groupValues?.getOrNull(2)?.toLongOrNull() + ioBlockBytes = match?.groupValues?.getOrNull(3)?.toLongOrNull() + typeLabel = match?.groupValues?.getOrNull(4)?.trim() + } + + trimmed.startsWith("Device:") -> { + val match = + Regex("""Device:\s*(\S+)\s+Inode:\s*(\d+)\s+Links:\s*(\d+)\s+Device type:\s*(.+)""") + .find(trimmed) + device = match?.groupValues?.getOrNull(1) + inode = match?.groupValues?.getOrNull(2)?.toLongOrNull() + hardLinks = match?.groupValues?.getOrNull(3)?.toIntOrNull() + deviceType = match?.groupValues?.getOrNull(4)?.trim() + } + + trimmed.startsWith("Access: (") -> { + val modeMatch = Regex("""Access:\s+\(([^/]+)/([^)]+)\)""").find(trimmed) + octalMode = modeMatch?.groupValues?.getOrNull(1) + permissions = modeMatch?.groupValues?.getOrNull(2) + val uidMatch = Regex("""Uid:\s+\(\s*(\d+)/\s*([^)]+)\)""").find(trimmed) + uid = uidMatch?.groupValues?.getOrNull(1)?.toLongOrNull() + uidName = uidMatch?.groupValues?.getOrNull(2)?.trim() + val gidMatch = Regex("""Gid:\s+\(\s*(\d+)/\s*([^)]+)\)""").find(trimmed) + gid = gidMatch?.groupValues?.getOrNull(1)?.toLongOrNull() + gidName = gidMatch?.groupValues?.getOrNull(2)?.trim() + } + + trimmed.startsWith("Access:") -> accessTime = trimmed.removePrefix("Access:").trim() + trimmed.startsWith("Modify:") -> modifyTime = trimmed.removePrefix("Modify:").trim() + trimmed.startsWith("Change:") -> changeTime = trimmed.removePrefix("Change:").trim() + } + } + + return RemoteFileStat( + path = statPath, + name = statPath.substringAfterLast('/'), + typeLabel = typeLabel, + sizeBytes = sizeBytes, + blocks = blocks, + ioBlockBytes = ioBlockBytes, + inode = inode, + hardLinks = hardLinks, + octalMode = octalMode, + permissions = permissions, + uid = uid, + uidName = uidName, + gid = gid, + gidName = gidName, + accessTime = accessTime, + modifyTime = modifyTime, + changeTime = changeTime, + device = device, + deviceType = deviceType, + symlinkTarget = symlinkTarget, + rawOutput = rawOutput.trim(), + ) + } + + private fun stripListSuffix(name: String, permissions: String): String { + if (name.isEmpty()) { + return name + } + val suffix = name.last() + return when { + permissions.startsWith("d") && suffix == '/' -> name.dropLast(1) + suffix in listOf('*', '@', '|', '=', '>') -> name.dropLast(1) + else -> name + } + } + + private fun unescapeLsName(value: String): String { + val builder = StringBuilder(value.length) + var index = 0 + while (index < value.length) { + val ch = value[index] + if (ch == '\\' && index + 1 < value.length) { + builder.append(value[index + 1]) + index += 2 + } else { + builder.append(ch) + index++ + } + } + return builder.toString() + } + + private fun joinRemotePath(parent: String, child: String): String { + val normalizedParent = parent.trimEnd('/').ifBlank { "/" } + val normalizedChild = child.trimStart('/') + return when (normalizedParent) { + "/" -> "/$normalizedChild" + else -> "$normalizedParent/$normalizedChild" + } + } + + private fun pathForListCommand(path: String): String { + val normalized = path.trimEnd('/').ifBlank { "/" } + return if (normalized == "/") { + normalized + } else { + "$normalized/." + } + } + + private fun guessMimeType(name: String): String { + val extension = name.substringAfterLast('.', "").lowercase() + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + ?: "application/octet-stream" + } + + private fun ensureDirectoryExists(directory: File?) { + requireNotNull(directory) { "目录不存在" } + if (directory.exists()) { + require(directory.isDirectory) { "目标不是文件夹: ${directory.absolutePath}" } + return + } + if (!directory.mkdirs()) { + throw IOException("无法创建目录: ${directory.absolutePath}") + } + } + + private fun uniqueFile(directory: File, fileName: String): File { + val baseName = fileName.substringBeforeLast('.', fileName) + val extension = fileName.substringAfterLast('.', "") + var candidate = File(directory, fileName) + var index = 1 + while (candidate.exists()) { + val suffix = " ($index)" + candidate = if (extension.isBlank() || baseName == fileName) { + File(directory, "$fileName$suffix") + } else { + File(directory, "$baseName$suffix.$extension") + } + index++ + } + return candidate + } + + private fun uniqueDirectory(parent: File, baseName: String): File { + var candidate = File(parent, baseName) + var index = 1 + while (candidate.exists()) { + candidate = File(parent, "$baseName ($index)") + index++ + } + return candidate + } + + private fun createUniqueDirectoryDocument( + context: Context, + parentDocument: Uri, + baseName: String + ): Uri { + var name = baseName + var index = 1 + while (findChildDocument( + context, + parentDocument, + name, + DocumentsContract.Document.MIME_TYPE_DIR + ) != null + ) { + name = "$baseName ($index)" + index++ + } + return DocumentsContract.createDocument( + context.contentResolver, + parentDocument, + DocumentsContract.Document.MIME_TYPE_DIR, + name, + ) ?: throw IOException("无法创建目录: $name") + } + + private fun createUniqueDocument( + context: Context, + parentDocument: Uri, + baseName: String, + mimeType: String, + ): Uri { + val fileBase = baseName.substringBeforeLast('.', baseName) + val extension = baseName.substringAfterLast('.', "") + var candidate = baseName + var index = 1 + while (findChildDocument(context, parentDocument, candidate, mimeType) != null) { + candidate = if (extension.isBlank() || fileBase == baseName) { + "$baseName ($index)" + } else { + "$fileBase ($index).$extension" + } + index++ + } + return DocumentsContract.createDocument( + context.contentResolver, + parentDocument, + mimeType, + candidate, + ) ?: throw IOException("无法创建文件: $candidate") + } + + private fun ensureTreeDirectory( + context: Context, + rootDocument: Uri, + relativePath: String + ): Uri { + var current = rootDocument + if (relativePath.isBlank()) { + return current + } + relativePath.split('/') + .filter { it.isNotBlank() } + .forEach { segment -> + val existing = findChildDocument( + context, + current, + segment, + DocumentsContract.Document.MIME_TYPE_DIR + ) + current = existing ?: DocumentsContract.createDocument( + context.contentResolver, + current, + DocumentsContract.Document.MIME_TYPE_DIR, + segment, + ) ?: throw IOException("无法创建目录: $segment") + } + return current + } + + private fun findChildDocument( + context: Context, + parentDocument: Uri, + displayName: String, + mimeType: String, + ): Uri? { + val parentId = DocumentsContract.getDocumentId(parentDocument) + val childrenUri = + DocumentsContract.buildChildDocumentsUriUsingTree(parentDocument, parentId) + val resolver = context.contentResolver + resolver.query( + childrenUri, + arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + ), + null, + null, + null, + )?.use { cursor -> + while (cursor.moveToNext()) { + val childDisplayName = cursor.getString(1) + val childMimeType = cursor.getString(2) + if (childDisplayName == displayName && childMimeType == mimeType) { + return DocumentsContract.buildDocumentUriUsingTree( + parentDocument, + cursor.getString(0) + ) + } + } + } + return null + } +} + +class DirectorySnapshotSession private constructor( + private val shellSession: InteractiveShellSession, +) : Closeable { + suspend fun load(path: String): DirectoryDownloadSnapshot { + val normalizedPath = path.trimEnd('/').ifBlank { "/" } + val totalBytes = parseDuBytes( + shellSession.execute("du -sb ${FileManagerService.quoteShellArg(normalizedPath)}") + ) + + val directories = shellSession.execute( + "find ${FileManagerService.quoteShellArg(normalizedPath)} -type d" + ) + .lineSequence() + .map(String::trim) + .filter { it.isNotBlank() && it != normalizedPath } + .map { relativePath(normalizedPath, it) } + .filter { it.isNotBlank() } + .toList() + + val files = shellSession.execute( + "find ${FileManagerService.quoteShellArg(normalizedPath)} -type f" + ) + .lineSequence() + .map(String::trim) + .filter { it.isNotBlank() && it != normalizedPath } + .map { relativePath(normalizedPath, it) } + .filter { it.isNotBlank() } + .toList() + + return DirectoryDownloadSnapshot( + remoteRootPath = normalizedPath, + totalBytes = totalBytes, + directories = directories, + files = files, + ) + } + + suspend fun interrupt() { + shellSession.interrupt() + close() + } + + override fun close() { + shellSession.close() + } + + companion object { + suspend fun open(): DirectorySnapshotSession { + return DirectorySnapshotSession(InteractiveShellSession.open()) + } + + private fun parseDuBytes(output: String): Long? { + return output.lineSequence() + .map(String::trim).firstNotNullOfOrNull { line -> + Regex("""^(\d+)(?:\s+.*)?$""") + .find(line) + ?.groupValues + ?.getOrNull(1) + ?.toLongOrNull() + } + } + + private fun relativePath(root: String, fullPath: String): String { + val prefix = root.trimEnd('/') + "/" + return if (fullPath.startsWith(prefix)) { + fullPath.removePrefix(prefix) + } else { + fullPath + } + } + } +} + +private class InteractiveShellSession private constructor( + private val stream: AdbSocketStream, +) : Closeable { + private val mutex = Mutex() + + suspend fun execute(command: String): String = withContext(Dispatchers.IO) { + mutex.withLock { + val marker = "__SCRCPY_FILEMANAGER_${System.nanoTime()}__" + val commandBytes = buildString { + append("(") + append(command) + append(")\n") + append("printf '\\n") + append(marker) + append(":%d\\n' $?\n") + }.toByteArray(StandardCharsets.UTF_8) + stream.outputStream.write(commandBytes) + stream.outputStream.flush() + readUntilMarker(marker) + } + } + + suspend fun interrupt() = withContext(Dispatchers.IO) { + runCatching { + stream.outputStream.write(byteArrayOf(3)) + stream.outputStream.flush() + } + } + + override fun close() { + runCatching { stream.close() } + } + + private fun readUntilMarker(marker: String): String { + val builder = StringBuilder() + val buffer = ByteArray(4096) + val markerPrefix = "\n$marker:" + + while (true) { + val count = stream.inputStream.read(buffer) + if (count <= 0) { + throw IOException("远端 shell 已关闭") + } + builder.append(String(buffer, 0, count, StandardCharsets.UTF_8)) + val markerStart = builder.indexOf(markerPrefix) + if (markerStart >= 0) { + val markerEnd = builder.indexOf("\n", markerStart + markerPrefix.length) + if (markerEnd >= 0) { + val statusText = + builder.substring(markerStart + markerPrefix.length, markerEnd).trim() + val status = statusText.toIntOrNull() ?: 1 + val output = builder.substring(0, markerStart).trimEnd('\r', '\n') + if (status != 0) { + throw IOException(output.ifBlank { "命令执行失败 ($status)" }) + } + return output + } + } + } + } + + companion object { + suspend fun open(): InteractiveShellSession { + return withContext(Dispatchers.IO) { + InteractiveShellSession(NativeAdbService.openShellStream("")) + } + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt index 6d9f31e..34e404f 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt @@ -133,6 +133,14 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { booleanPreferencesKey("realtime_clipboard_sync_to_device"), true ) + val FILE_MANAGER_SORT_BY = Pair( + stringPreferencesKey("file_manager_sort_by"), + "NAME" + ) + val FILE_MANAGER_SORT_DESCENDING = Pair( + booleanPreferencesKey("file_manager_sort_descending"), + false + ) val LAST_UPDATE_CHECK_AT = Pair( longPreferencesKey("last_update_check_at"), 0L @@ -172,6 +180,8 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { val adbAutoLoadAppListOnConnect by setting(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT) val passwordRequireAuth by setting(PASSWORD_REQUIRE_AUTH) val realtimeClipboardSyncToDevice by setting(REALTIME_CLIPBOARD_SYNC_TO_DEVICE) + val fileManagerSortBy by setting(FILE_MANAGER_SORT_BY) + val fileManagerSortDescending by setting(FILE_MANAGER_SORT_DESCENDING) val lastUpdateCheckAt by setting(LAST_UPDATE_CHECK_AT) @Parcelize @@ -202,6 +212,8 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { val adbAutoLoadAppListOnConnect: Boolean, val passwordRequireAuth: Boolean, val realtimeClipboardSyncToDevice: Boolean, + val fileManagerSortBy: String, + val fileManagerSortDescending: Boolean, val lastUpdateCheckAt: Long, ) : Parcelable { } @@ -233,6 +245,8 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { bundleField(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT) { bundle: Bundle -> bundle.adbAutoLoadAppListOnConnect }, bundleField(PASSWORD_REQUIRE_AUTH) { bundle: Bundle -> bundle.passwordRequireAuth }, bundleField(REALTIME_CLIPBOARD_SYNC_TO_DEVICE) { bundle: Bundle -> bundle.realtimeClipboardSyncToDevice }, + bundleField(FILE_MANAGER_SORT_BY) { bundle: Bundle -> bundle.fileManagerSortBy }, + bundleField(FILE_MANAGER_SORT_DESCENDING) { bundle: Bundle -> bundle.fileManagerSortDescending }, bundleField(LAST_UPDATE_CHECK_AT) { bundle: Bundle -> bundle.lastUpdateCheckAt }, ) @@ -267,6 +281,8 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { adbAutoLoadAppListOnConnect = preferences.read(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT), passwordRequireAuth = preferences.read(PASSWORD_REQUIRE_AUTH), realtimeClipboardSyncToDevice = preferences.read(REALTIME_CLIPBOARD_SYNC_TO_DEVICE), + fileManagerSortBy = preferences.read(FILE_MANAGER_SORT_BY), + fileManagerSortDescending = preferences.read(FILE_MANAGER_SORT_DESCENDING), lastUpdateCheckAt = preferences.read(LAST_UPDATE_CHECK_AT), ) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/AppBottomSheets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/AppBottomSheets.kt index be0b344..b8872aa 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/AppBottomSheets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/AppBottomSheets.kt @@ -4,8 +4,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.sizeIn diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/MultiGroupsDropdown.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/MultiGroupsDropdown.kt new file mode 100644 index 0000000..1962b49 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/MultiGroupsDropdown.kt @@ -0,0 +1,35 @@ +package io.github.miuzarte.scrcpyforandroid.widgets + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import top.yukonga.miuix.kmp.basic.DropdownImpl +import top.yukonga.miuix.kmp.basic.HorizontalDivider + +data class MultiGroupsDropdownGroup( + val options: List, + val selectedIndex: Int, + val onSelectedIndexChange: (Int) -> Unit, +) + +@Composable +fun MultiGroupsDropdown( + groups: List, + modifier: Modifier = Modifier, +) { + groups.forEachIndexed { groupIndex, group -> + group.options.forEachIndexed { optionIndex, option -> + DropdownImpl( + text = option, + optionSize = group.options.size, + isSelected = optionIndex == group.selectedIndex, + index = optionIndex, + onSelectedIndexChange = group.onSelectedIndexChange, + ) + } + if (groupIndex != groups.lastIndex) { + HorizontalDivider(modifier = modifier.padding(horizontal = 20.dp)) + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/TerminalInputView.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/TerminalInputView.kt index 6c7ae21..ebcb2e2 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/TerminalInputView.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/TerminalInputView.kt @@ -15,9 +15,9 @@ class TerminalInputView @JvmOverloads constructor( defStyleAttr: Int = 0, ) : View(context, attrs, defStyleAttr) { interface InputCallbacks { + fun handleKeyEvent(event: KeyEvent): Boolean fun handleCommitText(text: CharSequence): Boolean fun handleDeleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean - fun handleKeyEvent(event: KeyEvent): Boolean } var inputCallbacks: InputCallbacks? = null @@ -66,9 +66,10 @@ class TerminalInputView @JvmOverloads constructor( } override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean { - if (inputCallbacks?.handleDeleteSurroundingText(beforeLength, afterLength) == true) { - return true - } + if (inputCallbacks + ?.handleDeleteSurroundingText(beforeLength, afterLength) == true + ) return true + return super.deleteSurroundingText(beforeLength, afterLength) }