mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-08 05:20:59 +03:00
fix: android: replace all-files access with scoped storage (#15602)
* fix: android: replace all-files access with scoped storage + system picker Remove MANAGE_EXTERNAL_STORAGE, READ_EXTERNAL_STORAGE, and WRITE_EXTERNAL_STORAGE from the Android manifest. Remove requestLegacyExternalStorage. Replace broad external storage with app-scoped external storage for the file-transfer workspace. File import uses the system file_picker. File export uses Android's SAF ACTION_CREATE_DOCUMENT with path validation that restricts export sources to app-owned directories. Remove the external_path dependency. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: refine file import feedback Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: use SAF for file imports Replace file_picker imports with Android's Storage Access Framework to avoid legacy storage permissions, stale cached files, and duplicate staging of large imports. Stream selected documents into app-scoped storage with failure-safe replacement, keep exports restricted to validated app storage roots, use filesDir for the internal fallback workspace, and remove legacy permissions contributed during manifest merging. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: keep file imports in the selected directory Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: reset projection and constrain file workspace Release capture resources when media projection is revoked externally. Keep Android local file navigation within the app-scoped workspace. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: handle scoped storage start-up regressions. Allow zero digits in POSIX filenames by rejecting NUL explicitly, and initialise the app-specific home directory before the Android service starts the native server. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: update content resolver mode to use 'wt' instead of 'w' to prevent trailing bytes from old document whilst reporting sucess Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android, enforce file workspace boundary on the server, and unblock the ui thread. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: validate rename destinations against the app workspace bound file-operation paths. report rename failures, general import failures, and unregister / reregister projection when its onStop callback fires. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: reconnect was refreshing the directory with net entry instances, while selected items retained the old instances, it was reporting a selected item, but checkbox statue used object identity, and appeared unchecked. Fixed by reconciling by path and entry type before replacing the directory snapshot, rebinding valid selections, and dropping missing ones. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: (android) add SAF folder import and multi item export - import directories using ACTION_OPEN_DOCUMENT_TREE. Export multiple files, logs, and screen recordings via export buttons, add localisation keys for new actions Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix(android): harden scoped storage file handling - create new SAF documents instead of overwriting export sources - reject empty peer paths except for home directory reads - report directory backup restore and cleanup failures - resolve log export paths from the configured app name Signed-off-by: fufesou <linlong1266@gmail.com> * fix(android): harden scoped-storage file operations - snapshot directory exports before writing to the destination - query document provider metadata off the main thread - reject invalid remote directories without read timeouts Signed-off-by: fufesou <linlong1266@gmail.com> * fix(android): handle SAF directory name collisions - reject dot-segment folder names during import - fail imports with duplicate document display names - only reuse matching directories during export Signed-off-by: fufesou <linlong1266@gmail.com> * fix(android): handle SAF folder import collisions Reject filesystem-equivalent destination names and avoid showing a failure when folder overwrite is skipped. Signed-off-by: fufesou <linlong1266@gmail.com> --------- Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> Signed-off-by: fufesou <linlong1266@gmail.com> Co-authored-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
@@ -1,15 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.carriez.flutter_hbb">
|
||||
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
@@ -26,7 +28,6 @@
|
||||
android:name=".MainApplication"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="RustDesk"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:supportsRtl="true">
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ package com.carriez.flutter_hbb
|
||||
|
||||
import ffi.FFI
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -24,6 +25,10 @@ import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
|
||||
import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar
|
||||
import android.media.MediaCodecList
|
||||
import android.media.MediaFormat
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.util.DisplayMetrics
|
||||
import androidx.annotation.RequiresApi
|
||||
import org.json.JSONArray
|
||||
@@ -33,6 +38,9 @@ import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlin.concurrent.thread
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
@@ -46,6 +54,23 @@ class MainActivity : FlutterActivity() {
|
||||
private val channelTag = "mChannel"
|
||||
private val logTag = "mMainActivity"
|
||||
private var mainService: MainService? = null
|
||||
private sealed class PendingPicker {
|
||||
data class ImportFiles(val result: MethodChannel.Result) : PendingPicker()
|
||||
data class ExportFile(val source: File, val result: MethodChannel.Result) : PendingPicker()
|
||||
data class ImportDirectory(val result: MethodChannel.Result) : PendingPicker()
|
||||
data class ExportFiles(
|
||||
val sources: List<File>,
|
||||
val rejected: Int,
|
||||
val result: MethodChannel.Result
|
||||
) : PendingPicker()
|
||||
}
|
||||
|
||||
private data class ExportSource(
|
||||
val file: File,
|
||||
val children: List<ExportSource>?
|
||||
)
|
||||
|
||||
private var pendingPicker: PendingPicker? = null
|
||||
|
||||
private var isAudioStart = false
|
||||
private val audioRecordHandle = AudioRecordHandle(this, { false }, { isAudioStart })
|
||||
@@ -91,6 +116,108 @@ class MainActivity : FlutterActivity() {
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode == REQ_IMPORT_FILES) {
|
||||
val pending = pendingPicker as? PendingPicker.ImportFiles ?: return
|
||||
pendingPicker = null
|
||||
if (resultCode != Activity.RESULT_OK || data == null) {
|
||||
pending.result.success(emptyList<Map<String, String>>())
|
||||
return
|
||||
}
|
||||
|
||||
val uris = linkedSetOf<Uri>()
|
||||
data.data?.let { uris.add(it) }
|
||||
data.clipData?.let { clipData ->
|
||||
for (index in 0 until clipData.itemCount) {
|
||||
uris.add(clipData.getItemAt(index).uri)
|
||||
}
|
||||
}
|
||||
thread {
|
||||
val files = uris.map { uri ->
|
||||
mapOf(
|
||||
"uri" to uri.toString(),
|
||||
"name" to (displayName(uri) ?: uri.lastPathSegment.orEmpty())
|
||||
)
|
||||
}
|
||||
runOnUiThread { pending.result.success(files) }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (requestCode == REQ_EXPORT_FILE) {
|
||||
val pending = pendingPicker as? PendingPicker.ExportFile ?: return
|
||||
pendingPicker = null
|
||||
val destination = data?.data
|
||||
|
||||
if (resultCode != Activity.RESULT_OK || destination == null) {
|
||||
pending.result.success(false)
|
||||
return
|
||||
}
|
||||
|
||||
thread {
|
||||
try {
|
||||
FileInputStream(pending.source).use { input ->
|
||||
contentResolver.openOutputStream(destination, "wt")?.use { output ->
|
||||
input.copyTo(output)
|
||||
} ?: throw IllegalStateException("Unable to open the selected destination")
|
||||
}
|
||||
runOnUiThread { pending.result.success(true) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to export file", e)
|
||||
runOnUiThread {
|
||||
pending.result.error("export_failed", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (requestCode == REQ_IMPORT_DIRECTORY) {
|
||||
val pending = pendingPicker as? PendingPicker.ImportDirectory ?: return
|
||||
pendingPicker = null
|
||||
val treeUri = data?.data
|
||||
if (resultCode != Activity.RESULT_OK || treeUri == null) {
|
||||
pending.result.success(null)
|
||||
return
|
||||
}
|
||||
thread {
|
||||
val selected = mapOf(
|
||||
"uri" to treeUri.toString(),
|
||||
"name" to (treeDisplayName(treeUri) ?: "Imported")
|
||||
)
|
||||
runOnUiThread { pending.result.success(selected) }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (requestCode == REQ_EXPORT_FILES) {
|
||||
val pending = pendingPicker as? PendingPicker.ExportFiles ?: return
|
||||
pendingPicker = null
|
||||
val treeUri = data?.data
|
||||
if (resultCode != Activity.RESULT_OK || treeUri == null) {
|
||||
pending.result.success(null)
|
||||
return
|
||||
}
|
||||
thread {
|
||||
var exported = 0
|
||||
var failed = pending.rejected
|
||||
var processed = 0
|
||||
try {
|
||||
val sources = pending.sources.map { snapshotExportSource(it) }
|
||||
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||
sources.forEach { source ->
|
||||
val ok = source?.let {
|
||||
copyExportSourceToTree(treeUri, rootDocId, it)
|
||||
} ?: false
|
||||
if (ok) exported++ else failed++
|
||||
processed++
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to export selected files", e)
|
||||
failed += pending.sources.size - processed
|
||||
}
|
||||
runOnUiThread {
|
||||
pending.result.success(mapOf("exported" to exported, "failed" to failed))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (requestCode == REQ_INVOKE_PERMISSION_ACTIVITY_MEDIA_PROJECTION && resultCode == RES_FAILED) {
|
||||
flutterMethodChannel?.invokeMethod("on_media_projection_canceled", null)
|
||||
}
|
||||
@@ -267,6 +394,242 @@ class MainActivity : FlutterActivity() {
|
||||
result.success(false)
|
||||
}
|
||||
}
|
||||
PICK_IMPORT_FILES -> {
|
||||
if (pendingPicker != null) {
|
||||
result.error("picker_in_progress", "Another document picker is already open", null)
|
||||
} else {
|
||||
pendingPicker = PendingPicker.ImportFiles(result)
|
||||
try {
|
||||
startActivityForResult(
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "*/*"
|
||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||
},
|
||||
REQ_IMPORT_FILES
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
pendingPicker = null
|
||||
result.error("picker_unavailable", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
IMPORT_FILE -> {
|
||||
val arguments = call.arguments as? Map<*, *>
|
||||
val uri = (arguments?.get("uri") as? String)?.let {
|
||||
runCatching { Uri.parse(it) }.getOrNull()
|
||||
}
|
||||
val path = arguments?.get("path") as? String
|
||||
val overwrite = arguments?.get("overwrite") as? Boolean ?: false
|
||||
val destination = path?.let { canonicalAppScopedFile(it) }
|
||||
|
||||
if (uri?.scheme != "content") {
|
||||
result.error("invalid_uri", "The selected document URI is invalid", null)
|
||||
} else if (destination == null ||
|
||||
destination.isDirectory ||
|
||||
destination.parentFile?.isDirectory != true) {
|
||||
result.error("invalid_destination", "The destination is outside app-scoped storage", null)
|
||||
} else {
|
||||
thread {
|
||||
var temporary: File? = null
|
||||
var reservedDestination = false
|
||||
var errorCode = "import_failed"
|
||||
try {
|
||||
val temporaryFile = File.createTempFile(
|
||||
".rustdesk-import-",
|
||||
".tmp",
|
||||
destination.parentFile
|
||||
)
|
||||
temporary = temporaryFile
|
||||
contentResolver.openInputStream(uri)?.use { input ->
|
||||
FileOutputStream(temporaryFile).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: throw IllegalStateException("Unable to open the selected document")
|
||||
if (!overwrite) {
|
||||
reservedDestination = destination.createNewFile()
|
||||
if (!reservedDestination) {
|
||||
throw IllegalStateException("The destination already exists")
|
||||
}
|
||||
}
|
||||
if (!temporaryFile.renameTo(destination)) {
|
||||
if (reservedDestination) {
|
||||
destination.delete()
|
||||
}
|
||||
errorCode = "rename_failed"
|
||||
throw IllegalStateException("Unable to replace the destination")
|
||||
}
|
||||
runOnUiThread { result.success(true) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to import file", e)
|
||||
runOnUiThread {
|
||||
result.error(errorCode, e.message, null)
|
||||
}
|
||||
} finally {
|
||||
temporary?.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EXPORT_FILE -> {
|
||||
val path = (call.arguments as? Map<*, *>)?.get("path") as? String
|
||||
val source = path?.let { canonicalExportSource(it) }
|
||||
|
||||
if (source?.isFile != true) {
|
||||
result.error("invalid_source", "The file is outside app-scoped storage", null)
|
||||
} else if (pendingPicker != null) {
|
||||
result.error("picker_in_progress", "Another document picker is already open", null)
|
||||
} else {
|
||||
val mimeType = MimeTypeMap.getSingleton()
|
||||
.getMimeTypeFromExtension(source.extension.lowercase())
|
||||
?: "application/octet-stream"
|
||||
pendingPicker = PendingPicker.ExportFile(source, result)
|
||||
try {
|
||||
startActivityForResult(
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = mimeType
|
||||
putExtra(Intent.EXTRA_TITLE, source.name)
|
||||
},
|
||||
REQ_EXPORT_FILE
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
pendingPicker = null
|
||||
result.error("picker_unavailable", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
PICK_IMPORT_DIRECTORY -> {
|
||||
if (pendingPicker != null) {
|
||||
result.error("picker_in_progress", "Another document picker is already open", null)
|
||||
} else {
|
||||
pendingPicker = PendingPicker.ImportDirectory(result)
|
||||
try {
|
||||
startActivityForResult(
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
|
||||
putExtra(Intent.EXTRA_TITLE, "Select the folder to import")
|
||||
},
|
||||
REQ_IMPORT_DIRECTORY
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
pendingPicker = null
|
||||
result.error("picker_unavailable", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
IMPORT_DIRECTORY -> {
|
||||
val arguments = call.arguments as? Map<*, *>
|
||||
val uri = (arguments?.get("uri") as? String)?.let {
|
||||
runCatching { Uri.parse(it) }.getOrNull()
|
||||
}
|
||||
val path = arguments?.get("path") as? String
|
||||
val overwrite = arguments?.get("overwrite") as? Boolean ?: false
|
||||
val destination = path?.let { canonicalAppScopedFile(it) }
|
||||
|
||||
if (uri?.scheme != "content") {
|
||||
result.error("invalid_uri", "The selected document URI is invalid", null)
|
||||
} else if (destination == null ||
|
||||
destination.parentFile?.isDirectory != true ||
|
||||
(destination.exists() && !destination.isDirectory)) {
|
||||
result.error("invalid_destination", "The destination is outside app-scoped storage", null)
|
||||
} else {
|
||||
thread {
|
||||
var temporary: File? = null
|
||||
var backup: File? = null
|
||||
val ok = try {
|
||||
val parent = destination.parentFile
|
||||
?: throw IllegalStateException("The destination has no parent")
|
||||
temporary = File.createTempFile(
|
||||
".rustdesk-import-dir-",
|
||||
".tmp",
|
||||
parent
|
||||
).also {
|
||||
if (!it.delete() || !it.mkdir()) {
|
||||
throw IllegalStateException("Unable to create a temporary folder")
|
||||
}
|
||||
}
|
||||
if (!copyDocumentTreeToFile(uri, temporary!!)) {
|
||||
throw IllegalStateException("Unable to read all folder contents")
|
||||
}
|
||||
if (destination.exists()) {
|
||||
if (!overwrite) {
|
||||
throw IllegalStateException("The destination already exists")
|
||||
}
|
||||
val backupFile = File.createTempFile(
|
||||
".rustdesk-import-backup-",
|
||||
".tmp",
|
||||
parent
|
||||
)
|
||||
if (!backupFile.delete()) {
|
||||
throw IllegalStateException("Unable to prepare the destination backup")
|
||||
}
|
||||
backup = backupFile
|
||||
if (!destination.renameTo(backupFile)) {
|
||||
throw IllegalStateException("Unable to replace the destination")
|
||||
}
|
||||
}
|
||||
if (!temporary!!.renameTo(destination)) {
|
||||
val destinationBackup = backup
|
||||
if (destinationBackup != null &&
|
||||
!destinationBackup.renameTo(destination)
|
||||
) {
|
||||
throw IllegalStateException(
|
||||
"Unable to move the imported folder and restore " +
|
||||
"the destination from $destinationBackup"
|
||||
)
|
||||
}
|
||||
throw IllegalStateException("Unable to move the imported folder")
|
||||
}
|
||||
temporary = null
|
||||
val destinationBackup = backup
|
||||
if (destinationBackup != null &&
|
||||
!destinationBackup.deleteRecursively()
|
||||
) {
|
||||
throw IllegalStateException(
|
||||
"Unable to remove the destination backup: $destinationBackup"
|
||||
)
|
||||
}
|
||||
backup = null
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to import directory", e)
|
||||
false
|
||||
} finally {
|
||||
temporary?.deleteRecursively()
|
||||
}
|
||||
runOnUiThread { result.success(ok) }
|
||||
}
|
||||
}
|
||||
}
|
||||
EXPORT_FILES -> {
|
||||
val paths = (call.arguments as? Map<*, *>)?.get("paths") as? List<*>
|
||||
if (paths.isNullOrEmpty()) {
|
||||
result.error("invalid_source", "The selected files are outside app-scoped storage", null)
|
||||
} else {
|
||||
val sources = paths.mapNotNull {
|
||||
(it as? String)?.let(::canonicalExportSource)
|
||||
}
|
||||
val rejected = paths.size - sources.size
|
||||
if (sources.isEmpty()) {
|
||||
result.success(mapOf("exported" to 0, "failed" to rejected))
|
||||
} else if (pendingPicker != null) {
|
||||
result.error("picker_in_progress", "Another document picker is already open", null)
|
||||
} else {
|
||||
pendingPicker = PendingPicker.ExportFiles(sources, rejected, result)
|
||||
try {
|
||||
startActivityForResult(
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
|
||||
putExtra(Intent.EXTRA_TITLE, "Select the destination folder")
|
||||
},
|
||||
REQ_EXPORT_FILES
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
pendingPicker = null
|
||||
result.error("picker_unavailable", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
GET_VALUE -> {
|
||||
if (call.arguments is String) {
|
||||
if (call.arguments == KEY_IS_SUPPORT_VOICE_CALL) {
|
||||
@@ -291,6 +654,228 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun canonicalAppScopedFile(path: String): File? {
|
||||
val file = runCatching { File(path).canonicalFile }.getOrNull() ?: return null
|
||||
val allowedRoots = listOfNotNull(filesDir, getExternalFilesDir(null)).mapNotNull {
|
||||
runCatching { it.canonicalFile }.getOrNull()
|
||||
}
|
||||
return file.takeIf { candidate ->
|
||||
allowedRoots.any { root ->
|
||||
candidate == root || candidate.path.startsWith(root.path + File.separator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun canonicalExportSource(path: String): File? {
|
||||
val original = File(path).absoluteFile
|
||||
val canonical = canonicalAppScopedFile(path) ?: return null
|
||||
return canonical.takeIf {
|
||||
original.path == canonical.path && (canonical.isFile || canonical.isDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapshotExportSource(source: File): ExportSource? {
|
||||
val safeSource = canonicalExportSource(source.path) ?: return null
|
||||
if (safeSource.isFile) return ExportSource(safeSource, null)
|
||||
val sourceChildren = safeSource.listFiles() ?: return null
|
||||
val children = ArrayList<ExportSource>(sourceChildren.size)
|
||||
for (child in sourceChildren) {
|
||||
val snapshot = snapshotExportSource(child) ?: return null
|
||||
children.add(snapshot)
|
||||
}
|
||||
return ExportSource(safeSource, children)
|
||||
}
|
||||
|
||||
private fun copyExportSourceToTree(
|
||||
treeUri: Uri,
|
||||
parentDocId: String,
|
||||
source: ExportSource
|
||||
): Boolean {
|
||||
val children = source.children
|
||||
return if (children == null) {
|
||||
copyFileToTree(treeUri, parentDocId, source.file)
|
||||
} else {
|
||||
copyDirToTree(treeUri, parentDocId, source)
|
||||
}
|
||||
}
|
||||
|
||||
private fun treeDisplayName(treeUri: Uri): String? {
|
||||
return try {
|
||||
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||
val docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, rootDocId)
|
||||
contentResolver.query(
|
||||
docUri,
|
||||
arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||
} catch (e: Exception) {
|
||||
Log.w(logTag, "Failed to read selected folder name", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyDocumentTreeToFile(treeUri: Uri, destinationDir: File): Boolean {
|
||||
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||
return copyChildrenToFile(treeUri, rootDocId, destinationDir)
|
||||
}
|
||||
|
||||
private fun copyChildrenToFile(
|
||||
treeUri: Uri,
|
||||
parentDocId: String,
|
||||
destinationDir: File
|
||||
): Boolean {
|
||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId)
|
||||
var ok = true
|
||||
val destinationNames = HashSet<String>()
|
||||
val cursor = contentResolver.query(childrenUri, childColumns, null, null, null)
|
||||
?: return false
|
||||
cursor.use {
|
||||
while (cursor.moveToNext()) {
|
||||
val docId = cursor.getString(0)
|
||||
val name = cursor.getString(1)
|
||||
val mime = cursor.getString(2)
|
||||
val docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)
|
||||
if (name != null && !destinationNames.add(name)) {
|
||||
ok = false
|
||||
continue
|
||||
}
|
||||
val destination = safeDestinationChild(destinationDir, name)
|
||||
if (destination == null || destination.exists()) {
|
||||
ok = false
|
||||
continue
|
||||
}
|
||||
if (mime == DocumentsContract.Document.MIME_TYPE_DIR) {
|
||||
if (!destination.mkdirs() && !destination.isDirectory) {
|
||||
ok = false
|
||||
continue
|
||||
}
|
||||
if (!copyChildrenToFile(treeUri, docId, destination)) {
|
||||
ok = false
|
||||
}
|
||||
} else if (!copyDocumentToFile(docUri, destination)) {
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
private fun safeDestinationChild(destinationDir: File, name: String?): File? {
|
||||
if (name.isNullOrEmpty() || name == "." || name == ".." ||
|
||||
name.indexOf('\u0000') >= 0 || name.contains('/') || name.contains('\\')) {
|
||||
return null
|
||||
}
|
||||
val parent = runCatching { destinationDir.canonicalFile }.getOrNull() ?: return null
|
||||
val child = runCatching { File(parent, name).canonicalFile }.getOrNull() ?: return null
|
||||
return child.takeIf { it.path.startsWith(parent.path + File.separator) }
|
||||
}
|
||||
|
||||
private fun copyDocumentToFile(uri: Uri, destination: File): Boolean {
|
||||
return try {
|
||||
destination.parentFile?.mkdirs()
|
||||
if (destination.exists() && !destination.delete()) {
|
||||
return false
|
||||
}
|
||||
contentResolver.openInputStream(uri)?.use { input ->
|
||||
FileOutputStream(destination).use { output -> input.copyTo(output) }
|
||||
} != null
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to copy document to $destination", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyFileToTree(treeUri: Uri, parentDocId: String, source: File): Boolean {
|
||||
val safeSource = canonicalExportSource(source.path)?.takeIf { it.isFile } ?: return false
|
||||
return try {
|
||||
val mime = MimeTypeMap.getSingleton()
|
||||
.getMimeTypeFromExtension(safeSource.extension.lowercase())
|
||||
?: "application/octet-stream"
|
||||
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocId)
|
||||
val docUri = DocumentsContract.createDocument(
|
||||
contentResolver,
|
||||
parentUri,
|
||||
mime,
|
||||
safeSource.name
|
||||
) ?: return false
|
||||
contentResolver.openOutputStream(docUri, "wt")?.use { output ->
|
||||
FileInputStream(safeSource).use { input -> input.copyTo(output) }
|
||||
} ?: return false
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to export file $safeSource", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyDirToTree(
|
||||
treeUri: Uri,
|
||||
parentDocId: String,
|
||||
source: ExportSource
|
||||
): Boolean {
|
||||
val children = source.children ?: return false
|
||||
val safeSource = canonicalExportSource(source.file.path)?.takeIf { it.isDirectory }
|
||||
?: return false
|
||||
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocId)
|
||||
var dirDocId = findChildDocId(treeUri, parentDocId, safeSource.name)
|
||||
if (dirDocId == null) {
|
||||
dirDocId = try {
|
||||
DocumentsContract.createDocument(
|
||||
contentResolver,
|
||||
parentUri,
|
||||
DocumentsContract.Document.MIME_TYPE_DIR,
|
||||
safeSource.name
|
||||
)?.let { DocumentsContract.getDocumentId(it) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(logTag, "Failed to create folder ${safeSource.name}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
if (dirDocId == null) return false
|
||||
|
||||
var ok = true
|
||||
children.forEach { child ->
|
||||
val childOk = copyExportSourceToTree(treeUri, dirDocId, child)
|
||||
if (!childOk) ok = false
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
private fun findChildDocId(treeUri: Uri, parentDocId: String, name: String): String? {
|
||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId)
|
||||
val cursor = contentResolver.query(childrenUri, childColumns, null, null, null)
|
||||
?: throw IllegalStateException("Unable to query destination folder")
|
||||
cursor.use {
|
||||
while (cursor.moveToNext()) {
|
||||
if (cursor.getString(1) == name &&
|
||||
cursor.getString(2) == DocumentsContract.Document.MIME_TYPE_DIR
|
||||
) {
|
||||
return cursor.getString(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private val childColumns = arrayOf(
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
DocumentsContract.Document.COLUMN_MIME_TYPE
|
||||
)
|
||||
|
||||
private fun displayName(uri: Uri): String? {
|
||||
return try {
|
||||
contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) cursor.getString(0) else null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(logTag, "Failed to read selected document name", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun setCodecInfo() {
|
||||
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
|
||||
val codecs = codecList.codecInfos
|
||||
|
||||
@@ -214,6 +214,17 @@ class MainService : Service() {
|
||||
|
||||
// video
|
||||
private var mediaProjection: MediaProjection? = null
|
||||
private val mediaProjectionCallback = object : MediaProjection.Callback() {
|
||||
override fun onStop() {
|
||||
Log.d(logTag, "MediaProjection stopped")
|
||||
stopCapture()
|
||||
virtualDisplay?.release()
|
||||
virtualDisplay = null
|
||||
releaseMediaProjection()
|
||||
_isReady = false
|
||||
checkMediaPermission()
|
||||
}
|
||||
}
|
||||
private var surface: Surface? = null
|
||||
private val sendVP9Thread = Executors.newSingleThreadExecutor()
|
||||
private var videoEncoder: MediaCodec? = null
|
||||
@@ -243,7 +254,9 @@ class MainService : Service() {
|
||||
// keep the config dir same with flutter
|
||||
val prefs = applicationContext.getSharedPreferences(KEY_SHARED_PREFERENCES, FlutterActivity.MODE_PRIVATE)
|
||||
val configPath = prefs.getString(KEY_APP_DIR_CONFIG_PATH, "") ?: ""
|
||||
FFI.startServer(configPath, "")
|
||||
val homePath = applicationContext.getExternalFilesDir(null)?.absolutePath
|
||||
?: applicationContext.filesDir.absolutePath
|
||||
FFI.startServer(configPath, homePath, "")
|
||||
|
||||
createForegroundNotification()
|
||||
}
|
||||
@@ -347,10 +360,13 @@ class MainService : Service() {
|
||||
getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
|
||||
intent.getParcelableExtra<Intent>(EXT_MEDIA_PROJECTION_RES_INTENT)?.let {
|
||||
mediaProjection =
|
||||
releaseMediaProjection()
|
||||
val projection =
|
||||
mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, it)
|
||||
checkMediaPermission()
|
||||
projection.registerCallback(mediaProjectionCallback, Handler(Looper.getMainLooper()))
|
||||
mediaProjection = projection
|
||||
_isReady = true
|
||||
checkMediaPermission()
|
||||
} ?: let {
|
||||
Log.d(logTag, "getParcelableExtra intent null, invoke requestMediaProjection")
|
||||
requestMediaProjection()
|
||||
@@ -372,6 +388,12 @@ class MainService : Service() {
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun releaseMediaProjection() {
|
||||
mediaProjection?.unregisterCallback(mediaProjectionCallback)
|
||||
mediaProjection?.stop()
|
||||
mediaProjection = null
|
||||
}
|
||||
|
||||
@SuppressLint("WrongConstant")
|
||||
private fun createSurface(): Surface? {
|
||||
return if (useVP9) {
|
||||
@@ -496,7 +518,7 @@ class MainService : Service() {
|
||||
virtualDisplay = null
|
||||
}
|
||||
|
||||
mediaProjection = null
|
||||
releaseMediaProjection()
|
||||
checkMediaPermission()
|
||||
stopForeground(true)
|
||||
stopService(Intent(this, FloatingWindowService::class.java))
|
||||
|
||||
@@ -38,6 +38,10 @@ const val EXT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY"
|
||||
// Activity requestCode
|
||||
const val REQ_INVOKE_PERMISSION_ACTIVITY_MEDIA_PROJECTION = 101
|
||||
const val REQ_REQUEST_MEDIA_PROJECTION = 201
|
||||
const val REQ_EXPORT_FILE = 301
|
||||
const val REQ_IMPORT_FILES = 302
|
||||
const val REQ_IMPORT_DIRECTORY = 303
|
||||
const val REQ_EXPORT_FILES = 304
|
||||
|
||||
// Activity responseCode
|
||||
const val RES_FAILED = -100
|
||||
@@ -47,6 +51,12 @@ const val START_ACTION = "start_action"
|
||||
const val GET_START_ON_BOOT_OPT = "get_start_on_boot_opt"
|
||||
const val SET_START_ON_BOOT_OPT = "set_start_on_boot_opt"
|
||||
const val SYNC_APP_DIR_CONFIG_PATH = "sync_app_dir"
|
||||
const val PICK_IMPORT_FILES = "pick_import_files"
|
||||
const val IMPORT_FILE = "import_file"
|
||||
const val EXPORT_FILE = "export_file"
|
||||
const val PICK_IMPORT_DIRECTORY = "pick_import_directory"
|
||||
const val IMPORT_DIRECTORY = "import_directory"
|
||||
const val EXPORT_FILES = "export_files"
|
||||
const val GET_VALUE = "get_value"
|
||||
|
||||
const val KEY_IS_SUPPORT_VOICE_CALL = "KEY_IS_SUPPORT_VOICE_CALL"
|
||||
|
||||
@@ -15,7 +15,7 @@ object FFI {
|
||||
external fun init(ctx: Context)
|
||||
external fun onAppStart(ctx: Context)
|
||||
external fun setClipboardManager(clipboardManager: RdClipboardManager)
|
||||
external fun startServer(app_dir: String, custom_client_config: String)
|
||||
external fun startServer(app_dir: String, home_dir: String, custom_client_config: String)
|
||||
external fun startService()
|
||||
external fun onVideoFrameUpdate(buf: ByteBuffer)
|
||||
external fun onAudioFrameUpdate(buf: ByteBuffer)
|
||||
|
||||
@@ -1519,13 +1519,6 @@ class AndroidPermissionManager {
|
||||
static Timer? _timer;
|
||||
static var _current = "";
|
||||
|
||||
static bool isWaitingFile() {
|
||||
if (_completer != null) {
|
||||
return !_completer!.isCompleted && _current == kManageExternalStorage;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<bool> check(String type) {
|
||||
if (isDesktop || isWeb) {
|
||||
return Future.value(true);
|
||||
@@ -2634,13 +2627,6 @@ connect(BuildContext context, String id,
|
||||
}
|
||||
} else {
|
||||
if (isFileTransfer) {
|
||||
if (isAndroid) {
|
||||
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
|
||||
if (!await AndroidPermissionManager.request(kManageExternalStorage)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isWeb) {
|
||||
Navigator.push(
|
||||
context,
|
||||
|
||||
@@ -439,7 +439,6 @@ const kActionApplicationDetailsSettings =
|
||||
const kActionAccessibilitySettings = "android.settings.ACCESSIBILITY_SETTINGS";
|
||||
|
||||
const kRecordAudio = "android.permission.RECORD_AUDIO";
|
||||
const kManageExternalStorage = "android.permission.MANAGE_EXTERNAL_STORAGE";
|
||||
const kRequestIgnoreBatteryOptimizations =
|
||||
"android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS";
|
||||
const kSystemAlertWindow = "android.permission.SYSTEM_ALERT_WINDOW";
|
||||
@@ -451,6 +450,12 @@ class AndroidChannel {
|
||||
static final kGetStartOnBootOpt = "get_start_on_boot_opt";
|
||||
static final kSetStartOnBootOpt = "set_start_on_boot_opt";
|
||||
static final kSyncAppDirConfigPath = "sync_app_dir";
|
||||
static final kPickImportFiles = "pick_import_files";
|
||||
static final kImportFile = "import_file";
|
||||
static final kExportFile = "export_file";
|
||||
static final kPickImportDirectory = "pick_import_directory";
|
||||
static final kImportDirectory = "import_directory";
|
||||
static final kExportFiles = "export_files";
|
||||
}
|
||||
|
||||
/// flutter/packages/flutter/lib/src/services/keyboard_key.dart -> _keyLabels
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_breadcrumb/flutter_breadcrumb.dart';
|
||||
@@ -8,6 +9,7 @@ import 'package:toggle_switch/toggle_switch.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../common/widgets/dialog.dart';
|
||||
import '../../consts.dart';
|
||||
|
||||
class FileManagerPage extends StatefulWidget {
|
||||
FileManagerPage(
|
||||
@@ -73,6 +75,173 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
DirectoryOptions get currentOptions => currentFileController.options.value;
|
||||
final _uniqueKey = UniqueKey();
|
||||
|
||||
Future<T> _runAndroidDocumentPicker<T>(Future<T> Function() action) async {
|
||||
gFFI.ffiModel.beginAndroidDocumentPicker();
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
gFFI.ffiModel.endAndroidDocumentPicker();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importFiles() async {
|
||||
var imported = 0;
|
||||
var failed = false;
|
||||
final importController = currentFileController;
|
||||
final importDirectory = currentDir.path;
|
||||
final importIsWindows = currentOptions.isWindows;
|
||||
try {
|
||||
final selectedFiles = await _runAndroidDocumentPicker(() =>
|
||||
gFFI.invokeMethodWithResult<List<dynamic>>(
|
||||
AndroidChannel.kPickImportFiles));
|
||||
if (selectedFiles == null || selectedFiles.isEmpty) return;
|
||||
|
||||
for (final selected in selectedFiles) {
|
||||
final uri = (selected as Map<dynamic, dynamic>)['uri'] as String?;
|
||||
final selectedName = selected['name'] as String?;
|
||||
final name = selectedName?.replaceAll('\\', '/').split('/').last;
|
||||
if (uri == null ||
|
||||
name == null ||
|
||||
!PathUtil.validName(name, importIsWindows)) {
|
||||
failed = true;
|
||||
continue;
|
||||
}
|
||||
final destination =
|
||||
PathUtil.join(importDirectory, name, importIsWindows);
|
||||
var overwrite = false;
|
||||
if (await File(destination).exists()) {
|
||||
final overwriteResult = await model.showFileConfirmDialog(
|
||||
translate('Overwrite'), destination, false, false);
|
||||
if (overwriteResult == false) break;
|
||||
if (overwriteResult != true) continue;
|
||||
overwrite = true;
|
||||
}
|
||||
try {
|
||||
final success = await gFFI.invokeMethod(
|
||||
AndroidChannel.kImportFile,
|
||||
{'uri': uri, 'path': destination, 'overwrite': overwrite});
|
||||
if (success == true) {
|
||||
imported++;
|
||||
} else {
|
||||
failed = true;
|
||||
}
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
debugPrint('Failed to import $name: $e');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
debugPrint('Failed to select files for import: $e');
|
||||
}
|
||||
await importController.refresh();
|
||||
if (failed) {
|
||||
showToast(translate('Failed'));
|
||||
} else if (imported > 0) {
|
||||
showToast(translate('Successful'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _exportFile(Entry entry) async {
|
||||
try {
|
||||
final exported = await _runAndroidDocumentPicker(() => gFFI
|
||||
.invokeMethod(AndroidChannel.kExportFile, {'path': entry.path}));
|
||||
if (exported == true) {
|
||||
showToast(translate('Successful'));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to export ${entry.name}: $e');
|
||||
showToast(translate('Failed'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importFolder() async {
|
||||
final importController = currentFileController;
|
||||
final importDirectory = currentDir.path;
|
||||
final importIsWindows = currentOptions.isWindows;
|
||||
try {
|
||||
final picked = await _runAndroidDocumentPicker(() =>
|
||||
gFFI.invokeMethodWithResult<Map<dynamic, dynamic>>(
|
||||
AndroidChannel.kPickImportDirectory));
|
||||
if (picked == null || picked.isEmpty) return;
|
||||
final uri = picked['uri'] as String?;
|
||||
final name =
|
||||
(picked['name'] as String?)?.replaceAll('\\', '/').split('/').last;
|
||||
if (uri == null ||
|
||||
name == null ||
|
||||
name == '.' ||
|
||||
name == '..' ||
|
||||
!PathUtil.validName(name, importIsWindows)) {
|
||||
showToast(translate('Failed'));
|
||||
return;
|
||||
}
|
||||
final destination = PathUtil.join(importDirectory, name, importIsWindows);
|
||||
final destinationType = await FileSystemEntity.type(destination);
|
||||
var overwrite = false;
|
||||
if (destinationType == FileSystemEntityType.directory) {
|
||||
final overwriteResult = await model.showFileConfirmDialog(
|
||||
translate('Overwrite'), destination, false, false);
|
||||
if (overwriteResult != true) return;
|
||||
overwrite = true;
|
||||
} else if (destinationType != FileSystemEntityType.notFound) {
|
||||
showToast(translate('Failed'));
|
||||
return;
|
||||
}
|
||||
final success = await gFFI.invokeMethod(AndroidChannel.kImportDirectory,
|
||||
{'uri': uri, 'path': destination, 'overwrite': overwrite});
|
||||
if (success == true) {
|
||||
showToast(translate('Successful'));
|
||||
} else {
|
||||
showToast(translate('Failed'));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to import folder: $e');
|
||||
showToast(translate('Failed'));
|
||||
}
|
||||
await importController.refresh();
|
||||
}
|
||||
|
||||
Future<void> _exportItems(SelectedItems items) async {
|
||||
await _exportPaths(items.items.map((e) => e.path));
|
||||
}
|
||||
|
||||
Future<void> _exportLogs() async {
|
||||
final home = currentFileController.homePath;
|
||||
if (home.isEmpty) {
|
||||
showToast(translate('Failed'));
|
||||
return;
|
||||
}
|
||||
final appDir = PathUtil.join(home, appName, false);
|
||||
final paths = [
|
||||
PathUtil.join(appDir, 'Logs', false),
|
||||
PathUtil.join(appDir, 'ScreenRecord', false),
|
||||
].where((p) => File(p).existsSync() || Directory(p).existsSync()).toList();
|
||||
if (paths.isEmpty) {
|
||||
showToast(translate('Failed'));
|
||||
return;
|
||||
}
|
||||
await _exportPaths(paths);
|
||||
}
|
||||
|
||||
Future<void> _exportPaths(Iterable<String> paths) async {
|
||||
try {
|
||||
final result = await _runAndroidDocumentPicker(() =>
|
||||
gFFI.invokeMethodWithResult<Map<dynamic, dynamic>>(
|
||||
AndroidChannel.kExportFiles, {'paths': paths.toList()}));
|
||||
if (result == null) return;
|
||||
final exported = result['exported'] as int? ?? 0;
|
||||
final failed = result['failed'] as int? ?? 0;
|
||||
if (failed > 0) {
|
||||
showToast(translate('Failed'));
|
||||
} else if (exported > 0) {
|
||||
showToast(translate('Successful'));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Failed to export paths: $e');
|
||||
showToast(translate('Failed'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -159,6 +328,45 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
),
|
||||
value: "refresh",
|
||||
),
|
||||
if (isAndroid)
|
||||
PopupMenuItem(
|
||||
enabled: showLocal && currentDir.path.isNotEmpty,
|
||||
value: "import",
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.add_to_drive,
|
||||
color: Theme.of(context).iconTheme.color),
|
||||
SizedBox(width: 5),
|
||||
Text(translate("Add"))
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isAndroid)
|
||||
PopupMenuItem(
|
||||
enabled: showLocal && currentDir.path.isNotEmpty,
|
||||
value: "import_folder",
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.create_new_folder_outlined,
|
||||
color: Theme.of(context).iconTheme.color),
|
||||
SizedBox(width: 5),
|
||||
Text(translate("Import Folder"))
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isAndroid)
|
||||
PopupMenuItem(
|
||||
enabled: showLocal && currentDir.path.isNotEmpty,
|
||||
value: "export_logs",
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.article_outlined,
|
||||
color: Theme.of(context).iconTheme.color),
|
||||
SizedBox(width: 5),
|
||||
Text(translate("Export Logs"))
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
enabled: currentDir.path != "/",
|
||||
child: Row(
|
||||
@@ -203,6 +411,12 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
onSelected: (v) {
|
||||
if (v == "refresh") {
|
||||
currentFileController.refresh();
|
||||
} else if (v == "import") {
|
||||
_importFiles();
|
||||
} else if (v == "import_folder") {
|
||||
_importFolder();
|
||||
} else if (v == "export_logs") {
|
||||
_exportLogs();
|
||||
} else if (v == "select") {
|
||||
model.localController.selectedItems.clear();
|
||||
model.remoteController.selectedItems.clear();
|
||||
@@ -300,6 +514,24 @@ class _FileManagerPageState extends State<FileManagerPage> {
|
||||
setState(() {});
|
||||
},
|
||||
actions: [
|
||||
if (isAndroid &&
|
||||
selectedItems?.isLocal == true &&
|
||||
selectedItems?.items.isNotEmpty == true) ...[
|
||||
if (selectedItems!.items.length == 1 &&
|
||||
selectedItems!.items.single.isFile)
|
||||
IconButton(
|
||||
tooltip: translate("Save as"),
|
||||
icon: Icon(Icons.save_alt),
|
||||
onPressed: () =>
|
||||
_exportFile(selectedItems!.items.single),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
tooltip: translate("Export"),
|
||||
icon: Icon(Icons.drive_folder_upload),
|
||||
onPressed: () => _exportItems(selectedItems!),
|
||||
),
|
||||
],
|
||||
IconButton(
|
||||
icon: Icon(Icons.compare_arrows),
|
||||
onPressed: () => setState(() => showLocal = !showLocal),
|
||||
|
||||
@@ -225,12 +225,6 @@ class _ServerPageState extends State<ServerPage> {
|
||||
|
||||
void checkService() async {
|
||||
gFFI.invokeMethod("check_service");
|
||||
// for Android 10/11, request MANAGE_EXTERNAL_STORAGE permission from system setting page
|
||||
if (AndroidPermissionManager.isWaitingFile() && !gFFI.serverModel.fileOk) {
|
||||
AndroidPermissionManager.complete(kManageExternalStorage,
|
||||
await AndroidPermissionManager.check(kManageExternalStorage));
|
||||
debugPrint("file permission finished");
|
||||
}
|
||||
}
|
||||
|
||||
class ServiceNotRunningNotification extends StatelessWidget {
|
||||
|
||||
@@ -381,6 +381,14 @@ class FileController {
|
||||
void set homePath(String path) => options.value.home = path;
|
||||
OverlayDialogManager? get dialogManager => rootState.target?.dialogManager;
|
||||
|
||||
bool _isPathAllowed(String candidate) {
|
||||
if (!isAndroid || !isLocal) return true;
|
||||
if (homePath.isEmpty || candidate.isEmpty) return false;
|
||||
final home = PathUtil.posixContext.normalize(homePath);
|
||||
final target = PathUtil.posixContext.normalize(candidate);
|
||||
return target == home || PathUtil.posixContext.isWithin(home, target);
|
||||
}
|
||||
|
||||
String get shortPath {
|
||||
final dirPath = directory.value.path;
|
||||
if (dirPath.startsWith(homePath)) {
|
||||
@@ -414,8 +422,13 @@ class FileController {
|
||||
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
|
||||
final savedDir = (await bind.sessionGetPeerOption(
|
||||
var savedDir = (await bind.sessionGetPeerOption(
|
||||
sessionId: sessionId, name: isLocal ? "local_dir" : "remote_dir"));
|
||||
if (savedDir.isNotEmpty && !_isPathAllowed(savedDir)) {
|
||||
savedDir = options.value.home;
|
||||
await bind.sessionPeerOption(
|
||||
sessionId: sessionId, name: "local_dir", value: savedDir);
|
||||
}
|
||||
Future<bool> tryOpenReadyDirs() async {
|
||||
final dirs = <String>{
|
||||
if (directory.value.path.isNotEmpty) directory.value.path,
|
||||
@@ -485,6 +498,9 @@ class FileController {
|
||||
}
|
||||
|
||||
Future<bool> _openDirectoryPath(String path, {bool isBack = false}) async {
|
||||
if (!_isPathAllowed(path)) {
|
||||
return false;
|
||||
}
|
||||
if (!isBack) {
|
||||
pushHistory();
|
||||
}
|
||||
@@ -504,6 +520,7 @@ class FileController {
|
||||
return true;
|
||||
}
|
||||
fd.format(isWindows, sort: sortBy.value);
|
||||
selectedItems.reconcile(fd.entries);
|
||||
directory.value = fd;
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -550,6 +567,9 @@ class FileController {
|
||||
final isWindows = options.value.isWindows;
|
||||
final dirPath = directory.value.path;
|
||||
var parent = PathUtil.dirname(dirPath, isWindows);
|
||||
if (!_isPathAllowed(parent)) {
|
||||
return true;
|
||||
}
|
||||
// specially for C:\, D:\, goto '/'
|
||||
if (parent == dirPath && isWindows) {
|
||||
return await _openDirectoryPath('/', isBack: isBack);
|
||||
@@ -1885,7 +1905,7 @@ class PathUtil {
|
||||
}
|
||||
|
||||
static bool validName(String name, bool isWindows) {
|
||||
final unixFileNamePattern = RegExp(r'^[^/\0]+$');
|
||||
final unixFileNamePattern = RegExp(r'^[^/\x00]+$');
|
||||
final windowsFileNamePattern = RegExp(r'^[^<>:"/\\|?*]+$');
|
||||
final reg = isWindows ? windowsFileNamePattern : unixFileNamePattern;
|
||||
return reg.hasMatch(name);
|
||||
@@ -1928,6 +1948,21 @@ class SelectedItems {
|
||||
items.clear();
|
||||
}
|
||||
|
||||
void reconcile(List<Entry> entries) {
|
||||
if (items.isEmpty) return;
|
||||
final currentByPath = {for (final entry in entries) entry.path: entry};
|
||||
final reconciled = <Entry>[];
|
||||
for (final item in items) {
|
||||
final current = currentByPath[item.path];
|
||||
if (current != null && current.entryType == item.entryType) {
|
||||
reconciled.add(current);
|
||||
}
|
||||
}
|
||||
items
|
||||
..clear()
|
||||
..addAll(reconciled);
|
||||
}
|
||||
|
||||
void selectAll(List<Entry> entries) {
|
||||
items.clear();
|
||||
items.addAll(entries);
|
||||
|
||||
@@ -124,6 +124,8 @@ class FfiModel with ChangeNotifier {
|
||||
Timer? _restartReconnectDelayTimer;
|
||||
var _reconnects = 1;
|
||||
DateTime? _offlineReconnectStartTime;
|
||||
bool _androidDocumentPickerActive = false;
|
||||
bool _androidDocumentPickerInterruptedConnection = false;
|
||||
bool _viewOnly = false;
|
||||
bool _showMyCursor = false;
|
||||
WeakReference<FFI> parent;
|
||||
@@ -255,6 +257,8 @@ class FfiModel with ChangeNotifier {
|
||||
_inputBlocked = false;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_androidDocumentPickerActive = false;
|
||||
_androidDocumentPickerInterruptedConnection = false;
|
||||
resetRestartReconnectState();
|
||||
clearPermissions();
|
||||
waitForImageTimer?.cancel();
|
||||
@@ -892,6 +896,13 @@ class FfiModel with ChangeNotifier {
|
||||
final text = evt['text'];
|
||||
final link = evt['link'];
|
||||
|
||||
if (isAndroid &&
|
||||
_androidDocumentPickerActive &&
|
||||
title == 'Connection Error') {
|
||||
_androidDocumentPickerInterruptedConnection = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable relative mouse mode on any error-type message to ensure cursor is released.
|
||||
// This includes connection errors, session-ending messages, elevation errors, etc.
|
||||
// Safety: releasing pointer lock on errors prevents the user from being stuck.
|
||||
@@ -968,6 +979,23 @@ class FfiModel with ChangeNotifier {
|
||||
_restartReconnectDelayTimer = null;
|
||||
}
|
||||
|
||||
void beginAndroidDocumentPicker() {
|
||||
if (!isAndroid) return;
|
||||
_androidDocumentPickerActive = true;
|
||||
_androidDocumentPickerInterruptedConnection = false;
|
||||
}
|
||||
|
||||
void endAndroidDocumentPicker() {
|
||||
if (!isAndroid) return;
|
||||
_androidDocumentPickerActive = false;
|
||||
if (!_androidDocumentPickerInterruptedConnection ||
|
||||
parent.target?.closed == true) {
|
||||
return;
|
||||
}
|
||||
_androidDocumentPickerInterruptedConnection = false;
|
||||
reconnect(parent.target!.dialogManager, sessionId, false);
|
||||
}
|
||||
|
||||
/// Auto-retry check for "Remote desktop is offline" error.
|
||||
/// returns true to auto-retry, false otherwise.
|
||||
bool shouldAutoRetryOnOffline(
|
||||
@@ -4060,6 +4088,11 @@ class FFI {
|
||||
return await platformFFI.invokeMethod(method, arguments);
|
||||
}
|
||||
|
||||
Future<T?> invokeMethodWithResult<T>(String method,
|
||||
[dynamic arguments]) async {
|
||||
return await platformFFI.invokeMethodWithResult<T>(method, arguments);
|
||||
}
|
||||
|
||||
// Terminal model management
|
||||
void registerTerminalModel(int terminalId, TerminalModel model) {
|
||||
debugPrint('[FFI] Registering terminal model for terminal $terminalId');
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:external_path/external_path.dart';
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -171,8 +170,10 @@ class PlatformFFI {
|
||||
_startListenEvent(_ffiBind); // global event
|
||||
try {
|
||||
if (isAndroid) {
|
||||
// only support for android
|
||||
_homeDir = (await ExternalPath.getExternalStorageDirectories())[0];
|
||||
// Android file transfer uses app-specific storage. User-selected
|
||||
// files enter and leave this workspace through the system picker.
|
||||
_homeDir = (await getExternalStorageDirectory())?.path ??
|
||||
(await getApplicationSupportDirectory()).path;
|
||||
} else if (isIOS) {
|
||||
// The previous code was `_homeDir = (await getDownloadsDirectory())?.path ?? '';`,
|
||||
// which provided the `downloads` path in the sandbox.
|
||||
@@ -306,6 +307,12 @@ class PlatformFFI {
|
||||
return await _toAndroidChannel.invokeMethod(method, arguments);
|
||||
}
|
||||
|
||||
Future<T?> invokeMethodWithResult<T>(String method,
|
||||
[dynamic arguments]) async {
|
||||
if (!isAndroid) return null;
|
||||
return await _toAndroidChannel.invokeMethod<T>(method, arguments);
|
||||
}
|
||||
|
||||
void syncAndroidServiceAppDirConfigPath() {
|
||||
invokeMethod(AndroidChannel.kSyncAppDirConfigPath, _dir);
|
||||
}
|
||||
|
||||
@@ -210,15 +210,10 @@ class ServerModel with ChangeNotifier {
|
||||
_audioOk = audioOption != 'N';
|
||||
}
|
||||
|
||||
// file
|
||||
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
|
||||
_fileOk = false;
|
||||
bind.mainSetOption(key: kOptionEnableFileTransfer, value: "N");
|
||||
} else {
|
||||
final fileOption =
|
||||
await bind.mainGetOption(key: kOptionEnableFileTransfer);
|
||||
_fileOk = fileOption != 'N';
|
||||
}
|
||||
// Android file transfer is confined to app-specific storage. Files enter
|
||||
// and leave the workspace through Android's system document picker.
|
||||
final fileOption = await bind.mainGetOption(key: kOptionEnableFileTransfer);
|
||||
_fileOk = fileOption != 'N';
|
||||
|
||||
// clipboard
|
||||
final clipOption = await bind.mainGetOption(key: kOptionEnableClipboard);
|
||||
@@ -319,16 +314,6 @@ class ServerModel with ChangeNotifier {
|
||||
if (clients.any((c) => !c.disconnected)) {
|
||||
await showClientsMayNotBeChangedAlert(parent.target);
|
||||
}
|
||||
if (!_fileOk &&
|
||||
!await AndroidPermissionManager.check(kManageExternalStorage)) {
|
||||
final res =
|
||||
await AndroidPermissionManager.request(kManageExternalStorage);
|
||||
if (!res) {
|
||||
showToast(translate('Failed'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_fileOk = !_fileOk;
|
||||
bind.mainSetOption(
|
||||
key: kOptionEnableFileTransfer,
|
||||
@@ -418,9 +403,6 @@ class ServerModel with ChangeNotifier {
|
||||
if (bind.mainGetLocalOption(key: kOptionDisableFloatingWindow) != 'Y') {
|
||||
await checkFloatingWindowPermission();
|
||||
}
|
||||
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
|
||||
await AndroidPermissionManager.request(kManageExternalStorage);
|
||||
}
|
||||
final res = await parent.target?.dialogManager
|
||||
.show<bool>((setState, close, context) {
|
||||
submit() => close(true);
|
||||
|
||||
@@ -251,6 +251,11 @@ class PlatformFFI {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<T?> invokeMethodWithResult<T>(String method,
|
||||
[dynamic arguments]) async {
|
||||
return null;
|
||||
}
|
||||
|
||||
// just for compilation
|
||||
void syncAndroidServiceAppDirConfigPath() {}
|
||||
|
||||
|
||||
@@ -409,14 +409,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.0.1"
|
||||
external_path:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: external_path
|
||||
sha256: "2095c626fbbefe70d5a4afc9b1137172a68ee2c276e51c3c1283394485bea8f4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
ffi:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -29,7 +29,6 @@ dependencies:
|
||||
|
||||
ffi: ^2.1.0
|
||||
path_provider: ^2.1.1
|
||||
external_path: ^1.0.3
|
||||
provider: ^6.0.5
|
||||
tuple: ^2.0.0
|
||||
wakelock_plus: ^1.1.3
|
||||
|
||||
Reference in New Issue
Block a user