From d4b06a6c5ca36596970d7c8f087687be3c3772f7 Mon Sep 17 00:00:00 2001 From: Michael Clark <104532890+michaeljclarkk@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:29:51 +1000 Subject: [PATCH] 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 * 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 * 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 * 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 --------- Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> Signed-off-by: fufesou Co-authored-by: fufesou --- .../android/app/src/main/AndroidManifest.xml | 9 +- .../com/carriez/flutter_hbb/MainActivity.kt | 585 ++++++++++++++++++ .../com/carriez/flutter_hbb/MainService.kt | 30 +- .../kotlin/com/carriez/flutter_hbb/common.kt | 10 + flutter/android/app/src/main/kotlin/ffi.kt | 2 +- flutter/lib/common.dart | 14 - flutter/lib/consts.dart | 7 +- .../lib/mobile/pages/file_manager_page.dart | 232 +++++++ flutter/lib/mobile/pages/server_page.dart | 6 - flutter/lib/models/file_model.dart | 39 +- flutter/lib/models/model.dart | 33 + flutter/lib/models/native_model.dart | 13 +- flutter/lib/models/server_model.dart | 26 +- flutter/lib/models/web_model.dart | 5 + flutter/pubspec.lock | 8 - flutter/pubspec.yaml | 1 - src/common.rs | 55 ++ src/flutter_ffi.rs | 4 + src/lang/ar.rs | 3 + src/lang/be.rs | 3 + src/lang/bg.rs | 3 + src/lang/ca.rs | 3 + src/lang/cn.rs | 3 + src/lang/cs.rs | 3 + src/lang/da.rs | 3 + src/lang/de.rs | 3 + src/lang/el.rs | 3 + src/lang/eo.rs | 3 + src/lang/es.rs | 3 + src/lang/et.rs | 3 + src/lang/eu.rs | 3 + src/lang/fa.rs | 3 + src/lang/fi.rs | 3 + src/lang/fr.rs | 3 + src/lang/ge.rs | 3 + src/lang/gu.rs | 3 + src/lang/he.rs | 3 + src/lang/hi.rs | 3 + src/lang/hr.rs | 3 + src/lang/hu.rs | 3 + src/lang/id.rs | 3 + src/lang/it.rs | 3 + src/lang/ja.rs | 3 + src/lang/ko.rs | 3 + src/lang/kz.rs | 3 + src/lang/lt.rs | 3 + src/lang/lv.rs | 3 + src/lang/ml.rs | 3 + src/lang/nb.rs | 3 + src/lang/nl.rs | 3 + src/lang/pl.rs | 3 + src/lang/pt_PT.rs | 3 + src/lang/ptbr.rs | 3 + src/lang/ro.rs | 3 + src/lang/ru.rs | 3 + src/lang/sc.rs | 3 + src/lang/sk.rs | 3 + src/lang/sl.rs | 3 + src/lang/sq.rs | 3 + src/lang/sr.rs | 3 + src/lang/sv.rs | 3 + src/lang/ta.rs | 3 + src/lang/template.rs | 3 + src/lang/th.rs | 3 + src/lang/tr.rs | 3 + src/lang/tw.rs | 3 + src/lang/uk.rs | 3 + src/lang/vi.rs | 3 + src/server/connection.rs | 91 ++- src/ui_cm_interface.rs | 55 ++ 70 files changed, 1304 insertions(+), 71 deletions(-) diff --git a/flutter/android/app/src/main/AndroidManifest.xml b/flutter/android/app/src/main/AndroidManifest.xml index f4788af4c..2d9616a6c 100644 --- a/flutter/android/app/src/main/AndroidManifest.xml +++ b/flutter/android/app/src/main/AndroidManifest.xml @@ -1,15 +1,17 @@ - + + + - - + @@ -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"> diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt index 7274085fd..02cec3c25 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt @@ -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, + val rejected: Int, + val result: MethodChannel.Result + ) : PendingPicker() + } + + private data class ExportSource( + val file: File, + val children: List? + ) + + 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>()) + return + } + + val uris = linkedSetOf() + 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(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() + 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 diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt index b03b63844..4648b9adc 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt @@ -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(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)) diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/common.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/common.kt index 514d493b9..2923cad9f 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/common.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/common.kt @@ -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" diff --git a/flutter/android/app/src/main/kotlin/ffi.kt b/flutter/android/app/src/main/kotlin/ffi.kt index 89e3dc046..02e6606ae 100644 --- a/flutter/android/app/src/main/kotlin/ffi.kt +++ b/flutter/android/app/src/main/kotlin/ffi.kt @@ -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) diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index 93c7a4d4b..25eed4259 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -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 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, diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 10459e782..ca0bd523f 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -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 diff --git a/flutter/lib/mobile/pages/file_manager_page.dart b/flutter/lib/mobile/pages/file_manager_page.dart index 982a4c805..e389bdf6c 100644 --- a/flutter/lib/mobile/pages/file_manager_page.dart +++ b/flutter/lib/mobile/pages/file_manager_page.dart @@ -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 { DirectoryOptions get currentOptions => currentFileController.options.value; final _uniqueKey = UniqueKey(); + Future _runAndroidDocumentPicker(Future Function() action) async { + gFFI.ffiModel.beginAndroidDocumentPicker(); + try { + return await action(); + } finally { + gFFI.ffiModel.endAndroidDocumentPicker(); + } + } + + Future _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>( + AndroidChannel.kPickImportFiles)); + if (selectedFiles == null || selectedFiles.isEmpty) return; + + for (final selected in selectedFiles) { + final uri = (selected as Map)['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 _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 _importFolder() async { + final importController = currentFileController; + final importDirectory = currentDir.path; + final importIsWindows = currentOptions.isWindows; + try { + final picked = await _runAndroidDocumentPicker(() => + gFFI.invokeMethodWithResult>( + 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 _exportItems(SelectedItems items) async { + await _exportPaths(items.items.map((e) => e.path)); + } + + Future _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 _exportPaths(Iterable paths) async { + try { + final result = await _runAndroidDocumentPicker(() => + gFFI.invokeMethodWithResult>( + 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 { ), 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 { 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 { 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), diff --git a/flutter/lib/mobile/pages/server_page.dart b/flutter/lib/mobile/pages/server_page.dart index cd3f97a53..d61cf70b8 100644 --- a/flutter/lib/mobile/pages/server_page.dart +++ b/flutter/lib/mobile/pages/server_page.dart @@ -225,12 +225,6 @@ class _ServerPageState extends State { 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 { diff --git a/flutter/lib/models/file_model.dart b/flutter/lib/models/file_model.dart index 22bf1eab6..26396bce5 100644 --- a/flutter/lib/models/file_model.dart +++ b/flutter/lib/models/file_model.dart @@ -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 tryOpenReadyDirs() async { final dirs = { if (directory.value.path.isNotEmpty) directory.value.path, @@ -485,6 +498,9 @@ class FileController { } Future _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 entries) { + if (items.isEmpty) return; + final currentByPath = {for (final entry in entries) entry.path: entry}; + final reconciled = []; + 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 entries) { items.clear(); items.addAll(entries); diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index bd564ba3b..e22782034 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -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 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 invokeMethodWithResult(String method, + [dynamic arguments]) async { + return await platformFFI.invokeMethodWithResult(method, arguments); + } + // Terminal model management void registerTerminalModel(int terminalId, TerminalModel model) { debugPrint('[FFI] Registering terminal model for terminal $terminalId'); diff --git a/flutter/lib/models/native_model.dart b/flutter/lib/models/native_model.dart index 8c3c5cf71..93f06d55d 100644 --- a/flutter/lib/models/native_model.dart +++ b/flutter/lib/models/native_model.dart @@ -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 invokeMethodWithResult(String method, + [dynamic arguments]) async { + if (!isAndroid) return null; + return await _toAndroidChannel.invokeMethod(method, arguments); + } + void syncAndroidServiceAppDirConfigPath() { invokeMethod(AndroidChannel.kSyncAppDirConfigPath, _dir); } diff --git a/flutter/lib/models/server_model.dart b/flutter/lib/models/server_model.dart index 6e78ad17f..031a62509 100644 --- a/flutter/lib/models/server_model.dart +++ b/flutter/lib/models/server_model.dart @@ -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((setState, close, context) { submit() => close(true); diff --git a/flutter/lib/models/web_model.dart b/flutter/lib/models/web_model.dart index b65825e51..be8d83500 100644 --- a/flutter/lib/models/web_model.dart +++ b/flutter/lib/models/web_model.dart @@ -251,6 +251,11 @@ class PlatformFFI { return true; } + Future invokeMethodWithResult(String method, + [dynamic arguments]) async { + return null; + } + // just for compilation void syncAndroidServiceAppDirConfigPath() {} diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 1a90336ba..84dd9cb0e 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -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: diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 198036834..d67ce0003 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -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 diff --git a/src/common.rs b/src/common.rs index e6993d074..648bc6b5c 100644 --- a/src/common.rs +++ b/src/common.rs @@ -222,6 +222,61 @@ pub fn need_fs_cm_send_files() -> bool { } } +/// Android is scoped-storage only: the peer may never touch anything outside the app +/// workspace (`Config::get_home()`, i.e. the app-specific external files directory). +/// +/// Every peer supplied path must be validated with this before it reaches the +/// filesystem, for reads, writes, renames, creations and deletions alike. The path is +/// resolved to its canonical form (of the deepest existing ancestor, so paths that are +/// about to be created are handled too) so symlinks cannot escape the workspace. +/// +/// Only the `ReadDir` protocol action treats an empty path as the home directory. +/// Callers must opt in to that protocol-specific behavior with `allow_empty`. +#[cfg(target_os = "android")] +pub fn is_peer_path_allowed(path: &str, allow_empty: bool) -> bool { + use std::path::{Component, Path, PathBuf}; + + // Canonicalize the deepest existing ancestor and re-append the missing tail. + fn resolve(path: &Path) -> Option { + let mut tail: Vec = Vec::new(); + let mut base = path.to_path_buf(); + loop { + if let Ok(mut resolved) = base.canonicalize() { + while let Some(component) = tail.pop() { + resolved.push(component); + } + return Some(resolved); + } + tail.push(base.file_name()?.to_os_string()); + if !base.pop() { + return None; + } + } + } + + if path.is_empty() { + return allow_empty; + } + let path = Path::new(path); + // `..` is never needed by the protocol and would defeat the prefix check below. + if !path.is_absolute() || path.components().any(|c| c == Component::ParentDir) { + return false; + } + let home = Config::get_home(); + let home = home.canonicalize().unwrap_or(home); + if home.as_os_str().is_empty() { + return false; + } + // `Path::starts_with` compares whole components, and is true for equal paths. + resolve(path).map_or(false, |target| target.starts_with(&home)) +} + +#[inline] +#[cfg(not(target_os = "android"))] +pub fn is_peer_path_allowed(_path: &str, _allow_empty: bool) -> bool { + true +} + #[inline] pub fn is_main() -> bool { *IS_MAIN diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 6d093cfab..1528376ab 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -2912,6 +2912,7 @@ pub mod server_side { env: JNIEnv, _class: JClass, app_dir: JString, + home_dir: JString, custom_client_config: JString, ) { log::debug!("startServer from jvm"); @@ -2919,6 +2920,9 @@ pub mod server_side { if let Ok(app_dir) = env.get_string(&app_dir) { *config::APP_DIR.write().unwrap() = app_dir.into(); } + if let Ok(home_dir) = env.get_string(&home_dir) { + *config::APP_HOME_DIR.write().unwrap() = home_dir.into(); + } if let Ok(custom_client_config) = env.get_string(&custom_client_config) { if !custom_client_config.is_empty() { let custom_client_config: String = custom_client_config.into(); diff --git a/src/lang/ar.rs b/src/lang/ar.rs index 04d982ba9..33c80e6eb 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "لقطة الشاشة للشاشات المدمجة غير مدعومة"), ("screenshot-action-tip", "إجراء لقطة الشاشة"), ("Save as", "حفظ باسم"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "نسخ إلى الحافظة"), ("Enable remote printer", "تمكين الطابعة عن بُعد"), ("Downloading {}", "جارٍ تنزيل {}"), diff --git a/src/lang/be.rs b/src/lang/be.rs index 6d2c93882..7a1635c27 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Аб’яднанне здымкаў экранаў з некалькіх дысплэяў у дадзены момант не падтрымліваецца. Пераключыцеся на адзін з дысплэяў і паўтарыце дзеянне."), ("screenshot-action-tip", "Выберыце, што рабіць з атрыманым здымкам экрана."), ("Save as", "Захаваць у файл"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Скапіяваць у буфер абмену"), ("Enable remote printer", "Выкарыстоўваць аддалены прынтар"), ("Downloading {}", "Ідзе спампоўванне {}"), diff --git a/src/lang/bg.rs b/src/lang/bg.rs index 83c98545e..d9f7cf842 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Обединяването на снимки от няколко екрана в момента не се поддържа. Моля, превключете към един екран и опитайте отново."), ("screenshot-action-tip", "Моля, изберете как да продължите със снимката на екрана."), ("Save as", "Запазване като"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Копиране в клипборда"), ("Enable remote printer", "Позволяване на отдалечен принтер"), ("Downloading {}", "Изтегляне на {}"), diff --git a/src/lang/ca.rs b/src/lang/ca.rs index 9b0ebb085..196574688 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Actualment no és possible combinar captures de pantalla de diverses pantalles. Canvieu a una sola pantalla i torneu a provar."), ("screenshot-action-tip", "Seleccioneu com voleu continuar amb la captura de pantalla."), ("Save as", "Anomena i desa"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Copia al porta-retalls"), ("Enable remote printer", "Habilita l'impressora remota"), ("Downloading {}", "Descarregant {}"), diff --git a/src/lang/cn.rs b/src/lang/cn.rs index 191c25908..be998606b 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "当前不支持多个屏幕的合并截屏,请切换到单个屏幕重试。"), ("screenshot-action-tip", "请选择如何继续截屏。"), ("Save as", "另存为"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "复制到剪贴板"), ("Enable remote printer", "启用远程打印机"), ("Downloading {}", "正在下载 {}"), diff --git a/src/lang/cs.rs b/src/lang/cs.rs index 1d214e024..21daea69c 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Sloučení snímků obrazovky z více displejů aktuálně není podporováno. Přepněte na jeden displej a zkuste to znovu."), ("screenshot-action-tip", "Vyberte, jak pokračovat se snímkem obrazovky."), ("Save as", "Uložit jako"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopírovat do schránky"), ("Enable remote printer", "Povolit vzdálenou tiskárnu"), ("Downloading {}", "Stahuje se {}"), diff --git a/src/lang/da.rs b/src/lang/da.rs index 38447d724..b29e3eddf 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Sammenfletning af skærmbilleder fra flere skærme understøttes ikke i øjeblikket. Skift venligst til en enkelt skærm og prøv igen."), ("screenshot-action-tip", "Vælg venligst, hvordan du vil fortsætte med skærmbilledet."), ("Save as", "Gem som"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopiér til udklipsholder"), ("Enable remote printer", "Aktivér fjernprinter"), ("Downloading {}", "Downloader {}"), diff --git a/src/lang/de.rs b/src/lang/de.rs index 833be3fca..9c6e75bc6 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Das Zusammenführen von Screenshots von mehreren Bildschirmen wird derzeit nicht unterstützt. Bitte wechseln Sie zu einem einzelnen Bildschirm und versuchen Sie es erneut."), ("screenshot-action-tip", "Bitte wählen Sie aus, wie Sie mit dem Screenshot fortfahren möchten."), ("Save as", "Speichern unter"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "In Zwischenablage kopieren"), ("Enable remote printer", "Entfernten Drucker aktivieren"), ("Downloading {}", "{} herunterladen"), diff --git a/src/lang/el.rs b/src/lang/el.rs index cc7591ea3..d3d1e378f 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Η συγχώνευση στιγμιότυπων οθόνης από πολλές οθόνες δεν υποστηρίζεται προς το παρόν. Αλλάξτε σε μία μόνο οθόνη και δοκιμάστε ξανά."), ("screenshot-action-tip", "Επιλέξτε πώς θα συνεχίσετε με το στιγμιότυπο οθόνης."), ("Save as", "Αποθήκευση ως"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Αντιγραφή στο πρόχειρο"), ("Enable remote printer", "Ενεργοποίηση απομακρυσμένου εκτυπωτή"), ("Downloading {}", "Γίνεται Λήψη {}"), diff --git a/src/lang/eo.rs b/src/lang/eo.rs index 48a49f96d..4f9b0ccd7 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Kunfandi ekrankopiojn de pluraj ekranoj aktuale ne estas subtenata. Bonvolu ŝanĝi al unu ekrano kaj reprovi."), ("screenshot-action-tip", "Bonvolu elekti kiel daŭrigi kun la ekrankopio."), ("Save as", "Konservi kiel"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopii al la poŝo"), ("Enable remote printer", "Ebligi foran presilon"), ("Downloading {}", "Elŝutas {}"), diff --git a/src/lang/es.rs b/src/lang/es.rs index b481fce7f..89926b43a 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "La fusión de capturas de pantalla de múltiples monitores no está soportada. Por favor, cambie a un monitor e inténtelo de nuevo."), ("screenshot-action-tip", "Por favor, seleccione cómo continuar con la captura de pantalla."), ("Save as", "Guardar como"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Copiar al portapapeles"), ("Enable remote printer", "Habilitar impresora remota"), ("Downloading {}", "Descargando {}"), diff --git a/src/lang/et.rs b/src/lang/et.rs index d916df419..9bbb7b07d 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Mitme kuva kuvatõmmiste ühendamine pole praegu toetatud. Palun lülitu ühele kuvale ja proovi uuesti."), ("screenshot-action-tip", "Palun vali, kuidas kuvatõmmisega jätkata."), ("Save as", "Salvesta kui"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopeeri lõikelauale"), ("Enable remote printer", "Luba kaugprinter"), ("Downloading {}", "Allalaadimine: {}"), diff --git a/src/lang/eu.rs b/src/lang/eu.rs index e74f3a285..8515e6f53 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Pantaila anitzen pantaila-argazkiak bateratzea ez da onartzen une honetan. Aldatu pantaila bakarrera eta saiatu berriro."), ("screenshot-action-tip", "Hautatu pantaila-argazkiarekin nola jarraitu."), ("Save as", "Gorde honela"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopiatu arbelera"), ("Enable remote printer", "Gaitu urruneko inprimagailua"), ("Downloading {}", "{} deskargatzen"), diff --git a/src/lang/fa.rs b/src/lang/fa.rs index c9fd7b45e..a96fe7160 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "ادغام تصاویر از نمایشگرهای متعدد در حال حاضر پشتیبانی نمی شود. لطفاً به یک صفحه نمایش واحد تغییر دهید و دوباره امتحان کنید."), ("screenshot-action-tip", "لطفاً نحوه ادامه با تصویر را انتخاب کنید."), ("Save as", "ذخیره به عنوان"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "در کلیپ بورد کپی کنید"), ("Enable remote printer", "چاپگر از راه دور را فعال کنید"), ("Downloading {}", "بارگیری {}"), diff --git a/src/lang/fi.rs b/src/lang/fi.rs index 9cb8e8de1..d5695d18d 100644 --- a/src/lang/fi.rs +++ b/src/lang/fi.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Yhdistetyn näytön kuvakaappaus ei ole tuettu"), ("screenshot-action-tip", "Valitse, mitä haluat tehdä kuvakaappaukselle"), ("Save as", "Tallenna nimellä"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopioi leikepöydälle"), ("Enable remote printer", "Ota etätulostin käyttöön"), ("Downloading {}", "Ladataan {}"), diff --git a/src/lang/fr.rs b/src/lang/fr.rs index 5b3204053..4dece7adc 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Actuellement, la prise de capture d’écran ne prend pas en charge les affichages multiples. Veuillez réessayer après avoir sélectionné un seul affichage."), ("screenshot-action-tip", "Veuillez choisir l’action à effectuer avec la capture d’écran."), ("Save as", "Enregistrer sous"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Copier dans le presse-papier"), ("Enable remote printer", "Activer l’impression à distance"), ("Downloading {}", "Téléchargement de {}"), diff --git a/src/lang/ge.rs b/src/lang/ge.rs index 988570095..a422c4853 100644 --- a/src/lang/ge.rs +++ b/src/lang/ge.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "რამდენიმე ეკრანის სურათის გაერთიანება ამჟამად მხარდაჭერილი არ არის. გადართეთ ერთ ეკრანზე და სცადეთ ხელახლა."), ("screenshot-action-tip", "აირჩიეთ, როგორ გავაგრძელოთ ეკრანის სურათთან მუშაობა."), ("Save as", "შენახვა როგორც"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "ბუფერში კოპირება"), ("Enable remote printer", "დისტანციური პრინტერის ჩართვა"), ("Downloading {}", "მიმდინარეობს {}-ის ჩამოტვირთვა"), diff --git a/src/lang/gu.rs b/src/lang/gu.rs index 7825c5204..3a1c14139 100644 --- a/src/lang/gu.rs +++ b/src/lang/gu.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "મર્જ કરેલ સ્ક્રીનશોટ સપોર્ટેડ નથી."), ("screenshot-action-tip", "સ્ક્રીનશોટ પછીની ક્રિયા"), ("Save as", "તરીકે સાચવો"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "ક્લિપબોર્ડમાં કોપી કરો"), ("Enable remote printer", "રિમોટ પ્રિન્ટર સક્ષમ કરો"), ("Downloading {}", "{} ડાઉનલોડ થઈ રહ્યું છે"), diff --git a/src/lang/he.rs b/src/lang/he.rs index 1183a3cbb..2ff95b48f 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "צילום מסך משולב מכל המסכים אינו נתמך"), ("screenshot-action-tip", "בחר פעולה לאחר צילום המסך"), ("Save as", "שמור בשם"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "העתק ללוח"), ("Enable remote printer", "אפשר מדפסת מרוחקת"), ("Downloading {}", "מוריד את {}"), diff --git a/src/lang/hi.rs b/src/lang/hi.rs index d73b381c0..da7fc6a40 100644 --- a/src/lang/hi.rs +++ b/src/lang/hi.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "मर्ज की गई स्क्रीन के स्क्रीनशॉट समर्थित नहीं हैं।"), ("screenshot-action-tip", "स्क्रीनशॉट लेने के बाद की कार्रवाई"), ("Save as", "इस रूप में सहेजें"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "क्लिपबोर्ड पर कॉपी करें"), ("Enable remote printer", "रिमोट प्रिंटर सक्षम करें"), ("Downloading {}", "{} डाउनलोड हो रहा है"), diff --git a/src/lang/hr.rs b/src/lang/hr.rs index 7a0f9d3cf..20f5b0da1 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Spajanje snimaka zaslona s više zaslona trenutačno nije podržano. Prebacite se na jedan zaslon i pokušajte ponovno."), ("screenshot-action-tip", "Odaberite kako nastaviti sa snimkom zaslona."), ("Save as", "Spremi kao"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopiraj u međuspremnik"), ("Enable remote printer", "Omogući udaljeni pisač"), ("Downloading {}", "Preuzimanje {}"), diff --git a/src/lang/hu.rs b/src/lang/hu.rs index 705dc867c..28f3d0482 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Egyesített képernyőről nem támogatott a képernyőkép készítése"), ("screenshot-action-tip", "Képernyőkép-művelet"), ("Save as", "Mentés másként"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Másolás a vágólapra"), ("Enable remote printer", "Távoli nyomtatók engedélyezése"), ("Downloading {}", "{} letöltése"), diff --git a/src/lang/id.rs b/src/lang/id.rs index 25c12040d..8c7af75e4 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Menggabungkan tangkapan layar dari beberapa tampilan saat ini tidak didukung. Silakan beralih ke satu tampilan dan coba lagi."), ("screenshot-action-tip", "Silakan pilih cara melanjutkan dengan tangkapan layar."), ("Save as", "Simpan sebagai"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Salin ke papan klip"), ("Enable remote printer", "Aktifkan printer jarak jauh"), ("Downloading {}", "Mendownload {}"), diff --git a/src/lang/it.rs b/src/lang/it.rs index 330e5577a..8de645fbe 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "L'unione della cattura di schermate di più display non è attualmente supportata.\nPassa ad un singolo display e riprova."), ("screenshot-action-tip", "Seleziona come continuare con la schermata."), ("Save as", "Salva come"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Copia negli appunti"), ("Enable remote printer", "Abilita stampante remota"), ("Downloading {}", "Download {}"), diff --git a/src/lang/ja.rs b/src/lang/ja.rs index f9ae7777e..71713ca01 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "複数のディスプレイのスクリーンショットの結合は、現在非対応です。単一のディスプレイに切り替えてもう一度お試しください。"), ("screenshot-action-tip", "スクリーンショットを続行する方法を選択してください。"), ("Save as", "保存先"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "クリップボードにコピー"), ("Enable remote printer", "リモートプリンターを有効化する"), ("Downloading {}", "{} をダウンロード中"), diff --git a/src/lang/ko.rs b/src/lang/ko.rs index f7da53b3f..abd48fb9e 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "현재 다중 디스플레이의 스크린샷 병합이 지원되지 않습니다. 단일 디스플레이로 전환한 후 다시 시도해 주세요."), ("screenshot-action-tip", "스크린샷을 계속 진행할 방법을 선택해 주세요."), ("Save as", "다른 이름으로 저장"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "클립보드에 복사"), ("Enable remote printer", "원격 프린터 허용"), ("Downloading {}", "{} 다운로드 중"), diff --git a/src/lang/kz.rs b/src/lang/kz.rs index 89121acca..b623e5c33 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Бірнеше дисплейдің скриншоттарын біріктіруге қазір қолдау көрсетілмейді. Жеке дисплейге ауысып, қайталап көруді өтінеміз."), ("screenshot-action-tip", "Скриншотпен қалай жалғастыру керектігін таңдауды өтінеміз."), ("Save as", "Басқаша сақтау"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Көшіру-тақтаға көшіру"), ("Enable remote printer", "Қашықтағы принтерді іске қосу"), ("Downloading {}", "{} жүктелуде"), diff --git a/src/lang/lt.rs b/src/lang/lt.rs index eb19f21c2..45d2ddc08 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Kelių ekranų nuotraukų sujungimas šiuo metu nepalaikomas. Perjunkite į vieną ekraną ir bandykite dar kartą."), ("screenshot-action-tip", "Pasirinkite, ką daryti su ekrano nuotrauka."), ("Save as", "Įrašyti kaip"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopijuoti į iškarpinę"), ("Enable remote printer", "Įgalinti nuotolinį spausdintuvą"), ("Downloading {}", "Atsisiunčiama {}"), diff --git a/src/lang/lv.rs b/src/lang/lv.rs index fe853cdff..a71cdc038 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Vairāku displeju ekrānuzņēmumu apvienošana pašlaik netiek atbalstīta. Lūdzu, pārslēdzieties uz vienu displeju un mēģiniet vēlreiz."), ("screenshot-action-tip", "Lūdzu, atlasiet, kā turpināt darbu ar ekrānuzņēmumu."), ("Save as", "Saglabāt kā"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopēt starpliktuvē"), ("Enable remote printer", "Iespējot attālo printeri"), ("Downloading {}", "Notiek {} lejupielāde"), diff --git a/src/lang/ml.rs b/src/lang/ml.rs index fe8534a0e..157a7abb3 100644 --- a/src/lang/ml.rs +++ b/src/lang/ml.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "മെർജ് ചെയ്ത സ്ക്രീൻഷോട്ട് പിന്തുണയ്ക്കുന്നില്ല."), ("screenshot-action-tip", "സ്ക്രീൻഷോട്ടിന് ശേഷമുള്ള നടപടി"), ("Save as", "പേരിൽ സേവ് ചെയ്യുക"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "ക്ലിപ്പ്ബോർഡിലേക്ക് കോപ്പി ചെയ്യുക"), ("Enable remote printer", "റിമോട്ട് പ്രിന്റർ അനുവദിക്കുക"), ("Downloading {}", "{} ഡൗൺലോഡ് ചെയ്യുന്നു"), diff --git a/src/lang/nb.rs b/src/lang/nb.rs index 45bd5c540..e92c47eb2 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Sammenslåing av skjermbilder fra flere skjermer støttes for øyeblikket ikke. Bytt til én enkelt skjerm og prøv igjen."), ("screenshot-action-tip", "Velg hvordan du vil fortsette med skjermbildet."), ("Save as", "Lagre som"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopier til utklipstavlen"), ("Enable remote printer", "Aktiver fjernskriver"), ("Downloading {}", "Laster ned {}"), diff --git a/src/lang/nl.rs b/src/lang/nl.rs index b0d21f97f..813bb2110 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Schermopnames van meerdere schermen samenvoegen wordt momenteel niet ondersteund. Schakel over naar een enkel scherm en herhaal de actie."), ("screenshot-action-tip", "Kies wat je met de gemaakte schermopname wilt doen."), ("Save as", "Opslaan als"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopiëren naar het klembord"), ("Enable remote printer", "Printer op afstand inschakelen"), ("Downloading {}", "Downloaden {}"), diff --git a/src/lang/pl.rs b/src/lang/pl.rs index 120183803..37144a0bc 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Łączenie zrzutów ekranu z wielu wyświetlaczy nie jest obecnie obsługiwane. Przełącz się na pojedynczy wyświetlacz i spróbuj ponownie."), ("screenshot-action-tip", "Wybierz sposób kontynuacji zrzutu ekranu."), ("Save as", "Zapisz jako"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopiuj do schowka"), ("Enable remote printer", "Włącz zdalne drukowanie"), ("Downloading {}", "Pobieranie {}"), diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index 7d033b363..94043d38d 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "A junção de capturas de ecrã de vários ecrãs não é atualmente suportada. Mude para um único ecrã e tente novamente."), ("screenshot-action-tip", "Selecione como pretende continuar com a captura de ecrã."), ("Save as", "Guardar como"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Copiar para a área de transferência"), ("Enable remote printer", "Ativar impressora remota"), ("Downloading {}", "A transferir {}"), diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 897ef1735..a7879960d 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "A captura de tela de múltiplas telas não é suportada no momento. Por favor, alterne para uma única tela e tente novamente."), ("screenshot-action-tip", "Por favor, selecione como deseja continuar com a captura de tela."), ("Save as", "Salvar como"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Copiar para área de transferência"), ("Enable remote printer", "Habilitar impressora remota"), ("Downloading {}", "Baixando {}"), diff --git a/src/lang/ro.rs b/src/lang/ro.rs index aee37cf94..03eb282f5 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Captura de ecran a ecranului combinat nu este suportată în prezent."), ("screenshot-action-tip", "Selectează acțiunea pentru captura de ecran: salvează ca fișier sau copiază în clipboard."), ("Save as", "Salvează ca"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Copiază în clipboard"), ("Enable remote printer", "Activează imprimanta la distanță"), ("Downloading {}", "Se descarcă {}"), diff --git a/src/lang/ru.rs b/src/lang/ru.rs index 834fcd565..8d29105db 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Объединение снимков экранов с нескольких дисплеев в настоящее время не поддерживается. Переключитесь на один дисплей и повторите действие."), ("screenshot-action-tip", "Выберите, что делать с полученным снимком экрана."), ("Save as", "Сохранить в файл"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Копировать в буфер обмена"), ("Enable remote printer", "Использовать удалённый принтер"), ("Downloading {}", "Скачивание"), diff --git a/src/lang/sc.rs b/src/lang/sc.rs index 59d0967c6..8be034c4b 100644 --- a/src/lang/sc.rs +++ b/src/lang/sc.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "S'unione de sa catura de ischermadas de prus ischermos como no est suportada.\nCola a un'ischermu ebbia e torra a proare."), ("screenshot-action-tip", "Seletziona comente sighire cun s'ischermada."), ("Save as", "Sarva comente"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Còpia in punta de billete"), ("Enable remote printer", "Abìlita imprentadora remota"), ("Downloading {}", "Iscarrighende {}"), diff --git a/src/lang/sk.rs b/src/lang/sk.rs index f01cf6e3a..cf17d2129 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Zlučovanie snímok obrazovky z viacerých displejov nie je momentálne podporované. Prepnite na jeden displej a skúste to znova."), ("screenshot-action-tip", "Vyberte, ako pokračovať so snímkou obrazovky."), ("Save as", "Uložiť ako"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopírovať do schránky"), ("Enable remote printer", "Povoliť vzdialenú tlačiareň"), ("Downloading {}", "Sťahuje sa {}"), diff --git a/src/lang/sl.rs b/src/lang/sl.rs index 04a0dd0e2..6d4480fe5 100644 --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Združevanje posnetkov zaslona z več zaslonov trenutno ni podprto. Preklopite na en zaslon in poskusite znova."), ("screenshot-action-tip", "Izberite, kako nadaljevati s posnetkom zaslona."), ("Save as", "Shrani kot"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopiraj v odložišče"), ("Enable remote printer", "Omogoči oddaljeni tiskalnik"), ("Downloading {}", "Prenašanje {}"), diff --git a/src/lang/sq.rs b/src/lang/sq.rs index 2fb1c811d..470708082 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Bashkimi i pamjeve të ekranit nga disa ekrane aktualisht nuk mbështetet. Ju lutemi kaloni te një ekran i vetëm dhe provoni përsëri."), ("screenshot-action-tip", "Ju lutemi zgjidhni si të vazhdoni me pamjen e ekranit."), ("Save as", "Ruaj si"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopjo te clipboard"), ("Enable remote printer", "Aktivizo printerin në distancë"), ("Downloading {}", "Duke shkarkuar {}"), diff --git a/src/lang/sr.rs b/src/lang/sr.rs index e1b0e703d..fe3d047e5 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Spajanje snimaka ekrana sa više prikaza trenutno nije podržano. Molimo prebacite na jedan prikaz i pokušajte ponovo."), ("screenshot-action-tip", "Molimo izaberite kako da nastavite sa snimkom ekrana."), ("Save as", "Sačuvaj kao"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kopiraj u clipboard"), ("Enable remote printer", "Omogući udaljeni štampač"), ("Downloading {}", "Preuzimanje {}"), diff --git a/src/lang/sv.rs b/src/lang/sv.rs index 594efa688..9f2efc263 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Sammanslagning av skärmdumpar från flera skärmar stöds för närvarande inte. Byt till en enda skärm och försök igen."), ("screenshot-action-tip", "Välj hur du vill fortsätta med skärmdumpen."), ("Save as", "Spara som"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Kppiera till urklipp"), ("Enable remote printer", "Aktivera fjärrskrivare"), ("Downloading {}", "Laddar ner {}"), diff --git a/src/lang/ta.rs b/src/lang/ta.rs index 8a4afde95..ac2486ccb 100644 --- a/src/lang/ta.rs +++ b/src/lang/ta.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "ஸ்கிரீன்ஷாட்_இணைக்கப்பட்ட_திரை_ஆதரவற்ற_குறிப்பு"), ("screenshot-action-tip", "ஸ்கிரீன்ஷாட்_செயல்_குறிப்பு"), ("Save as", "இப்படி சேமி"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "கிளிப்போர்டில் நகல்"), ("Enable remote printer", "தொலை அச்சுப்பொறி இயக்கு"), ("Downloading {}", "{} பதிவிறக்குகிறது"), diff --git a/src/lang/template.rs b/src/lang/template.rs index 83497a0f6..b65c92793 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", ""), ("screenshot-action-tip", ""), ("Save as", ""), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", ""), ("Enable remote printer", ""), ("Downloading {}", ""), diff --git a/src/lang/th.rs b/src/lang/th.rs index 31f314726..7531d072d 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "ขณะนี้ยังไม่รองรับการรวมภาพหน้าจอจากหลายจอแสดงผล กรุณาสลับไปใช้จอแสดงผลเดียวแล้วลองใหม่"), ("screenshot-action-tip", "กรุณาเลือกวิธีดำเนินการต่อกับภาพหน้าจอ"), ("Save as", "บันทึกเป็น"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "คัดลอกไปยังคลิปบอร์ด"), ("Enable remote printer", "เปิดใช้งานเครื่องพิมพ์ระยะไกล"), ("Downloading {}", "กำลังดาวน์โหลด {}"), diff --git a/src/lang/tr.rs b/src/lang/tr.rs index 66ac42a1c..8546d96bc 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Birden fazla ekranın ekran görüntülerinin birleştirilmesi şu anda desteklenmiyor. Lütfen tek bir ekrana geçin ve tekrar deneyin."), ("screenshot-action-tip", "Lütfen ekran görüntüsüyle nasıl devam edeceğinizi seçin."), ("Save as", "Farklı kaydet"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Panoya kopyala"), ("Enable remote printer", "Uzak yazıcıyı etkinleştir"), ("Downloading {}", "{} indiriliyor"), diff --git a/src/lang/tw.rs b/src/lang/tw.rs index b35322d10..8663c5d5f 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "目前不支援合併多個螢幕的截圖。請切換至單一螢幕後再試。"), ("screenshot-action-tip", "請選擇要如何處理這張截圖。"), ("Save as", "另存為"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "複製到剪貼簿"), ("Enable remote printer", "啟用遠端列印"), ("Downloading {}", "正在下載 {} 並安裝新版本。"), diff --git a/src/lang/uk.rs b/src/lang/uk.rs index 281b9cd6c..c11eac1f6 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Об'єднання знімків кількох дисплеїв наразі не підтримується. Перейдіть на один дисплей і спробуйте знову."), ("screenshot-action-tip", "Виберіть, що робити зі знімком екрана."), ("Save as", "Зберегти як"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Скопіювати до буфера обміну"), ("Enable remote printer", "Увімкнути віддалений принтер"), ("Downloading {}", "Завантаження {}"), diff --git a/src/lang/vi.rs b/src/lang/vi.rs index c9b28b949..7f4e0ef46 100644 --- a/src/lang/vi.rs +++ b/src/lang/vi.rs @@ -659,6 +659,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("screenshot-merged-screen-not-supported-tip", "Không hỗ trợ chụp gộp nhiều màn hình."), ("screenshot-action-tip", "Hành động chụp màn hình"), ("Save as", "Lưu thành"), + ("Export", ""), + ("Export Logs", ""), + ("Import Folder", ""), ("Copy to clipboard", "Sao chép vào Clipboard"), ("Enable remote printer", "Bật máy in từ xa"), ("Downloading {}", "Đang tải xuống {}"), diff --git a/src/server/connection.rs b/src/server/connection.rs index adcab4c88..bcdae795a 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -2021,11 +2021,17 @@ impl Connection { self.update_scoped_login_options().await; if let Some((dir, show_hidden)) = self.file_transfer.clone() { self.keyboard = false; - let dir = if !dir.is_empty() && std::path::Path::new(&dir).is_dir() { - &dir - } else { - "" - }; + let is_existing_dir = !dir.is_empty() && std::path::Path::new(&dir).is_dir(); + let is_allowed_dir = + is_existing_dir && crate::common::is_peer_path_allowed(&dir, false); + #[cfg(target_os = "android")] + if is_existing_dir && !is_allowed_dir { + log::warn!( + "Use the app workspace because the initial file-transfer directory is outside it: {}", + dir + ); + } + let dir = if is_allowed_dir { &dir } else { "" }; if !wait_session_id_confirm { self.read_dir(dir, show_hidden); } else { @@ -3313,6 +3319,81 @@ impl Connection { return true; } } + // Android is scoped-storage only: reject any peer supplied path that + // escapes the app workspace before it reaches the filesystem. + #[cfg(target_os = "android")] + { + // (path, job id, allow empty) of the peer supplied path this action + // operates on. + let checked: Option<(&str, i32, bool)> = match &fa.union { + Some(file_action::Union::ReadEmptyDirs(rd)) => { + Some((rd.path.as_str(), -1, false)) + } + Some(file_action::Union::ReadDir(rd)) => { + Some((rd.path.as_str(), 0, true)) + } + Some(file_action::Union::AllFiles(f)) => { + Some((f.path.as_str(), f.id, false)) + } + Some(file_action::Union::Send(s)) => { + // Printer jobs read from memory, `path` is only a lookup key. + if JobType::from_proto(s.file_type) == JobType::Generic { + Some((s.path.as_str(), s.id, false)) + } else { + None + } + } + Some(file_action::Union::Receive(r)) => { + Some((r.path.as_str(), r.id, false)) + } + Some(file_action::Union::RemoveDir(d)) => { + Some((d.path.as_str(), d.id, false)) + } + Some(file_action::Union::RemoveFile(f)) => { + Some((f.path.as_str(), f.id, false)) + } + Some(file_action::Union::Create(c)) => { + Some((c.path.as_str(), c.id, false)) + } + Some(file_action::Union::Rename(r)) => { + Some((r.path.as_str(), r.id, false)) + } + _ => None, + }; + if let Some((path, job_id, allow_empty)) = checked { + if !crate::common::is_peer_path_allowed(path, allow_empty) { + log::warn!( + "Reject file action outside the app workspace: {}", + path + ); + if job_id >= 0 { + self.send(fs::new_error(job_id, "Permission denied", -1)) + .await; + } + return true; + } + } + if let Some(file_action::Union::Rename(r)) = &fa.union { + let destination = std::path::Path::new(&r.path) + .parent() + .map(|parent| parent.join(&r.new_name)); + let allowed = destination + .as_deref() + .and_then(std::path::Path::to_str) + .map_or(false, |path| { + crate::common::is_peer_path_allowed(path, false) + }); + if !allowed { + log::warn!( + "Reject rename destination outside the app workspace: {:?}", + destination + ); + self.send(fs::new_error(r.id, "Permission denied", -1)) + .await; + return true; + } + } + } match fa.union { Some(file_action::Union::ReadEmptyDirs(rd)) => { self.read_empty_dirs(&rd.path, rd.include_hidden); diff --git a/src/ui_cm_interface.rs b/src/ui_cm_interface.rs index 5e13ef82b..c659170e3 100644 --- a/src/ui_cm_interface.rs +++ b/src/ui_cm_interface.rs @@ -977,6 +977,61 @@ async fn handle_fs( tx_log: Option<&UnboundedSender>, _conn_id: i32, ) { + // Android is scoped-storage only, so every peer supplied path has to stay inside the + // app workspace. This is the filesystem boundary, keep it enforced here even though + // `Connection` rejects out-of-workspace requests earlier as well. + #[cfg(target_os = "android")] + { + // (path, job id, file num, allow empty) of the peer supplied path this message + // acts on. + let checked: Option<(&str, i32, i32, bool)> = match &fs { + ipc::FS::ReadEmptyDirs { dir, .. } => Some((dir.as_str(), -1, -1, false)), + ipc::FS::ReadDir { dir, .. } => Some((dir.as_str(), -1, -1, true)), + ipc::FS::RemoveDir { path, id, .. } | ipc::FS::CreateDir { path, id } => { + Some((path.as_str(), *id, 0, false)) + } + ipc::FS::Rename { path, id, .. } => Some((path.as_str(), *id, 0, false)), + ipc::FS::RemoveFile { path, id, file_num } => { + Some((path.as_str(), *id, *file_num, false)) + } + ipc::FS::ReadAllFiles { path, id, .. } => Some((path.as_str(), *id, -1, false)), + ipc::FS::NewWrite { + path, id, file_num, .. + } + | ipc::FS::ReadFile { + path, id, file_num, .. + } => Some((path.as_str(), *id, *file_num, false)), + _ => None, + }; + if let Some((path, id, file_num, allow_empty)) = checked { + if !crate::common::is_peer_path_allowed(path, allow_empty) { + log::warn!("Reject file operation outside the app workspace: {}", path); + if id >= 0 { + send_raw(fs::new_error(id, "Permission denied", file_num), tx); + } + return; + } + } + if let ipc::FS::Rename { path, new_name, id } = &fs { + let destination = std::path::Path::new(path) + .parent() + .map(|parent| parent.join(new_name)); + let allowed = destination + .as_deref() + .and_then(std::path::Path::to_str) + .map_or(false, |path| { + crate::common::is_peer_path_allowed(path, false) + }); + if !allowed { + log::warn!( + "Reject rename destination outside the app workspace: {:?}", + destination + ); + send_raw(fs::new_error(*id, "Permission denied", 0), tx); + return; + } + } + } match fs { ipc::FS::ReadEmptyDirs { dir,