diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/DocumentDescriptors.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/DocumentDescriptors.kt new file mode 100644 index 000000000..764bec43b --- /dev/null +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/DocumentDescriptors.kt @@ -0,0 +1,41 @@ +package com.carriez.flutter_hbb + +import android.os.ParcelFileDescriptor +import org.json.JSONObject +import java.io.IOException +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +internal class DocumentDescriptors { + private val descriptors = ConcurrentHashMap() + + fun open(descriptor: ParcelFileDescriptor): JSONObject { + val id = UUID.randomUUID().toString() + try { + // Only detach the duplicate: the original carries the provider's close/error channel. + val duplicate = ParcelFileDescriptor.dup(descriptor.fileDescriptor) + descriptors[id] = descriptor + return JSONObject().put("fd", duplicate.detachFd()).put("descriptor", id) + } catch (error: Exception) { + descriptors.remove(id) + try { + descriptor.closeWithError(error.message ?: "Unable to open document") + } catch (closeError: Exception) { + error.addSuppressed(closeError) + } + throw error + } + } + + fun close(request: JSONObject): Boolean { + val descriptor = descriptors.remove(request.getString("descriptor")) + ?: throw IOException("Document descriptor was already closed") + val error = request.optString("error", "") + if (error.isNotEmpty()) { + descriptor.closeWithError(error) + } else { + descriptor.use { if (it.canDetectErrors()) it.checkError() } + } + return true + } +} diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/DocumentLogs.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/DocumentLogs.kt new file mode 100644 index 000000000..990547d78 --- /dev/null +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/DocumentLogs.kt @@ -0,0 +1,261 @@ +package com.carriez.flutter_hbb + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract +import android.provider.DocumentsContract.Document +import android.util.Log +import java.io.FileNotFoundException +import java.io.IOException +import java.util.UUID +import java.util.concurrent.TimeUnit +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +internal class DocumentLogs(private val context: Context) { + private val resolver = context.contentResolver + private val prefs = context.getSharedPreferences(DOCUMENT_PREFS, Context.MODE_PRIVATE) + private val appName = context.applicationInfo.loadLabel(context.packageManager).toString() + private val appDirectoryId = "$PARENT_ID/$appName" + private val directoryId = "$appDirectoryId/Logs" + val directoryPath = directoryId.substringAfter(':') + private var output: ParcelFileDescriptor.AutoCloseOutputStream? = null + private var written = 0L + private var openedDay = 0L + private var failure: Exception? = null + private val lock = ReentrantLock() + + init { + setEnabled(grant() != null) + } + + @androidx.annotation.Keep + private external fun setEnabled(enabled: Boolean) + + private fun grant(): Uri? = prefs.getString(LOG_GRANT, null)?.let(Uri::parse) + + fun hasPermission(): Boolean { + val uri = grant() ?: return false + if (!isLogGrant(uri)) return false + return resolver.persistedUriPermissions.any { + it.uri == uri && it.isReadPermission && it.isWritePermission + } + } + + private fun isLogGrant(uri: Uri): Boolean = + uri.authority == AUTHORITY && DocumentsContract.isTreeUri(uri) && + DocumentsContract.getTreeDocumentId(uri) in listOf(PARENT_ID, appDirectoryId, directoryId) + + private fun isDirectory(uri: Uri): Boolean = try { + resolver.query(uri, arrayOf(Document.COLUMN_MIME_TYPE), null, null, null)?.use { + it.moveToFirst() && it.getString(it.getColumnIndexOrThrow( + Document.COLUMN_MIME_TYPE)) == Document.MIME_TYPE_DIR + } ?: false + } catch (_: FileNotFoundException) { + false + } + + private fun containsDirectory(grant: Uri, parentId: String, id: String): Boolean { + val children = DocumentsContract.buildChildDocumentsUriUsingTree(grant, parentId) + val columns = arrayOf(Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE) + return resolver.query(children, columns, null, null, null)?.use { cursor -> + checkDocumentListing(cursor, "Unable to list ${parentId.substringAfter(':')}", + "${parentId.substringAfter(':')} is still loading") + while (cursor.moveToNext()) { + if (cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID)) != id) continue + check(cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_MIME_TYPE)) == + Document.MIME_TYPE_DIR) { "${id.substringAfter(':')} is not a directory" } + return@use true + } + false + } ?: throw IOException("Unable to list ${parentId.substringAfter(':')}") + } + + private fun ensureDirectory(grant: Uri): Uri { + checkDocumentName(appName) + val rootId = DocumentsContract.getTreeDocumentId(grant) + var parent = DocumentsContract.buildDocumentUriUsingTree(grant, rootId) + check(isDirectory(parent)) { "The authorized log folder is unavailable" } + val children = when (rootId) { + PARENT_ID -> listOf(appDirectoryId, directoryId) + appDirectoryId -> listOf(directoryId) + directoryId -> emptyList() + else -> throw IOException("The selected folder does not contain $directoryPath") + } + for (id in children) { + val child = DocumentsContract.buildDocumentUriUsingTree(grant, id) + if (!containsDirectory(grant, DocumentsContract.getDocumentId(parent), id)) { + closeOutput() + val created = DocumentsContract.createDocument( + resolver, parent, Document.MIME_TYPE_DIR, id.substringAfterLast('/')) + ?: throw IOException("Unable to create ${id.substringAfter(':')}") + check(DocumentsContract.getDocumentId(created) == id) { + "${id.substringAfter(':')} is not available as a directory" + } + } + parent = child + } + return parent + } + + fun select(uri: Uri, flags: Int) = lock.withLock { + require(isLogGrant(uri)) { + "Please select Android → media." + } + val permissions = flags and GRANT_FLAGS + require(permissions == GRANT_FLAGS) { "Read and write access is required for logs" } + setEnabled(false) + resolver.takePersistableUriPermission(uri, permissions) + ensureDirectory(uri) + closeOutput() + check(prefs.edit().putString(LOG_GRANT, uri.toString()).commit()) { + "Unable to remember the log directory" + } + check(ensureReady()) { "Log directory access was revoked" } + } + + fun ensureReady(): Boolean = lock.withLock { + setEnabled(false) + if (!hasPermission()) return false + val uri = grant() ?: return false + val root = DocumentsContract.buildDocumentUriUsingTree(uri, DocumentsContract.getTreeDocumentId(uri)) + if (!isDirectory(root)) return false + ensureDirectory(uri) + failure = null + if (output == null) openNext(uri) + setEnabled(true) + return true + } + + fun write(message: String): Boolean { + if (!lock.tryLock()) return false + return try { + writeLocked(message) + } finally { + lock.unlock() + } + } + + private fun writeLocked(message: String): Boolean { + val uri = grant() ?: return false + failure?.let { throw IOException("File logging stopped: ${it.message}", it) } + try { + if (output == null && !hasPermission()) { + throw IOException("Log directory access was revoked") + } + if (output == null || written >= MAX_LOG_BYTES || openedDay != currentDay()) { + openNext(uri) + } + val bytes = message.toByteArray(Charsets.UTF_8) + val stream = output ?: throw IOException("The log file is not open") + stream.write(bytes) + written += bytes.size + return true + } catch (error: Exception) { + setEnabled(false) + failure = error + try { + closeOutput() + } catch (closeError: Exception) { + error.addSuppressed(closeError) + } + Log.e("DocumentLogs", "File logging stopped", error) + throw error + } + } + + private fun closeOutput() { + val stream = output + output = null + stream?.close() + } + + private fun openNext(grant: Uri) { + closeOutput() + val name = "rustdesk-${System.currentTimeMillis()}-${UUID.randomUUID()}.txt" + val document = DocumentsContract.createDocument( + resolver, ensureDirectory(grant), "text/plain", name) + ?: throw IOException("Unable to create a log file") + try { + val descriptor = resolver.openFileDescriptor(document, "w") + ?: throw IOException("Unable to open the log file") + output = ParcelFileDescriptor.AutoCloseOutputStream(descriptor) + } catch (error: Exception) { + try { + if (!DocumentsContract.deleteDocument(resolver, document)) { + throw IOException("Unable to remove the unopened log file") + } + } catch (cleanupError: Exception) { + error.addSuppressed(cleanupError) + } + throw error + } + written = 0L + openedDay = currentDay() + val logs = files(grant).sortedBy { it.second } + for ((uri, _) in logs.filter { it.first != document } + .take((logs.size - MAX_LOG_FILES).coerceAtLeast(0))) { + try { + if (!DocumentsContract.deleteDocument(resolver, uri)) { + throw IOException("Unable to remove an expired log file") + } + } catch (error: Exception) { + Log.w("DocumentLogs", "Unable to remove an expired log file", error) + } + } + } + + private fun files(grant: Uri): List> { + val children = DocumentsContract.buildChildDocumentsUriUsingTree(grant, directoryId) + val columns = arrayOf(Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_MIME_TYPE) + return resolver.query(children, columns, null, null, null)?.use { cursor -> + checkDocumentListing(cursor, "Unable to list logs", "The log directory is still loading") + val entries = mutableListOf>() + while (cursor.moveToNext()) { + if (cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_MIME_TYPE)) == + Document.MIME_TYPE_DIR) continue + val name = cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DISPLAY_NAME)) + if (!LOG_NAME.matches(name)) continue + val id = cursor.getString(cursor.getColumnIndexOrThrow(Document.COLUMN_DOCUMENT_ID)) + entries.add(DocumentsContract.buildDocumentUriUsingTree(grant, id) to name) + } + entries + } ?: throw IOException("Unable to list logs") + } + + fun exportTo(destination: Uri): Int { + val uri = grant() ?: return 0 + check(hasPermission()) { "Log directory access was revoked" } + val parent = DocumentsContract.buildDocumentUriUsingTree( + destination, DocumentsContract.getTreeDocumentId(destination)) + val logs = files(uri) + for ((source, name) in logs) { + val target = DocumentsContract.createDocument(resolver, parent, "text/plain", name) + ?: throw IOException("Unable to create an exported log") + val input = resolver.openInputStream(source) + ?: throw IOException("Unable to read log $name") + input.use { + val output = resolver.openOutputStream(target, "w") + ?: throw IOException("Unable to write log $name") + output.use { destinationStream -> it.copyTo(destinationStream) } + } + } + return logs.size + } + + private fun currentDay(): Long = System.currentTimeMillis() / TimeUnit.DAYS.toMillis(1) + + companion object { + const val AUTHORITY = "com.android.externalstorage.documents" + const val PARENT_ID = "primary:Android/media" + private const val LOG_GRANT = "log-folder" + private const val GRANT_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + private const val MAX_LOG_BYTES = 16L * 1024 * 1024 + private const val MAX_LOG_FILES = 90 + private val LOG_NAME = Regex("rustdesk-[0-9]+-[0-9a-f-]{36}\\.txt") + } +} diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/DocumentPicker.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/DocumentPicker.kt new file mode 100644 index 000000000..51357b70a --- /dev/null +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/DocumentPicker.kt @@ -0,0 +1,143 @@ +package com.carriez.flutter_hbb + +import android.app.Activity +import android.content.Intent +import android.os.Build +import android.provider.DocumentsContract +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import kotlin.concurrent.thread + +internal class DocumentPicker(private val activity: Activity) { + private var pending: Pair? = null + private var pendingLogFolder: MethodChannel.Result? = null + private val storage = (activity.application as MainApplication).documentStorage + + fun handle(call: MethodCall, result: MethodChannel.Result): Boolean { + when (call.method) { + "document_require_log_folder" -> + requireLogFolder(result, call.argument("checkOnly") == true) + "document_workspace" -> if (call.argument("share") == true) { + run(result) { storage.shareWorkspace() } + } else { + result.success((activity.getExternalFilesDir(null) ?: activity.filesDir).path) + } + "document_folder" -> pick(result, call.argument("share") == true) + else -> return false + } + return true + } + + private fun pick(result: MethodChannel.Result, share: Boolean) { + if (pending != null || pendingLogFolder != null) { + result.error("picker_in_progress", "Another document picker is already open", null) + return + } + try { + val intent = treePickerIntent("primary:Documents") + pending = result to share + activity.startActivityForResult(intent, REQUEST_DOCUMENT) + } catch (error: Exception) { + pending = null + result.error("picker_unavailable", error.message, null) + } + } + + fun onResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + if (requestCode == REQUEST_LOG_FOLDER) { + val result = pendingLogFolder ?: return true + pendingLogFolder = null + if (resultCode != Activity.RESULT_OK || data?.data == null) { + result.success(false) + } else { + run(result) { + storage.logs.select(requireNotNull(data.data), data.flags) + true + } + } + return true + } + if (requestCode != REQUEST_DOCUMENT) return false + val (result, share) = pending ?: return true + pending = null + if (resultCode != Activity.RESULT_OK || data == null) { + result.success(null) + return true + } + run(result) { + storage.select(requireNotNull(data.data), data.flags, share).toString() + } + return true + } + + fun close() { + pendingLogFolder?.error("picker_closed", "Log directory selection was interrupted", null) + pendingLogFolder = null + pending?.first?.error("picker_closed", "Document selection was interrupted", null) + pending = null + } + + private fun run(result: MethodChannel.Result, action: () -> Any?) { + thread { + try { + val value = action() + activity.runOnUiThread { result.success(value) } + } catch (error: Exception) { + activity.runOnUiThread { result.error("document_failed", error.message, null) } + } + } + } + + private fun requireLogFolder(result: MethodChannel.Result, checkOnly: Boolean) { + if (pending != null || pendingLogFolder != null) { + result.error("picker_in_progress", "Another document picker is already open", null) + return + } + pendingLogFolder = result + thread { + val ready = runCatching { storage.logs.ensureReady() } + activity.runOnUiThread { completeLogFolderCheck(result, ready, checkOnly) } + } + } + + private fun completeLogFolderCheck( + result: MethodChannel.Result, ready: Result, checkOnly: Boolean + ) { + if (pendingLogFolder !== result) return + ready.fold(onSuccess = { + if (it || checkOnly) { + pendingLogFolder = null + result.success(it) + } else { + pickLogFolder(result) + } + }, onFailure = { + pendingLogFolder = null + result.error("log_directory_failed", it.message, null) + }) + } + + private fun treePickerIntent(initialId: String): Intent = + Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + putExtra(DocumentsContract.EXTRA_INITIAL_URI, + DocumentsContract.buildDocumentUri(DocumentLogs.AUTHORITY, initialId)) + } + } + + private fun pickLogFolder(result: MethodChannel.Result) { + try { + activity.startActivityForResult(treePickerIntent(DocumentLogs.PARENT_ID), REQUEST_LOG_FOLDER) + } catch (error: Exception) { + pendingLogFolder = null + result.error("picker_unavailable", error.message, null) + } + } + + companion object { + private const val REQUEST_DOCUMENT = 4251 + private const val REQUEST_LOG_FOLDER = 4252 + } +} diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/DocumentStorage.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/DocumentStorage.kt new file mode 100644 index 000000000..d4e0adb38 --- /dev/null +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/DocumentStorage.kt @@ -0,0 +1,124 @@ +package com.carriez.flutter_hbb + +import android.content.Context +import android.content.Intent +import android.database.Cursor +import android.net.Uri +import android.provider.DocumentsContract +import android.util.Base64 +import org.json.JSONObject +import java.io.IOException + +private const val TREE_ROOT = "/@rustdesk/tree/" +internal const val DOCUMENT_PREFS = "document-storage" +internal const val DEFAULT_LOCATION = "default-location" +private const val URI_FLAGS = Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING +private const val MIN_LOCATION_COMPONENTS = 2 + +internal fun documentRoot(uri: Uri): String = + "$TREE_ROOT${Base64.encodeToString(uri.toString().toByteArray(Charsets.UTF_8), URI_FLAGS)}" + +internal fun checkDocumentName(name: String): String { + require(name.isNotEmpty() && name != "." && name != ".." && + !name.contains('/') && !name.contains('\\') && !name.contains('\u0000')) { + "Invalid document name" + } + return name +} + +internal fun checkDocumentListing(cursor: Cursor, errorMessage: String, loadingMessage: String) { + val extras = cursor.extras + extras.getString(DocumentsContract.EXTRA_ERROR)?.let { + throw IOException("$errorMessage: $it") + } + if (extras.getBoolean(DocumentsContract.EXTRA_LOADING)) { + throw IOException(loadingMessage) + } +} + +internal fun documentEntry(name: String, directory: Boolean, size: Long = 0): JSONObject = + JSONObject().put("name", name).put("directory", directory) + .put("size", size).put("modified", 0) + +internal class DocumentStorage(private val context: Context) { + private val prefs = context.getSharedPreferences(DOCUMENT_PREFS, Context.MODE_PRIVATE) + private val descriptors = DocumentDescriptors() + val logs = DocumentLogs(context) + + fun home(): String? = prefs.getString(DEFAULT_LOCATION, null) + + fun shareWorkspace(): String { + check(prefs.edit().remove(DEFAULT_LOCATION).commit()) { + "Unable to remember the shared folder" + } + return (context.getExternalFilesDir(null) ?: context.filesDir).path + } + + fun select(uri: Uri, flags: Int, share: Boolean): JSONObject { + require(uri.scheme == "content") { "Invalid document selection" } + val grants = flags and (Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + require(grants != 0) { "The selected location has no access grant" } + context.contentResolver.takePersistableUriPermission(uri, grants) + val root = documentRoot(uri) + val backend = DocumentTree(context, uri, descriptors) + val name = checkDocumentName(backend.rootName()) + val path = "$root/$name" + if (share) { + check(prefs.edit().putString(DEFAULT_LOCATION, path).commit()) { + "Unable to remember the shared folder" + } + } + return JSONObject().put("path", path).put("name", name) + } + + fun backend(path: String): DocumentTree { + require(path.startsWith(TREE_ROOT)) { "Invalid document location" } + val parts = path.removePrefix(TREE_ROOT).split('/') + require(parts.size >= MIN_LOCATION_COMPONENTS) { "Invalid document location" } + parts.forEach(::checkDocumentName) + val uri = Uri.parse(String(Base64.decode(parts.first(), URI_FLAGS), Charsets.UTF_8)) + require(uri.scheme == "content") { "Invalid document URI" } + val authorized = + context.contentResolver.persistedUriPermissions.any { it.uri == uri && it.isReadPermission } + check(authorized) { + "Access to this location was revoked. Select it again on the Android device." + } + return DocumentTree(context, uri, descriptors) + } + + fun request(request: String): String = try { + JSONObject().put("value", execute(JSONObject(request)) ?: JSONObject.NULL).toString() + } catch (error: Exception) { + JSONObject().put("error", error.message ?: error.javaClass.simpleName).toString() + } + + private fun execute(request: JSONObject): Any? { + val operation = request.getString("op") + if (operation == "home") return home() + if (operation == "log_write") return logs.write(request.getString("message")) + if (operation == "close") return descriptors.close(request) + val path = request.getString("path") + val backend = backend(path) + return when (operation) { + "allowed" -> true + "list" -> backend.list(path) + "stat" -> backend.stat(path) + "open" -> backend.open(path) + else -> mutate(request, backend) + } + } + + private fun mutate(request: JSONObject, backend: DocumentTree): Any { + val path = request.getString("path") + return when (val operation = request.getString("op")) { + "begin" -> backend.begin(path, request.getBoolean("overwrite")) + "finish" -> backend.finish(request.getJSONObject("token")).let { true } + "discard" -> backend.discard(request.getJSONObject("token")).let { true } + "delete" -> backend.delete(path).let { true } + "mkdir" -> backend.mkdir(path).let { true } + "rename" -> backend.rename(path, checkDocumentName(request.getString("name"))).let { true } + else -> throw IOException("Unsupported document operation: $operation") + } + } +} diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/DocumentTree.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/DocumentTree.kt new file mode 100644 index 000000000..d7452cc47 --- /dev/null +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/DocumentTree.kt @@ -0,0 +1,197 @@ +package com.carriez.flutter_hbb + +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract +import android.provider.DocumentsContract.Document +import android.webkit.MimeTypeMap +import org.json.JSONArray +import org.json.JSONObject +import java.io.FileNotFoundException +import java.io.IOException +import java.util.UUID + +internal class DocumentTree( + context: Context, + private val grant: Uri, + private val descriptors: DocumentDescriptors +) { + private val root = documentRoot(grant) + private val resolver = context.contentResolver + private val rootUri: Uri get() = DocumentsContract.buildDocumentUriUsingTree( + grant, DocumentsContract.getTreeDocumentId(grant)) + private val projection = arrayOf(Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_MIME_TYPE, Document.COLUMN_SIZE, Document.COLUMN_LAST_MODIFIED) + + fun rootName(): String = metadata(rootUri)?.getString("name") + ?: throw FileNotFoundException("The selected document no longer exists") + + private fun metadata(uri: Uri): JSONObject? = resolver.query(uri, projection, null, null, null)?.use { + if (!it.moveToFirst()) return@use null + val directory = it.getString(MIME_COLUMN) == Document.MIME_TYPE_DIR + val size = if (it.isNull(SIZE_COLUMN)) -1 else it.getLong(SIZE_COLUMN) + documentEntry(it.getString(NAME_COLUMN), directory, size) + .put("modified", it.getLong(MODIFIED_COLUMN) / MILLIS_PER_SECOND).put("uri", uri.toString()) + } ?: throw IOException("Unable to query the selected document") + + private fun children(uri: Uri): List> { + val query = DocumentsContract.buildChildDocumentsUriUsingTree( + grant, DocumentsContract.getDocumentId(uri)) + return resolver.query(query, projection, null, null, null)?.use { cursor -> + checkDocumentListing(cursor, "Unable to list the selected folder", + "The selected folder is still loading. Try again.") + val entries = mutableListOf>() + while (cursor.moveToNext()) { + val child = DocumentsContract.buildDocumentUriUsingTree(grant, cursor.getString(ID_COLUMN)) + val name = checkDocumentName(cursor.getString(NAME_COLUMN)) + val entry = documentEntry(name, cursor.getString(MIME_COLUMN) == Document.MIME_TYPE_DIR, + if (cursor.isNull(SIZE_COLUMN)) -1 else cursor.getLong(SIZE_COLUMN)) + .put("modified", cursor.getLong(MODIFIED_COLUMN) / MILLIS_PER_SECOND) + .put("uri", child.toString()) + entries.add(child to entry) + } + entries + } ?: throw IOException("Unable to list the selected folder") + } + + private fun relative(path: String): List { + val parts = path.removePrefix("$root/").split('/') + require(path.startsWith("$root/") && parts.first() == rootName()) { + "The selected document was moved or renamed. Select it again." + } + parts.forEach(::checkDocumentName) + return parts.drop(1) + } + + private fun resolve(path: String, createParents: Boolean = false): Uri? { + var current = rootUri + for (name in relative(path)) { + val matches = children(current).filter { it.second.getString("name") == name } + check(matches.size <= 1) { "The provider returned multiple documents named $name" } + val child = matches.singleOrNull() + current = child?.first ?: if (createParents) { + val created = DocumentsContract.createDocument(resolver, current, Document.MIME_TYPE_DIR, name) + ?: throw IOException("Unable to create folder $name") + check(metadata(created)?.getString("name") == name) { + "The provider cannot preserve folder name: $name" + } + created + } else return null + } + return current + } + + fun uri(path: String): Uri = resolve(path) + ?: throw FileNotFoundException("Document does not exist: $path") + + fun list(path: String): JSONArray = JSONArray(children(uri(path)).map { it.second }) + + fun stat(path: String): JSONObject? = resolve(path)?.let(::metadata) + + fun open(path: String): JSONObject = descriptors.open( + resolver.openFileDescriptor(uri(path), "r") ?: throw IOException("Unable to open document")) + + fun begin(path: String, overwrite: Boolean): JSONObject { + val original = resolve(path) + check(original == null || overwrite) { "Document overwrite requires confirmation" } + require(original == null || metadata(original)?.getBoolean("directory") == false) { + "Cannot replace a folder with a file" + } + val parent = resolve(path.substringBeforeLast('/'), true) + ?: throw FileNotFoundException("Destination folder no longer exists") + val name = checkDocumentName(path.substringAfterLast('/')) + val temporary = DocumentsContract.createDocument(resolver, parent, mime(name), + temporaryName(name, "download")) + ?: throw IOException("Unable to create the receiving file") + try { + val descriptor = resolver.openFileDescriptor(temporary, "rw") + ?: throw IOException("Unable to open the receiving file") + return descriptors.open(descriptor).put("uri", temporary.toString()) + .put("path", path).put("original", original?.toString() ?: JSONObject.NULL) + } catch (error: Exception) { + try { + check(DocumentsContract.deleteDocument(resolver, temporary)) { + "Unable to remove the receiving file" + } + } catch (cleanup: Exception) { + error.addSuppressed(cleanup) + throw IOException("${error.message ?: error.javaClass.simpleName}; " + + "temporary file cleanup failed: ${cleanup.message}", error) + } + throw error + } + } + + fun finish(token: JSONObject) { + val path = token.getString("path") + val name = checkDocumentName(path.substringAfterLast('/')) + val original = resolve(path) + val expected = if (token.isNull("original")) null else token.getString("original") + check(original?.toString() == expected) { + "The destination changed during the transfer; the received data was retained" + } + val backup = original?.let { renameChecked(it, temporaryName(name, "backup")) } + try { + renameChecked(Uri.parse(token.getString("uri")), name) + } catch (error: Exception) { + if (backup != null) { + try { renameChecked(backup, name) } catch (restore: Exception) { + error.addSuppressed(restore) + throw IOException("Publishing failed and the original remains in $backup: ${restore.message}", error) + } + } + throw error + } + if (backup != null && !DocumentsContract.deleteDocument(resolver, backup)) { + throw IOException("File saved, but the previous copy could not be removed: $backup") + } + } + + private fun renameChecked(uri: Uri, name: String): Uri { + val renamed = DocumentsContract.renameDocument(resolver, uri, name) + ?: throw IOException("Unable to publish the received file; the partial file was retained") + check(metadata(renamed)?.getString("name") == name) { "The provider changed the saved filename" } + return renamed + } + + fun discard(token: JSONObject) { + check(DocumentsContract.deleteDocument(resolver, Uri.parse(token.getString("uri")))) { + "Unable to remove the canceled transfer" + } + } + + fun delete(path: String) { + require(relative(path).isNotEmpty()) { "Cannot delete a shared root" } + check(DocumentsContract.deleteDocument(resolver, uri(path))) { "Unable to delete document" } + } + + fun mkdir(path: String) { + val created = resolve(path, true) ?: throw IOException("Unable to create folder") + check(metadata(created)?.getBoolean("directory") == true) { "The destination is not a folder" } + } + + fun rename(path: String, name: String) { + require(relative(path).isNotEmpty()) { "Cannot rename a shared root" } + if (resolve(path.substringBeforeLast('/') + "/" + name) != null) throw IOException(EXISTS_MESSAGE) + renameChecked(uri(path), name) + } + + companion object { + private const val ID_COLUMN = 0 + private const val NAME_COLUMN = 1 + private const val MIME_COLUMN = 2 + private const val SIZE_COLUMN = 3 + private const val MODIFIED_COLUMN = 4 + private const val MILLIS_PER_SECOND = 1000L + const val EXISTS_MESSAGE = "A file already exists at the destination. Rename the file or select another folder." + fun temporaryName(name: String, purpose: String): String { + // Providers can append the MIME extension when renaming; preserve it for temporary files. + val extension = name.substringAfterLast('.', "").let { if (it.isEmpty()) "" else ".$it" } + return ".rustdesk-${UUID.randomUUID()}.$purpose$extension" + } + + fun mime(name: String): String = MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(name.substringAfterLast('.', "").lowercase()) + ?: "application/octet-stream" + } +} 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 f38304083..276188f48 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 @@ -63,7 +63,8 @@ class MainActivity : FlutterActivity() { data class ExportFiles( val sources: List, val rejected: Int, - val result: MethodChannel.Result + val result: MethodChannel.Result, + val documentLogs: Boolean = false ) : PendingPicker() } @@ -73,6 +74,7 @@ class MainActivity : FlutterActivity() { ) private var pendingPicker: PendingPicker? = null + private val documentPicker by lazy { DocumentPicker(this) } private var isAudioStart = false private val audioRecordHandle = AudioRecordHandle(this, { false }, { isAudioStart }) @@ -127,6 +129,7 @@ class MainActivity : FlutterActivity() { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) + if (documentPicker.onResult(requestCode, resultCode, data)) return if (requestCode == REQ_IMPORT_FILES) { val pending = pendingPicker as? PendingPicker.ImportFiles ?: return pendingPicker = null @@ -223,6 +226,14 @@ class MainActivity : FlutterActivity() { Log.e(logTag, "Failed to export selected files", e) failed += pending.sources.size - processed } + if (pending.documentLogs) { + try { + exported += (application as MainApplication).documentStorage.logs.exportTo(treeUri) + } catch (error: Exception) { + Log.e(logTag, "Failed to export document logs", error) + failed++ + } + } runOnUiThread { pending.result.success(mapOf("exported" to exported, "failed" to failed)) } @@ -243,6 +254,7 @@ class MainActivity : FlutterActivity() { } override fun onDestroy() { + documentPicker.close() Log.e(logTag, "onDestroy") // The process can outlive the UI whenever something keeps it alive: // MainService, or the accessibility InputService on its own. Only the @@ -275,6 +287,7 @@ class MainActivity : FlutterActivity() { private fun initFlutterChannel(flutterMethodChannel: MethodChannel) { flutterMethodChannel.setMethodCallHandler { call, result -> + if (documentPicker.handle(call, result)) return@setMethodCallHandler // make sure result will be invoked, otherwise flutter will await forever when (call.method) { "init_service" -> { @@ -614,19 +627,22 @@ class MainActivity : FlutterActivity() { } EXPORT_FILES -> { val paths = (call.arguments as? Map<*, *>)?.get("paths") as? List<*> - if (paths.isNullOrEmpty()) { + val documentLogs = (call.arguments as? Map<*, *>)?.get("documentLogs") == true + if (paths == null || (paths.isEmpty() && !documentLogs)) { 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()) { + if (sources.isEmpty() && (!documentLogs || + !(application as MainApplication).documentStorage.logs.hasPermission())) { 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) + pendingPicker = PendingPicker.ExportFiles( + sources, rejected, result, documentLogs = documentLogs) try { startActivityForResult( Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainApplication.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainApplication.kt index 59a3b0fb4..7ee8be2af 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainApplication.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainApplication.kt @@ -5,6 +5,11 @@ import android.util.Log import ffi.FFI class MainApplication : Application() { + internal val documentStorage by lazy { DocumentStorage(this) } + + @androidx.annotation.Keep + fun rustDocumentStorage(request: String): String = documentStorage.request(request) + companion object { private const val TAG = "MainApplication" } diff --git a/flutter/lib/mobile/pages/file_manager_page.dart b/flutter/lib/mobile/pages/file_manager_page.dart index e389bdf6c..fa57fdbb2 100644 --- a/flutter/lib/mobile/pages/file_manager_page.dart +++ b/flutter/lib/mobile/pages/file_manager_page.dart @@ -10,6 +10,7 @@ import 'package:toggle_switch/toggle_switch.dart'; import '../../common.dart'; import '../../common/widgets/dialog.dart'; import '../../consts.dart'; +import '../../utils/android_documents.dart'; class FileManagerPage extends StatefulWidget { FileManagerPage( @@ -142,6 +143,29 @@ class _FileManagerPageState extends State { } } + Future _changeLocalStorage({required bool workspace}) async { + try { + final path = workspace + ? await AndroidDocuments.workspace() + : (await AndroidDocuments.selectFolder())?['path'] as String?; + if (path == null || !mounted) return; + final previousHome = model.localController.homePath; + model.localController.homePath = path; + try { + if (await model.localController.openDirectory(path, isBack: true)) { + model.localController.history.clear(); + } else { + model.localController.homePath = previousHome; + } + } catch (_) { + model.localController.homePath = previousHome; + rethrow; + } + } catch (error) { + showToast(error.toString()); + } + } + Future _exportFile(Entry entry) async { try { final exported = await _runAndroidDocumentPicker(() => gFFI @@ -206,9 +230,11 @@ class _FileManagerPageState extends State { } Future _exportLogs() async { - final home = currentFileController.homePath; - if (home.isEmpty) { - showToast(translate('Failed')); + final String home; + try { + home = await AndroidDocuments.workspace(); + } catch (error) { + showToast(error.toString()); return; } final appDir = PathUtil.join(home, appName, false); @@ -216,22 +242,25 @@ class _FileManagerPageState extends State { 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); + await _exportPaths(paths, includeDocumentLogs: true); } - Future _exportPaths(Iterable paths) async { + Future _exportPaths(Iterable paths, + {bool includeDocumentLogs = false}) async { try { - final result = await _runAndroidDocumentPicker(() => - gFFI.invokeMethodWithResult>( - AndroidChannel.kExportFiles, {'paths': paths.toList()})); + final result = await _runAndroidDocumentPicker(() => gFFI + .invokeMethodWithResult>( + AndroidChannel.kExportFiles, { + 'paths': paths.toList(), + if (includeDocumentLogs) 'documentLogs': true, + })); if (result == null) return; final exported = result['exported'] as int? ?? 0; final failed = result['failed'] as int? ?? 0; - if (failed > 0) { + if (includeDocumentLogs && exported > 0 && failed > 0) { + showToast('${translate('Successful')}: $exported, ' + '${translate('Failed')}: $failed'); + } else if (failed > 0 || (includeDocumentLogs && exported == 0)) { showToast(translate('Failed')); } else if (exported > 0) { showToast(translate('Successful')); @@ -317,6 +346,31 @@ class _FileManagerPageState extends State { icon: Icon(Icons.more_vert), itemBuilder: (context) { return [ + if (isAndroid && showLocal) ...[ + PopupMenuItem( + value: 'workspace', + child: Row( + children: [ + Icon(Icons.storage_outlined, + color: Theme.of(context).iconTheme.color), + SizedBox(width: 5), + Text(translate('RustDesk storage')), + ], + ), + ), + PopupMenuItem( + value: 'choose_folder', + child: Row( + children: [ + Icon(Icons.folder_open_outlined, + color: Theme.of(context).iconTheme.color), + SizedBox(width: 5), + Text('${translate('Other folders')}…'), + ], + ), + ), + const PopupMenuDivider(), + ], PopupMenuItem( child: Row( children: [ @@ -328,7 +382,7 @@ class _FileManagerPageState extends State { ), value: "refresh", ), - if (isAndroid) + if (isAndroid && !AndroidDocuments.isPath(currentDir.path)) PopupMenuItem( enabled: showLocal && currentDir.path.isNotEmpty, value: "import", @@ -341,7 +395,7 @@ class _FileManagerPageState extends State { ], ), ), - if (isAndroid) + if (isAndroid && !AndroidDocuments.isPath(currentDir.path)) PopupMenuItem( enabled: showLocal && currentDir.path.isNotEmpty, value: "import_folder", @@ -409,7 +463,9 @@ class _FileManagerPageState extends State { ]; }, onSelected: (v) { - if (v == "refresh") { + if (v == 'workspace' || v == 'choose_folder') { + _changeLocalStorage(workspace: v == 'workspace'); + } else if (v == "refresh") { currentFileController.refresh(); } else if (v == "import") { _importFiles(); @@ -515,6 +571,7 @@ class _FileManagerPageState extends State { }, actions: [ if (isAndroid && + !AndroidDocuments.isPath(currentDir.path) && selectedItems?.isLocal == true && selectedItems?.items.isNotEmpty == true) ...[ if (selectedItems!.items.length == 1 && @@ -900,7 +957,7 @@ class _FileManagerViewState extends State { Padding( padding: EdgeInsets.fromLTRB(30, 5, 30, 0), child: Text( - controller.directory.value.path, + AndroidDocuments.displayPath(controller.directory.value.path), style: TextStyle(color: MyTheme.darkGray), ), ), diff --git a/flutter/lib/mobile/pages/server_page.dart b/flutter/lib/mobile/pages/server_page.dart index d61cf70b8..ddc144578 100644 --- a/flutter/lib/mobile/pages/server_page.dart +++ b/flutter/lib/mobile/pages/server_page.dart @@ -13,6 +13,7 @@ import '../../common/widgets/dialog.dart'; import '../../consts.dart'; import '../../models/platform_model.dart'; import '../../models/server_model.dart'; +import '../../utils/android_documents.dart'; import 'home_page.dart'; class ServerPage extends StatefulWidget implements PageShape { @@ -573,6 +574,44 @@ class PermissionChecker extends StatefulWidget { } class _PermissionCheckerState extends State { + static const _folderOptionIndent = 16.0; + + Future _selectFolder() async { + try { + final workspace = await gFFI.dialogManager.show( + (_, close, __) => CustomAlertDialog( + title: Text(translate('Change Path')), + content: Column(mainAxisSize: MainAxisSize.min, children: [ + ListTile( + title: Text(translate('RustDesk storage')), + onTap: () => close(true), + ), + ListTile( + title: Text('${translate('Other folders')}…'), + onTap: () => close(false), + ), + ]), + ), + backDismiss: true, + clickMaskDismiss: true); + if (workspace == null) return; + if (workspace) { + showToast(await AndroidDocuments.workspace(share: true)); + await bind.setLocalFlutterOption( + k: 'android-file-transfer-folder-setup', v: 'Y'); + return; + } + final selected = await AndroidDocuments.selectFolder(share: true); + if (selected != null) { + await bind.setLocalFlutterOption( + k: 'android-file-transfer-folder-setup', v: 'Y'); + showToast(AndroidDocuments.displayPath(selected['path'] as String)); + } + } catch (error) { + showToast(error.toString()); + } + } + @override Widget build(BuildContext context) { final serverModel = Provider.of(context); @@ -620,6 +659,19 @@ class _PermissionCheckerState extends State { serverModel.toggleFile, enabled: !permissionChangeLocked, ), + if (isAndroid && + !bind.isDisableSettings() && + !bind.isOutgoingOnly() && + bind.mainGetBuildinOption(key: kOptionHideSecuritySetting) != 'Y') + ListTile( + dense: true, + contentPadding: + const EdgeInsetsDirectional.only(start: _folderOptionIndent), + title: Text(translate('Change Path')), + trailing: const Icon(Icons.chevron_right), + enabled: !permissionChangeLocked, + onTap: _selectFolder, + ), hasAudioPermission ? PermissionRow(translate("Audio Capture"), serverModel.audioOk, serverModel.toggleAudio, diff --git a/flutter/lib/mobile/widgets/android_file_transfer_setup.dart b/flutter/lib/mobile/widgets/android_file_transfer_setup.dart new file mode 100644 index 000000000..e103f9f76 --- /dev/null +++ b/flutter/lib/mobile/widgets/android_file_transfer_setup.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; + +import '../../common.dart'; +import '../../consts.dart'; +import '../../models/model.dart'; +import '../../models/platform_model.dart'; +import '../../utils/android_documents.dart'; + +const _setupOption = 'android-file-transfer-folder-setup'; +const _folderChoicePadding = EdgeInsets.fromLTRB(12, 12, 12, 0); +const _folderChoiceSpacing = 4.0; + +enum _FolderChoice { workspace, other, skip } + +Future showAndroidFileTransferSetup(FFI ffi) async { + if (bind.getLocalFlutterOption(k: _setupOption) == 'Y' || + !_canConfigureSharedFolder(ffi)) { + return true; + } + + try { + final choice = await ffi.dialogManager.show<_FolderChoice>( + (_, close, __) => _setupDialog(close), + backDismiss: true, + forceGlobal: true, + ); + if (choice == null) return false; + switch (choice) { + case _FolderChoice.workspace: + await AndroidDocuments.workspace(share: true); + break; + case _FolderChoice.other: + if (await AndroidDocuments.selectFolder(share: true) == null) { + return false; + } + break; + case _FolderChoice.skip: + break; + } + await bind.setLocalFlutterOption(k: _setupOption, v: 'Y'); + return true; + } catch (error) { + showToast(error.toString()); + return false; + } +} + +bool _canConfigureSharedFolder(FFI ffi) => + !bind.isOutgoingOnly() && + !bind.isDisableSettings() && + bind.mainGetBuildinOption(key: kOptionHideSecuritySetting) != 'Y' && + (!ffi.serverModel.clients.any((client) => !client.disconnected) || + option2bool( + kOptionEnablePermChangeInAcceptWindow, + bind.mainGetBuildinOption( + key: kOptionEnablePermChangeInAcceptWindow))); + +CustomAlertDialog _setupDialog(void Function([dynamic]) close) { + final choiceStyle = OutlinedButton.styleFrom( + alignment: AlignmentDirectional.centerStart, + minimumSize: const Size(0, kMinInteractiveDimension), + ); + return CustomAlertDialog( + title: Text(translate('Transfer file')), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(translate('Choose which folder remote devices can access.')), + Padding( + padding: _folderChoicePadding, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + OutlinedButton( + style: choiceStyle, + onPressed: () => close(_FolderChoice.workspace), + child: Text(translate('RustDesk storage')), + ), + const SizedBox(height: _folderChoiceSpacing), + OutlinedButton( + style: choiceStyle, + onPressed: () => close(_FolderChoice.other), + child: Text('${translate('Other folders')}…'), + ), + ], + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => close(_FolderChoice.skip), + child: Text(translate('Skip')), + ), + ], + onCancel: close, + ); +} diff --git a/flutter/lib/models/file_model.dart b/flutter/lib/models/file_model.dart index afa5c10a7..a63527d8b 100644 --- a/flutter/lib/models/file_model.dart +++ b/flutter/lib/models/file_model.dart @@ -12,6 +12,7 @@ import 'package:flutter_hbb/web/dummy.dart' if (dart.library.html) 'package:flutter_hbb/web/web_unique.dart'; import '../consts.dart'; +import '../utils/android_documents.dart'; import 'model.dart'; import 'platform_model.dart'; @@ -384,6 +385,7 @@ class FileController { bool _isPathAllowed(String candidate) { if (!isAndroid || !isLocal) return true; + if (AndroidDocuments.isPath(candidate)) return true; if (homePath.isEmpty || candidate.isEmpty) return false; final home = PathUtil.posixContext.normalize(homePath); final target = PathUtil.posixContext.normalize(candidate); @@ -409,6 +411,15 @@ class FileController { return DirectoryData(directory.value, options.value); } + Future _restoreWorkspaceHome(String savedDir) async { + if (savedDir.isEmpty || AndroidDocuments.isPath(savedDir)) return; + final workspace = await AndroidDocuments.workspace(); + if (PathUtil.posixContext.equals(workspace, savedDir) || + PathUtil.posixContext.isWithin(workspace, savedDir)) { + homePath = workspace; + } + } + Future onReady() async { if (isLocal) { options.value.home = await bind.mainGetHomeDir(); @@ -425,6 +436,7 @@ class FileController { var savedDir = (await bind.sessionGetPeerOption( sessionId: sessionId, name: isLocal ? "local_dir" : "remote_dir")); + if (isAndroid && isLocal) await _restoreWorkspaceHome(savedDir); if (savedDir.isNotEmpty && !_isPathAllowed(savedDir)) { savedDir = options.value.home; await bind.sessionPeerOption( @@ -437,7 +449,13 @@ class FileController { options.value.home, }; for (final dir in dirs) { + if (isAndroid && isLocal) await _restoreWorkspaceHome(dir); if (await _openDirectoryPath(dir, isBack: true)) { + final documentRoot = RegExp(r'^/@rustdesk/tree/[^/]+/[^/]+') + .stringMatch(directory.value.path); + if (isLocal && documentRoot != null) { + homePath = documentRoot; + } return true; } } @@ -529,6 +547,9 @@ class FileController { return true; } debugPrint("Failed to openDirectory $path: $e"); + if (isAndroid && isLocal && AndroidDocuments.isPath(path)) { + showToast(e.toString()); + } return false; } } @@ -565,6 +586,9 @@ class FileController { } Future _goToParentDirectory({bool isBack = false}) async { + if (AndroidDocuments.isRoot(directory.value.path)) { + return false; + } final isWindows = options.value.isWindows; final dirPath = directory.value.path; var parent = PathUtil.dirname(dirPath, isWindows); @@ -742,7 +766,7 @@ class FileController { return; } final isWindows = options.value.isWindows; - await Future.forEach(items.items, (Entry item) async { + await Future.forEach(items.items.toList(), (Entry item) async { final jobID = JobController.jobID.next(); var title = ""; var content = ""; @@ -1154,7 +1178,8 @@ class JobController { } Future jobDone(Map evt) async { - if (jobResultListener.isListening) { + final waitingForResult = jobResultListener.isListening; + if (waitingForResult) { jobResultListener.complete(evt); // return; } @@ -1205,7 +1230,8 @@ class JobController { if (job.type == JobType.deleteDir) { return job.state == JobState.done; } else { - return true; + // An awaiting file-deletion batch refreshes when removeAction finishes. + return job.type != JobType.deleteFile || !waitingForResult; } } @@ -1503,6 +1529,12 @@ class FileFetcher { if (!_removeRemoteReadTask(entry.key, task)) continue; task.completer.completeError(StateError(_kRemoteSessionChangedError)); } + for (final path in remoteEmptyDirsTasks.keys.toList()) { + if (!AndroidDocuments.isPath(path)) continue; + remoteEmptyDirsTasks + .remove(path) + ?.completeError(StateError(_kRemoteSessionChangedError)); + } } _RemoteReadTask _registerRemoteReadTask(String path, bool includeHidden) { @@ -1543,15 +1575,18 @@ class FileFetcher { final c = Completer>(); tasks[path] = c; - Timer(Duration(seconds: 2), () { + final timeout = AndroidDocuments.isPath(path) + ? _kRemoteReadDirTimeout + : const Duration(seconds: 2); + Timer(timeout, () { + if (!identical(tasks[path], c)) return; tasks.remove(path); - if (c.isCompleted) return; c.completeError("Failed to read empty dirs, timeout"); }); return c.future; } - Future registerReadRecursiveTask(int actID) { + Future registerReadRecursiveTask(int actID, String path) { final tasks = readRecursiveTasks; if (tasks.containsKey(actID)) { throw "Failed to registerRemoveTask, already have same ReadRecursive job"; @@ -1559,7 +1594,10 @@ class FileFetcher { final c = Completer(); tasks[actID] = c; - Timer(Duration(seconds: 2), () { + final timeout = AndroidDocuments.isPath(path) + ? _kRemoteReadDirTimeout + : const Duration(seconds: 2); + Timer(timeout, () { tasks.remove(actID); if (c.isCompleted) return; c.completeError("Failed to read dir, timeout"); @@ -1641,8 +1679,13 @@ class FileFetcher { fdJsons.map((fdJson) => FileDirectory.fromJson(fdJson)).toList(); return fds; } else { + final generation = _remoteSessionGeneration; await bind.sessionReadRemoteEmptyDirsRecursiveSync( sessionId: sessionId, path: path, includeHidden: showHidden); + if (AndroidDocuments.isPath(path) && + generation != _remoteSessionGeneration) { + throw StateError(_kRemoteSessionChangedError); + } return registerReadEmptyDirsTask(isLocal, path); } } catch (e) { @@ -1656,6 +1699,11 @@ class FileFetcher { if (isLocal) { final res = await bind.sessionReadLocalDirSync( sessionId: sessionId, path: path, showHidden: showHidden); + if (isAndroid && AndroidDocuments.isPath(path)) { + final data = jsonDecode(res); + if (data['error'] != null) throw StateError(data['error'] as String); + return FileDirectory.fromJson(data); + } final fd = FileDirectory.fromJson(jsonDecode(res)); return fd; } else { @@ -1695,7 +1743,7 @@ class FileFetcher { path: path, isRemote: !isLocal, showHidden: showHidden); - return registerReadRecursiveTask(actID); + return registerReadRecursiveTask(actID, path); } catch (e) { return Future.error(e); } diff --git a/flutter/lib/models/server_model.dart b/flutter/lib/models/server_model.dart index 031a62509..bdc4c576d 100644 --- a/flutter/lib/models/server_model.dart +++ b/flutter/lib/models/server_model.dart @@ -15,6 +15,8 @@ import '../common/formatter/id_formatter.dart'; import '../desktop/pages/server_page.dart' as desktop; import '../desktop/widgets/tabbar_widget.dart'; import '../mobile/pages/server_page.dart'; +import '../mobile/widgets/android_file_transfer_setup.dart'; +import '../utils/android_documents.dart'; import 'model.dart'; const kLoginDialogTag = "LOGIN"; @@ -399,6 +401,14 @@ class ServerModel with ChangeNotifier { stopService(); } } else { + if (isAndroid) { + try { + await AndroidDocuments.requireLogFolder(); + await showAndroidFileTransferSetup(gFFI); + } catch (_) { + debugPrint('Folder setup failed; continuing service startup'); + } + } await checkRequestNotificationPermission(); if (bind.mainGetLocalOption(key: kOptionDisableFloatingWindow) != 'Y') { await checkFloatingWindowPermission(); diff --git a/flutter/lib/utils/android_documents.dart b/flutter/lib/utils/android_documents.dart new file mode 100644 index 000000000..35048e97b --- /dev/null +++ b/flutter/lib/utils/android_documents.dart @@ -0,0 +1,98 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../common.dart'; + +class AndroidDocuments { + static const root = '/@rustdesk/'; + + static bool isPath(String path) => path.startsWith(root); + + static bool isRoot(String path) => + RegExp(r'^/@rustdesk/tree/[^/]+/[^/]+$').hasMatch(path); + + static String displayPath(String path) { + if (!isPath(path)) return path; + return path.replaceFirst(RegExp(r'^/@rustdesk/tree/[^/]+/'), ''); + } + + static Future?> selectFolder({bool share = false}) async { + gFFI.ffiModel.beginAndroidDocumentPicker(); + try { + final result = await gFFI.invokeMethodWithResult( + 'document_folder', {'share': share}); + return result == null ? null : jsonDecode(result) as Map; + } finally { + gFFI.ffiModel.endAndroidDocumentPicker(); + } + } + + static Future workspace({bool share = false}) async { + final path = await gFFI + .invokeMethodWithResult('document_workspace', {'share': share}); + if (path == null || path.isEmpty) { + throw StateError('App workspace is unavailable'); + } + return path; + } + + static Future requireLogFolder() async { + gFFI.ffiModel.beginAndroidDocumentPicker(); + try { + final ready = await gFFI.invokeMethodWithResult( + 'document_require_log_folder', {'checkOnly': true}); + if (ready == null) { + throw StateError('Log directory setup did not complete'); + } + if (ready) return true; + if (!await _confirmLogFolder()) return false; + final selected = await gFFI + .invokeMethodWithResult('document_require_log_folder'); + if (selected == null) { + throw StateError('Log directory setup did not complete'); + } + return selected; + } catch (error) { + debugPrint('Log directory setup failed'); + await gFFI.dialogManager.show( + (_, close, __) => CustomAlertDialog( + title: Text(translate('Error')), + content: Text(error is PlatformException + ? translate(error.message ?? 'Failed') + : error.toString()), + actions: [ + TextButton(onPressed: close, child: Text(translate('OK'))), + ], + onSubmit: close, + onCancel: close, + ), + backDismiss: true, + forceGlobal: true, + ); + rethrow; + } finally { + gFFI.ffiModel.endAndroidDocumentPicker(); + } + } + + static Future _confirmLogFolder() async => + await gFFI.dialogManager.show( + (_, close, __) => CustomAlertDialog( + title: Text(translate('Permissions')), + content: Text( + '${translate('android-logs-folder-tip')}\n'), + actions: [ + TextButton( + onPressed: () => close(true), + child: Text(translate('Choose folder')), + ), + ], + onCancel: () => close(false), + ), + backDismiss: true, + forceGlobal: true, + ) == + true; +} diff --git a/flutter/test/android_file_transfer_setup_test.dart b/flutter/test/android_file_transfer_setup_test.dart new file mode 100644 index 000000000..3098e3ac9 --- /dev/null +++ b/flutter/test/android_file_transfer_setup_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hbb/common.dart'; +import 'package:flutter_hbb/generated_bridge.dart'; +import 'package:flutter_hbb/mobile/widgets/android_file_transfer_setup.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/models/platform_model.dart'; +import 'package:flutter_hbb/models/server_model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Binding extends Fake implements RustdeskImpl { + final options = {}; + + @override + String translate( + {required String name, required String locale, dynamic hint}) => + name; + + @override + String getLocalFlutterOption({required String k, dynamic hint}) => + options[k] ?? ''; + + @override + Future setLocalFlutterOption( + {required String k, required String v, dynamic hint}) async { + options[k] = v; + } + + @override + bool isDisableSettings({dynamic hint}) => false; + + @override + bool isOutgoingOnly({dynamic hint}) => false; + + @override + String mainGetBuildinOption({required String key, dynamic hint}) => ''; +} + +class _Server extends Fake implements ServerModel { + @override + List get clients => []; +} + +class _Session extends Fake implements FFI { + @override + final dialogManager = OverlayDialogManager(); + + @override + final serverModel = _Server(); +} + +void main() { + late _Session session; + + setUp(() { + platformFFI.setBindingForTesting(_Binding()); + session = _Session(); + }); + + Future openApp(WidgetTester tester) => tester.pumpWidget(MaterialApp( + navigatorKey: globalKey, + home: const Scaffold(), + )); + + testWidgets('skipping folder setup suppresses later prompts', (tester) async { + await openApp(tester); + final first = showAndroidFileTransferSetup(session); + await tester.pumpAndSettle(); + await tester.tap(find.text('Skip')); + await tester.pumpAndSettle(); + expect(await first, isTrue); + expect(await showAndroidFileTransferSetup(_Session()), isTrue); + expect(find.byType(CustomAlertDialog), findsNothing); + }); + + testWidgets('canceling setup leaves it available at the next entry', + (tester) async { + await openApp(tester); + final first = showAndroidFileTransferSetup(session); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(await first, isFalse); + + final retry = showAndroidFileTransferSetup(session); + await tester.pumpAndSettle(); + expect(find.byType(CustomAlertDialog), findsOneWidget); + await tester.tap(find.text('Skip')); + await tester.pumpAndSettle(); + expect(await retry, isTrue); + }); +} diff --git a/flutter/test/file_model_test.dart b/flutter/test/file_model_test.dart index c56fbcd7f..47d9f92d6 100644 --- a/flutter/test/file_model_test.dart +++ b/flutter/test/file_model_test.dart @@ -1,7 +1,10 @@ +import 'dart:async'; import 'dart:convert'; +import 'package:flutter_hbb/generated_bridge.dart'; import 'package:flutter_hbb/models/file_model.dart'; import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:uuid/uuid.dart'; @@ -47,7 +50,124 @@ class _SentRead { const _SentRead(this.path, this.includeHidden); } +class _FileBinding extends Fake implements RustdeskImpl { + int _jobId = 0; + final emptyDirsDispatch = Completer(); + + @override + Future sessionReadRemoteEmptyDirsRecursiveSync( + {required UuidValue sessionId, + required String path, + required bool includeHidden, + dynamic hint}) => + emptyDirsDispatch.future; + + @override + String translate( + {required String name, required String locale, dynamic hint}) => + name; + + @override + String mainGetCommonSync({required String key, dynamic hint}) => + '${++_jobId}'; +} + +class _DeletingController extends FileController { + final confirmations = []; + + _DeletingController() + : super( + isLocal: false, + getSessionID: () => _sessionId, + rootState: WeakReference(_FakeFFI()), + jobController: JobController(() => _sessionId, () => null), + fileFetcher: FileFetcher(() => _sessionId), + getOtherSideDirectoryData: () => + DirectoryData(FileDirectory(), DirectoryOptions()), + ); + + @override + Future showRemoveDialog( + String title, String content, bool showCheckbox) async { + confirmations.add(content); + return true; + } + + @override + void sendRemoveFile(String path, int fileNum, int actId) { + scheduleMicrotask(() { + selectedItems.reconcile( + selectedItems.items.where((entry) => entry.path != path).toList()); + unawaited( + jobController.jobDone({'id': '$actId', 'file_num': '$fileNum'})); + }); + } + + @override + Future refresh() async => true; +} + void main() { + for (final scenario in ['completion', 'reconnect', 'dispatch reconnect']) { + testWidgets('SAF retry after $scenario', (tester) async { + const path = '/@rustdesk/tree/grant/root'; + final response = jsonEncode({ + 'path': path, + 'empty_dirs': [jsonDecode(_directoryJson('$path/empty'))], + }); + final binding = _FileBinding(); + platformFFI.setBindingForTesting(binding); + final fetcher = FileFetcher(() => _sessionId); + final first = expectLater( + fetcher.readEmptyDirs(path, false, false), + scenario == 'completion' + ? completion(isNotEmpty) + : throwsA(isA())); + try { + if (scenario != 'dispatch reconnect') + binding.emptyDirsDispatch.complete(); + await tester.pump(const Duration(seconds: 3)); + if (scenario != 'completion') { + fetcher.beginRemoteSession(); + } else { + fetcher.tryCompleteEmptyDirsTask(response, 'false'); + } + if (scenario == 'dispatch reconnect') + binding.emptyDirsDispatch.complete(); + await tester.pump(); + final retry = expectLater( + fetcher.readEmptyDirs(path, false, false).then((directories) => + directories.map((directory) => directory.path)), + completion(['$path/empty'])); + await tester.pump(); + await tester.pump(const Duration(seconds: 28)); + fetcher.tryCompleteEmptyDirsTask(response, 'false'); + await tester.pump(const Duration(seconds: 2)); + await retry; + } finally { + await tester.pump(const Duration(seconds: 30)); + } + await first; + }); + } + + test('deletion confirms all five files despite refreshed selection', + () async { + platformFFI.setBindingForTesting(_FileBinding()); + final controller = _DeletingController(); + final files = ['a.txt', 'b.txt', 'c.txt', 'd.txt', 'e.txt'] + .map((name) => Entry() + ..name = name + ..path = '/selected/$name') + .toList(); + controller.selectedItems.selectAll(files); + + await controller.removeAction(controller.selectedItems); + + expect(controller.confirmations, files.map((file) => file.path)); + expect(controller.selectedItems.items, isEmpty); + }); + test('a fast remote response is matched after registration', () async { late final FileFetcher fileFetcher; fileFetcher = FileFetcher( diff --git a/libs/base/src/fs.rs b/libs/base/src/fs.rs index 15fe2e063..559cd13d9 100644 --- a/libs/base/src/fs.rs +++ b/libs/base/src/fs.rs @@ -27,6 +27,15 @@ use hbb_common::{ static NEXT_JOB_ID: AtomicI32 = AtomicI32::new(1); +#[cfg(any(target_os = "android", test))] +pub mod android; + +#[cfg(any(target_os = "android", all(test, unix)))] +mod android_transfer; + +#[cfg(any(target_os = "android", all(test, unix)))] +mod android_descriptor; + pub mod download; #[cfg(test)] @@ -42,6 +51,10 @@ pub fn update_next_job_id(id: i32) { } pub fn read_dir(path: &Path, include_hidden: bool) -> ResultType { + #[cfg(any(target_os = "android", test))] + if android::is_path(path) { + return android::read_dir(&get_string(path), include_hidden); + } let mut dir = FileDirectory { path: get_string(path), ..Default::default() @@ -146,6 +159,15 @@ pub fn get_path(path: &str) -> PathBuf { #[inline] pub fn get_home_as_string() -> String { + #[cfg(target_os = "android")] + match android::home() { + Ok(Some(path)) => return path, + Ok(None) => {} + Err(error) => { + log::trace!("Unable to load the selected document location: {}", error); + return android::ROOT.trim_end_matches('/').to_owned(); + } + } get_string(&Config::get_home()) } @@ -209,6 +231,10 @@ fn read_dir_recursive( } pub fn get_recursive_files(path: &str, include_hidden: bool) -> ResultType> { + #[cfg(any(target_os = "android", test))] + if android::is_path(Path::new(path)) { + return android::recursive_files(path, include_hidden); + } read_dir_recursive(&get_path(path), &get_path(""), include_hidden) } @@ -254,6 +280,10 @@ pub fn get_empty_dirs_recursive( path: &str, include_hidden: bool, ) -> ResultType> { + #[cfg(any(target_os = "android", test))] + if android::is_path(Path::new(path)) { + return android::empty_dirs(path, include_hidden); + } read_empty_dirs_recursive(&get_path(path), &get_path(""), include_hidden) } @@ -397,6 +427,9 @@ pub struct FileDigest { #[derive(Default, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct TransferJob { + #[cfg(any(target_os = "android", all(test, unix)))] + #[serde(skip)] + document_transfer: android_transfer::DocumentTransfer, pub id: i32, pub r#type: JobType, pub remote: String, @@ -525,6 +558,11 @@ fn validate_fs_path_argument(path: &str, arg_name: &str) -> ResultType<()> { } fn validate_no_symlink_components(base: &PathBuf, name: &str) -> ResultType<()> { + #[cfg(any(target_os = "android", test))] + if android::is_path(base) { + // Document IDs are resolved and confined to their grant by Android, not the OS filesystem. + return Ok(()); + } if name.is_empty() { return Ok(()); } @@ -584,6 +622,10 @@ impl TransferJob { enable_overwrite_detection: bool, ) -> Self { log::info!("new write {}", data_source); + let is_download = is_remote && r#type == JobType::Generic; + #[cfg(any(target_os = "android", all(test, unix)))] + let is_download = is_download + && !matches!(&data_source, DataSource::FilePath(path) if android::is_path(path)); Self { id, r#type, @@ -595,7 +637,7 @@ impl TransferJob { files: Vec::new(), total_size: 0, enable_overwrite_detection, - download: (is_remote && r#type == JobType::Generic).then(Default::default), + download: is_download.then(Default::default), ..Default::default() } } @@ -660,6 +702,14 @@ impl TransferJob { if self.is_download() && !self.files.is_empty() { bail!("cannot replace an accepted download file list"); } + #[cfg(any(target_os = "android", all(test, unix)))] + if self.is_remote + && self.r#type == JobType::Generic + && self.is_document() + && !self.files.is_empty() + { + bail!("cannot replace an accepted download file list"); + } validate_transfer_file_names(&files)?; if let DataSource::FilePath(base) = &self.data_source { for file in &files { @@ -722,6 +772,10 @@ impl TransferJob { } pub fn modify_time(&self) { + #[cfg(any(target_os = "android", all(test, unix)))] + if self.is_document() { + return; + } if self.is_download() { if let Err(err) = self.finish_download() { log::error!("Failed to finish download {}: {}", self.id, err); @@ -752,6 +806,10 @@ impl TransferJob { } pub fn remove_download_file(&self) { + #[cfg(any(target_os = "android", all(test, unix)))] + if self.is_document() { + return; + } if self.is_download() && self.data_stream.is_none() { return; } @@ -793,6 +851,10 @@ impl TransferJob { if block.id != self.id { bail!("Wrong id"); } + #[cfg(any(target_os = "android", all(test, unix)))] + if self.is_document() { + return self.write_document(block).await; + } match &self.data_source { DataSource::FilePath(p) => { let file_num = block.file_num as usize; @@ -872,6 +934,10 @@ impl TransferJob { /// Open the data stream for the current file. /// Returns Ok(true) if job is done, Ok(false) otherwise. async fn open_data_stream(&mut self) -> ResultType { + #[cfg(any(target_os = "android", all(test, unix)))] + if self.is_document() { + return self.open_document_stream().await; + } let file_num = self.file_num as usize; match &mut self.data_source { DataSource::FilePath(p) => { @@ -911,6 +977,14 @@ impl TransferJob { /// Get current file's digest (last_modified, file_size) for overwrite detection. async fn get_current_digest(&self) -> ResultType<(u64, u64)> { + #[cfg(any(target_os = "android", all(test, unix)))] + if self.is_document() { + let entry = self + .files + .get(self.file_num as usize) + .ok_or(anyhow!("Wrong file number"))?; + return Ok((entry.modified_time, entry.size)); + } let meta = match self.data_stream.as_ref().ok_or(anyhow!("file is None"))? { DataStream::FileStream(file) => file.metadata().await?, DataStream::BufStream(_) => bail!("No digest for buf stream"), @@ -999,6 +1073,10 @@ impl TransferJob { self.data_stream = None; self.file_confirmed = false; self.file_is_waiting = false; + #[cfg(any(target_os = "android", all(test, unix)))] + if self.is_document() { + self.fail_document_read(err.to_string()); + } return Err(err.into()); } Ok(n) => { @@ -1011,6 +1089,10 @@ impl TransferJob { } unsafe { buf.set_len(offset) }; if offset == 0 { + #[cfg(any(target_os = "android", all(test, unix)))] + if self.is_document() { + self.finish_document_read().await?; + } if matches!(self.data_source, DataSource::MemoryCursor(_)) { self.data_stream.take(); return Ok(None); @@ -1118,6 +1200,10 @@ impl TransferJob { /// Get job error message, useful for getting status when job had finished pub fn job_error(&self) -> Option { + #[cfg(any(target_os = "android", all(test, unix)))] + if let Some(error) = &self.document_transfer.error { + return Some(error.clone()); + } if self.job_skipped() { return Some("skipped".to_string()); } @@ -1135,6 +1221,13 @@ impl TransferJob { } async fn set_stream_offset(&mut self, file_num: usize, offset: u64) { + #[cfg(any(target_os = "android", all(test, unix)))] + if self.is_document() { + if let Err(error) = self.seek_document(offset).await { + self.fail_document_read(error.to_string()); + } + return; + } if file_num >= self.files.len() { return; } @@ -1189,6 +1282,10 @@ impl TransferJob { } pub async fn confirm(&mut self, r: &FileTransferSendConfirmRequest) -> bool { + #[cfg(any(target_os = "android", all(test, unix)))] + if self.is_document() && self.is_remote { + self.confirm_document(r); + } if self.is_download() { self.confirm_download(r); // Open receiver resume streams in write() so validation errors reach the caller. @@ -1424,6 +1521,10 @@ pub async fn handle_read_jobs( } pub fn remove_all_empty_dir(path: &Path) -> ResultType<()> { + #[cfg(any(target_os = "android", test))] + if android::is_path(path) { + return android::remove_empty_tree(&get_string(path)); + } let fd = read_dir(path, true)?; for entry in fd.entries.iter() { match entry.entry_type.enum_value() { @@ -1442,6 +1543,10 @@ pub fn remove_all_empty_dir(path: &Path) -> ResultType<()> { #[inline] pub fn remove_file(file: &str) -> ResultType<()> { + #[cfg(any(target_os = "android", test))] + if android::is_path(Path::new(file)) { + return android::remove_file(file); + } validate_fs_path_argument(file, "file path")?; std::fs::remove_file(get_path(file))?; Ok(()) @@ -1449,6 +1554,10 @@ pub fn remove_file(file: &str) -> ResultType<()> { #[inline] pub fn create_dir(dir: &str) -> ResultType<()> { + #[cfg(any(target_os = "android", test))] + if android::is_path(Path::new(dir)) { + return android::operation("mkdir", dir); + } validate_fs_path_argument(dir, "directory path")?; std::fs::create_dir_all(get_path(dir))?; Ok(()) @@ -1456,6 +1565,10 @@ pub fn create_dir(dir: &str) -> ResultType<()> { #[inline] pub fn rename_file(path: &str, new_name: &str) -> ResultType<()> { + #[cfg(any(target_os = "android", test))] + if android::is_path(Path::new(path)) { + return android::rename(path, new_name); + } validate_fs_path_argument(path, "path")?; if new_name.is_empty() { bail!("new file name cannot be empty"); @@ -1493,6 +1606,26 @@ pub fn is_write_need_confirmation( file_path: &str, digest: &FileTransferDigest, ) -> ResultType { + #[cfg(any(target_os = "android", test))] + if android::is_path(Path::new(file_path)) { + android::validate(file_path)?; + if is_resume { + bail!("Document transfers must be restarted after interruption"); + } + return match android::stat(file_path)? { + Some(entry) => Ok(DigestCheckResult::NeedConfirm(FileTransferDigest { + id: digest.id, + file_num: digest.file_num, + last_modified: entry.modified_time, + file_size: entry.size, + is_identical: entry.modified_time != 0 + && entry.modified_time == digest.last_modified + && entry.size == digest.file_size, + ..Default::default() + })), + None => Ok(DigestCheckResult::NoSuchFile), + }; + } let path = Path::new(file_path); let digest_file = format!("{}.digest", file_path); let download_file = format!("{}.download", file_path); diff --git a/libs/base/src/fs/android.rs b/libs/base/src/fs/android.rs new file mode 100644 index 000000000..cdca0721e --- /dev/null +++ b/libs/base/src/fs/android.rs @@ -0,0 +1,280 @@ +use super::{FileDirectory, FileEntry, FileType}; +use hbb_common::{anyhow::anyhow, bail, ResultType}; +use serde_derive::Deserialize; +use serde_json::{json, Value}; +use std::{path::Path, sync::OnceLock}; + +pub const ROOT: &str = "/@rustdesk/"; +type Request = Box ResultType + Send + Sync>; +static BACKEND: OnceLock = OnceLock::new(); + +pub fn initialize( + request: impl Fn(&str) -> ResultType + Send + Sync + 'static, +) -> ResultType<()> { + BACKEND + .set(Box::new(request)) + .map_err(|_| anyhow!("Document storage already initialized")) +} + +pub fn is_path(path: &Path) -> bool { + path.to_str().map_or(false, |p| { + p == ROOT.trim_end_matches('/') || p.starts_with(ROOT) + }) +} + +pub(super) fn validate(path: &str) -> ResultType<()> { + let relative = path + .strip_prefix(ROOT) + .and_then(|path| path.strip_prefix("tree/")) + .ok_or(anyhow!("Invalid document path"))?; + if relative + .split('/') + .any(|p| p.is_empty() || p == "." || p == ".." || p.contains(['\\', '\0'])) + { + bail!("Invalid document path"); + } + Ok(()) +} + +pub fn request(value: Value) -> ResultType { + if let Some(path) = value.get("path").and_then(Value::as_str) { + validate(path)?; + } + let backend = BACKEND + .get() + .ok_or(anyhow!("Android document storage is unavailable"))?; + let response: Value = serde_json::from_str(&backend(&value.to_string())?)?; + if let Some(error) = response.get("error").and_then(Value::as_str) { + bail!("{}", error); + } + response + .get("value") + .cloned() + .ok_or(anyhow!("Invalid document provider response")) +} + +pub fn home() -> ResultType> { + let value = request(json!({"op": "home"}))?; + if value.is_null() { + return Ok(None); + } + let path = value.as_str().ok_or(anyhow!("Invalid document home"))?; + validate(path)?; + Ok(Some(path.to_owned())) +} + +pub fn allowed(path: &str) -> ResultType { + if !home()?.map_or(false, |home| Path::new(path).starts_with(home)) { + return Ok(false); + } + request(json!({"op": "allowed", "path": path}))? + .as_bool() + .ok_or(anyhow!("Invalid document permission response")) +} + +#[derive(Deserialize)] +struct Metadata { + name: String, + directory: bool, + size: i64, + modified: i64, +} + +fn entry(value: Value) -> ResultType { + let meta: Metadata = serde_json::from_value(value)?; + super::validate_file_name_no_traversal(&meta.name)?; + if meta.name.contains('/') || (!meta.directory && meta.size < 0) { + bail!("The provider did not supply a transferable file name or size"); + } + Ok(FileEntry { + entry_type: if meta.directory { + FileType::Dir + } else { + FileType::File + } + .into(), + is_hidden: meta.name.starts_with('.'), + name: meta.name, + size: if meta.directory { 0 } else { meta.size as u64 }, + modified_time: meta.modified.max(0) as u64, + ..Default::default() + }) +} + +pub fn stat(path: &str) -> ResultType> { + let value = request(json!({"op": "stat", "path": path}))?; + if value.is_null() { + Ok(None) + } else { + entry(value).map(Some) + } +} + +pub fn read_dir(path: &str, hidden: bool) -> ResultType { + let values: Vec = serde_json::from_value(request(json!({"op": "list", "path": path}))?)?; + let mut entries = Vec::new(); + for value in values { + let entry = entry(value)?; + if hidden || !entry.is_hidden { + entries.push(entry); + } + } + Ok(FileDirectory { + path: path.to_owned(), + entries, + ..Default::default() + }) +} + +pub fn recursive_files(path: &str, hidden: bool) -> ResultType> { + let meta = stat(path)?.ok_or(anyhow!("Document does not exist"))?; + if meta.entry_type.enum_value() == Ok(FileType::File) { + return Ok(vec![FileEntry { + name: String::new(), + ..meta + }]); + } + let mut files = Vec::new(); + let mut pending = vec![(path.to_owned(), String::new())]; + while let Some((directory, prefix)) = pending.pop() { + for mut entry in read_dir(&directory, hidden)?.entries { + let path = format!("{}/{}", directory, entry.name); + entry.name = if prefix.is_empty() { + entry.name + } else { + format!("{}/{}", prefix, entry.name) + }; + if entry.entry_type.enum_value() == Ok(FileType::Dir) { + pending.push((path, entry.name)); + } else { + files.push(entry); + } + } + } + Ok(files) +} + +pub fn empty_dirs(path: &str, hidden: bool) -> ResultType> { + let mut result = Vec::new(); + let mut pending = vec![path.to_owned()]; + while let Some(path) = pending.pop() { + let directory = read_dir(&path, hidden)?; + if directory.entries.is_empty() { + result.push(directory); + continue; + } + for entry in directory.entries { + if entry.entry_type.enum_value() == Ok(FileType::Dir) { + pending.push(format!("{}/{}", path, entry.name)); + } + } + } + Ok(result) +} + +pub fn operation(op: &str, path: &str) -> ResultType<()> { + request(json!({"op": op, "path": path}))?; + Ok(()) +} + +pub fn remove_empty_dir(path: &str) -> ResultType<()> { + if !read_dir(path, true)?.entries.is_empty() { + bail!("Directory is not empty"); + } + operation("delete", path) +} + +pub fn remove_file(path: &str) -> ResultType<()> { + let entry = stat(path)?.ok_or(anyhow!("Document does not exist"))?; + if entry.entry_type.enum_value() != Ok(FileType::File) { + bail!("Cannot remove a directory as a file"); + } + operation("delete", path) +} + +pub fn remove_empty_tree(path: &str) -> ResultType<()> { + validate(path)?; + let tree_prefix = Path::new(ROOT).join("tree"); + if Path::new(path).parent().and_then(Path::parent) == Some(tree_prefix.as_path()) { + bail!("Cannot delete a shared root"); + } + let mut directories = vec![path.to_owned()]; + let mut index = 0; + while index < directories.len() { + for entry in read_dir(&directories[index], true)?.entries { + if entry.entry_type.enum_value() != Ok(FileType::Dir) { + bail!("Directory is not empty"); + } + directories.push(format!("{}/{}", directories[index], entry.name)); + } + index += 1; + } + for directory in directories.into_iter().rev() { + remove_empty_dir(&directory)?; + } + Ok(()) +} + +pub fn rename(path: &str, name: &str) -> ResultType<()> { + super::validate_file_name_no_traversal(name)?; + request(json!({"op": "rename", "path": path, "name": name}))?; + Ok(()) +} + +#[cfg(any(target_os = "android", all(test, unix)))] +pub fn take_file(value: &Value) -> ResultType { + use std::convert::TryFrom; + use std::os::fd::FromRawFd; + let fd = value + .as_i64() + .and_then(|fd| i32::try_from(fd).ok()) + .filter(|fd| *fd >= 0) + .ok_or(anyhow!("Invalid document descriptor"))?; + // Android hands off a duplicate; Java retains the provider's close/error channel. + Ok(unsafe { std::fs::File::from_raw_fd(fd) }) +} + +#[cfg(any(target_os = "android", all(test, unix)))] +pub(super) async fn open( + path: String, +) -> ResultType<( + tokio::fs::File, + super::android_descriptor::DocumentDescriptor, + Option<(u64, u64)>, +)> { + use std::os::unix::fs::MetadataExt; + + let (file, descriptor, digest) = tokio::task::spawn_blocking(move || { + let token = request(json!({"op": "open", "path": path}))?; + let file = take_file(&token["fd"])?; + let descriptor = super::android_descriptor::DocumentDescriptor::new(&token)?; + let metadata = file.metadata()?; + // Stream inode timestamps do not identify the source document's version. + let digest = metadata + .is_file() + .then(|| (metadata.mtime().max(0) as u64, metadata.len())); + Ok::<_, hbb_common::anyhow::Error>((file, descriptor, digest)) + }) + .await??; + Ok((tokio::fs::File::from_std(file), descriptor, digest)) +} + +#[cfg(test)] +#[path = "tests/android_tests.rs"] +mod tests; + +#[cfg(test)] +mod metadata_tests { + use super::*; + + #[test] + fn negative_modification_time_is_unknown() { + for modified in [-1, 0, 1] { + let metadata = entry(json!({ + "name": "notes.txt", "directory": false, "size": 12, "modified": modified + })) + .unwrap(); + assert_eq!(metadata.modified_time, modified.max(0) as u64); + } + } +} diff --git a/libs/base/src/fs/android_descriptor.rs b/libs/base/src/fs/android_descriptor.rs new file mode 100644 index 000000000..74964678d --- /dev/null +++ b/libs/base/src/fs/android_descriptor.rs @@ -0,0 +1,55 @@ +use super::android; +use hbb_common::{anyhow::anyhow, ResultType}; +use serde_json::{json, Value}; +use std::time::Duration; + +const CLOSE_ERROR_LOG_INTERVAL: Duration = Duration::from_secs(60); + +#[derive(Debug)] +pub(super) struct DocumentDescriptor { + id: Option, +} + +impl DocumentDescriptor { + pub(super) fn new(token: &Value) -> ResultType { + let id = token["descriptor"] + .as_str() + .ok_or(anyhow!("Missing document descriptor"))?; + Ok(Self { + id: Some(id.to_owned()), + }) + } + + pub(super) async fn close(mut self, error: &str) -> ResultType<()> { + let id = self.id.take().ok_or(anyhow!("Document already closed"))?; + let error = error.to_owned(); + tokio::task::spawn_blocking(move || { + android::request(json!({ + "op": "close", "descriptor": id, "error": error + })) + }) + .await??; + Ok(()) + } +} + +impl Drop for DocumentDescriptor { + fn drop(&mut self) { + let Some(id) = self.id.take() else { + return; + }; + // Aborted jobs must also release the provider's retained descriptor. + tokio::task::spawn_blocking(move || { + if let Err(error) = android::request(json!({ + "op": "close", "descriptor": id, "error": "Document transfer interrupted" + })) { + hbb_common::throttled_log!( + CLOSE_ERROR_LOG_INTERVAL, + warn, + "Unable to release document descriptor: {}", + error + ); + } + }); + } +} diff --git a/libs/base/src/fs/android_transfer.rs b/libs/base/src/fs/android_transfer.rs new file mode 100644 index 000000000..d289cc4e6 --- /dev/null +++ b/libs/base/src/fs/android_transfer.rs @@ -0,0 +1,296 @@ +use super::{ + android, android_descriptor::DocumentDescriptor, file_transfer_send_confirm_request, + DataSource, DataStream, FileTransferBlock, FileTransferSendConfirmRequest, TransferJob, +}; +use hbb_common::{anyhow::anyhow, bail, compress::decompress, ResultType}; +use serde_json::{json, Value}; +use tokio::{ + fs::File, + io::{AsyncSeekExt, AsyncWriteExt}, + task::spawn_blocking, +}; + +#[derive(Debug, Default)] +pub(super) struct DocumentTransfer { + reader: Option, + pending: Option, + confirmed_file: Option, + pub error: Option, +} + +#[derive(Debug)] +struct PendingWrite { + file: File, + descriptor: DocumentDescriptor, + token: Value, + path: String, +} + +impl TransferJob { + pub fn is_document(&self) -> bool { + matches!(&self.data_source, DataSource::FilePath(path) if android::is_path(path)) + } + + pub(super) fn confirm_document(&mut self, request: &FileTransferSendConfirmRequest) { + if request.id != self.id { + return; + } + let overwrite = matches!( + request.union, + Some(file_transfer_send_confirm_request::Union::OffsetBlk(0)) + | Some(file_transfer_send_confirm_request::Union::Skip(false)) + ); + self.document_transfer.confirmed_file = (overwrite + && self.files.get(request.file_num as usize).is_some()) + .then_some(request.file_num); + } + + fn document_path(&self, file_num: usize) -> ResultType { + let DataSource::FilePath(base) = &self.data_source else { + bail!("Not a document transfer"); + }; + let entry = self + .files + .get(file_num) + .ok_or(anyhow!("Wrong file number"))?; + super::validate_file_name_no_traversal(&entry.name)?; + Ok(Self::join(base, &entry.name).to_string_lossy().into_owned()) + } + + pub(super) async fn open_document_stream(&mut self) -> ResultType { + if let Some(error) = &self.document_transfer.error { + self.file_num = self.files.len() as i32; + self.data_stream.take(); + bail!("{}", error); + } + if self.file_num as usize >= self.files.len() { + self.data_stream.take(); + return Ok(true); + } + if self.data_stream.is_some() { + return Ok(false); + } + let result = android::open(self.document_path(self.file_num as usize)?).await; + self.file_confirmed = false; + self.file_is_waiting = false; + match result { + Ok((file, descriptor, digest)) => { + let entry = self + .files + .get_mut(self.file_num as usize) + .ok_or(anyhow!("Wrong file number"))?; + entry.modified_time = digest.map_or(0, |(modified, _)| modified); + entry.size = digest.map_or(entry.size, |(_, size)| size); + self.data_stream = Some(DataStream::FileStream(file)); + self.document_transfer.reader = Some(descriptor); + } + Err(error) => { + self.fail_document_read(error.to_string()); + return Err(error); + } + } + Ok(false) + } + + pub(super) async fn finish_document_read(&mut self) -> ResultType<()> { + self.data_stream.take(); + let descriptor = self + .document_transfer + .reader + .take() + .ok_or(anyhow!("No open document reader"))?; + let result = descriptor.close("").await; + if let Err(error) = &result { + self.fail_document_read(error.to_string()); + } + result + } + + pub(super) fn fail_document_read(&mut self, error: String) { + self.document_transfer.error = Some(error); + self.file_num = self.files.len() as i32; + self.file_confirmed = false; + self.file_is_waiting = false; + self.document_transfer.reader.take(); + } + + pub(super) async fn write_document(&mut self, block: FileTransferBlock) -> ResultType<()> { + if let Some(error) = &self.document_transfer.error { + bail!("{}", error); + } + let result = self.write_document_block(&block).await; + if let Err(error) = &result { + self.document_transfer.error = Some(error.to_string()); + if let Err(cleanup_error) = self.cancel_document().await { + const CLEANUP_ERROR_LOG_INTERVAL: std::time::Duration = + std::time::Duration::from_secs(60); + hbb_common::throttled_log!( + CLEANUP_ERROR_LOG_INTERVAL, + warn, + "Failed to clean up document transfer {}: {}", + self.id, + cleanup_error + ); + } + } + result + } + + async fn write_document_block(&mut self, block: &FileTransferBlock) -> ResultType<()> { + if self.is_resume { + bail!("Document transfers must be restarted after interruption"); + } + let path = self.document_path(block.file_num as usize)?; + if self.document_transfer.pending.as_ref().map(|p| &p.path) != Some(&path) { + self.finish_document().await?; + // Upload senders handle confirmation when overwrite detection is supported. + let overwrite = (!self.is_remote && self.enable_overwrite_detection) + || self.document_transfer.confirmed_file.take() == Some(block.file_num); + self.document_transfer.pending = Some(begin_write(path, overwrite).await?); + self.file_num = block.file_num; + } + let pending = self + .document_transfer + .pending + .as_mut() + .ok_or(anyhow!("No open document"))?; + let data = if block.compressed { + std::borrow::Cow::Owned(decompress(&block.data)) + } else { + std::borrow::Cow::Borrowed(block.data.as_ref()) + }; + pending.file.write_all(&data).await?; + self.finished_size += data.len() as u64; + self.transferred += block.data.len() as u64; + if block.data.is_empty() { + self.finish_document().await?; + } + Ok(()) + } + + pub async fn finish_document(&mut self) -> ResultType<()> { + if let Some(error) = &self.document_transfer.error { + bail!("{}", error); + } + let Some(pending) = self.document_transfer.pending.take() else { + return Ok(()); + }; + let result = finish_write(pending).await; + if let Err(error) = &result { + self.document_transfer.error = Some(error.to_string()); + } + result + } + + pub async fn cancel_document(&mut self) -> ResultType<()> { + let Some(pending) = self.document_transfer.pending.take() else { + return Ok(()); + }; + let PendingWrite { + file, + descriptor, + path, + token, + } = pending; + drop(file); + let result = descriptor.close("Document transfer canceled").await; + discard_write(path, token, result).await + } + + pub(super) async fn seek_document(&mut self, offset: u64) -> ResultType<()> { + let Some(DataStream::FileStream(file)) = self.data_stream.as_mut() else { + bail!("Document receives must be restarted after interruption"); + }; + let entry = self + .files + .get(self.file_num as usize) + .ok_or(anyhow!("Wrong file number"))?; + if offset > 0 && entry.modified_time == 0 { + bail!( + "Cannot resume a document with an unknown modification time; restart the transfer" + ); + } + file.seek(std::io::SeekFrom::Start(offset)).await?; + self.finished_size += offset; + self.transferred += offset; + Ok(()) + } +} + +async fn begin_write(path: String, overwrite: bool) -> ResultType { + spawn_blocking(move || { + let token = android::request(json!({ + "op": "begin", "path": path, "overwrite": overwrite + }))?; + let file = android::take_file(&token["fd"])?; + let descriptor = DocumentDescriptor::new(&token)?; + Ok(PendingWrite { + file: File::from_std(file), + descriptor, + token, + path, + }) + }) + .await? +} + +async fn finish_write(pending: PendingWrite) -> ResultType<()> { + let PendingWrite { + mut file, + descriptor, + path, + token, + } = pending; + let written: ResultType<()> = async { + file.flush().await?; + file.sync_all().await?; + Ok(()) + } + .await; + drop(file); + let message = written + .as_ref() + .err() + .map(ToString::to_string) + .unwrap_or_default(); + let closed = descriptor.close(&message).await; + let result = match (written, closed) { + (Err(error), Err(close_error)) => Err(anyhow!( + "{}; closing document failed: {}", + error, + close_error + )), + (Err(error), _) | (_, Err(error)) => Err(error), + _ => Ok(()), + }; + if result.is_err() { + return discard_write(path, token, result).await; + } + spawn_blocking(move || { + android::request(json!({ + "op": "finish", "path": path, "token": token + })) + }) + .await??; + Ok(()) +} + +async fn discard_write(path: String, token: Value, result: ResultType<()>) -> ResultType<()> { + let cleanup: ResultType<()> = async { + spawn_blocking(move || { + android::request(json!({ + "op": "discard", "path": path, "token": token + })) + }) + .await??; + Ok(()) + } + .await; + match (result, cleanup) { + (Err(error), Err(cleanup)) => { + bail!("{}; temporary document cleanup failed: {}", error, cleanup) + } + (Err(error), _) | (_, Err(error)) => Err(error), + _ => Ok(()), + } +} diff --git a/libs/base/src/fs/tests/android_tests.rs b/libs/base/src/fs/tests/android_tests.rs new file mode 100644 index 000000000..4bf40dd86 --- /dev/null +++ b/libs/base/src/fs/tests/android_tests.rs @@ -0,0 +1,288 @@ +use super::*; + +#[cfg(unix)] +#[path = "android_write_tests.rs"] +mod write_tests; + +#[cfg(unix)] +use super::super::{ + DataSource, FileTransferBlock, FileTransferSendConfirmRequest, JobType, TransferJob, +}; + +#[cfg(unix)] +fn provider(output: &Path, request: &str) -> ResultType { + use std::{fs::File, os::fd::IntoRawFd}; + + let request: Value = serde_json::from_str(request)?; + let modified = output.metadata().map_or(0, |m| std::os::unix::fs::MetadataExt::mtime(&m)); + let metadata = json!({"name": "notes.txt", "directory": false, "size": 12, "modified": modified}); + let value = match request["op"].as_str() { + Some("home") => json!("/@rustdesk/tree/selected/Documents"), + Some("allowed") => json!(true), + Some("list") => json!([metadata]), + Some("stat") if request["path"].as_str().unwrap().ends_with(".txt") => metadata, + Some("stat") => json!({"name": "Documents", "directory": true, "size": 0, "modified": 0}), + Some("begin") if request["path"].as_str().unwrap().contains("write-error") => { + std::fs::write(output, b"partial")?; + json!({"fd": File::open(output)?.into_raw_fd(), "descriptor": request["path"]}) + } + Some("begin") => { + if output.exists() && request["overwrite"].as_bool() == Some(false) { + return Ok(json!({"error": "Document overwrite requires confirmation"}).to_string()); + } + json!({"fd": File::create(output)?.into_raw_fd(), "descriptor": request["path"]}) + } + Some("open") => write_tests::open_source(output, &request)?, + Some("close") + if request["descriptor"] + .as_str() + .unwrap() + .ends_with("broken.txt") => + { + return Ok(json!({"error": "Provider read failed"}).to_string()) + } + Some("close") => json!(true), + Some("finish") if request["path"].as_str().unwrap().ends_with("failed.txt") => { + return Ok(json!({"error": "Publishing failed"}).to_string()) + } + Some("finish") => json!(true), + Some("discard") => { + if request["path"].as_str().unwrap().contains("discard-error") { + bail!("Provider cleanup failed"); + } + std::fs::remove_file(output)?; + json!(true) + } + _ => bail!("Unexpected operation"), + }; + Ok(json!({"value": value}).to_string()) +} + +#[cfg(unix)] +fn receiving(path: &str) -> TransferJob { + receiving_files(path, vec![FileEntry::default()]) +} + +#[cfg(unix)] +fn receiving_files(path: &str, files: Vec) -> TransferJob { + TransferJob::new_write( + 1, + JobType::Generic, + String::new(), + DataSource::FilePath(path.into()), + 0, + false, + true, + false, + ) + .with_files(files) + .unwrap() +} + +#[cfg(unix)] +fn block(file_num: i32, data: &[u8]) -> FileTransferBlock { + FileTransferBlock { + id: 1, + file_num, + data: data.to_vec().into(), + ..Default::default() + } +} + +#[cfg(unix)] +fn sending(path: &str, overwrite_detection: bool) -> TransferJob { + TransferJob::new_read( + 2, + JobType::Generic, + String::new(), + DataSource::FilePath(path.into()), + 0, + false, + false, + overwrite_detection, + ) + .unwrap() +} + +#[cfg(unix)] +async fn receive(path: &str, succeeds: bool, output: &Path) { + let mut job = receiving(path); + confirm_overwrite(&mut job, 0).await; + job.write(block(0, b"hello, world")).await.unwrap(); + let eof = job.write(block(0, b"")).await; + assert_eq!(eof.is_ok(), succeeds); + assert_eq!(job.finish_document().await.is_ok(), succeeds); + assert_eq!(job.job_error().is_some(), !succeeds); + job.cancel_document().await.unwrap(); + assert_eq!(std::fs::read(output).unwrap(), b"hello, world"); +} + +#[cfg(unix)] +async fn confirm_overwrite(job: &mut TransferJob, file_num: i32) { + let mut confirmation = FileTransferSendConfirmRequest { + id: job.id(), + file_num, + ..Default::default() + }; + confirmation.set_offset_blk(0); + job.confirm(&confirmation).await; +} + +#[cfg(unix)] +async fn overwrite_requires_matching_confirmation(path: &str, output: &Path) { + std::fs::write(output, b"original").unwrap(); + let mut job = receiving(&format!("{path}/notes.txt")); + assert!(job.write(block(0, b"")).await.is_err()); + assert_eq!(std::fs::read(output).unwrap(), b"original"); + + let files = ["first.txt", "second.txt"] + .map(|name| FileEntry { + name: name.into(), + ..Default::default() + }) + .to_vec(); + let mut job = receiving_files(path, files.clone()); + confirm_overwrite(&mut job, 0).await; + assert!(job.set_files(vec![files[1].clone()]).is_err()); + assert_eq!(job.files(), &files); + job.write(block(0, b"")).await.unwrap(); + std::fs::write(output, b"second original").unwrap(); + assert!(job.write(block(1, b"")).await.is_err()); + assert_eq!(std::fs::read(output).unwrap(), b"second original"); + + let mut job = receiving_files(path, files); + confirm_overwrite(&mut job, 1).await; + job.write(block(1, b"")).await.unwrap(); + assert!(std::fs::read(output).unwrap().is_empty()); +} + +#[cfg(unix)] +async fn host_upload_respects_overwrite_support(path: &str, output: &Path) { + for (overwrite_detection, exists) in [(false, false), (false, true), (true, true)] { + if exists { + std::fs::write(output, b"original").unwrap(); + } + let mut job = TransferJob::new_write( + 1, + JobType::Generic, + String::new(), + DataSource::FilePath(format!("{path}/notes.txt").into()), + 0, + false, + false, + overwrite_detection, + ) + .with_files(vec![FileEntry::default()]) + .unwrap(); + let result = job.write(block(0, b"replacement")).await; + if exists && !overwrite_detection { + assert_eq!( + result.unwrap_err().to_string(), + "Document overwrite requires confirmation" + ); + assert_eq!(std::fs::read(output).unwrap(), b"original"); + } else { + result.unwrap(); + job.finish_document().await.unwrap(); + assert_eq!(std::fs::read(output).unwrap(), b"replacement"); + } + } +} + +#[cfg(unix)] +#[tokio::test] +async fn selected_folder_transfers_directly_and_preserves_data_on_publish_failure() { + use std::time::{SystemTime, UNIX_EPOCH}; + + let output = std::env::temp_dir().join(format!( + "rustdesk-documents-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let provider_output = output.clone(); + initialize(move |request| provider(&provider_output, request)).unwrap(); + let path = "/@rustdesk/tree/selected/Documents"; + assert!(allowed(path).unwrap() && allowed(&format!("{path}/notes.txt")).unwrap()); + assert!(!allowed(&format!("{path}-private/notes.txt")).unwrap()); + assert!(!allowed("/@rustdesk/tree/previous/Documents/notes.txt").unwrap()); + let directory = super::super::read_dir(Path::new(path), false).unwrap(); + assert_eq!(directory.path, path); + assert_eq!(directory.entries[0].name, "notes.txt"); + let files = super::super::get_recursive_files(path, false).unwrap(); + assert_eq!(files[0].name, "notes.txt"); + assert_eq!(files[0].size, 12); + assert!(remove_empty_tree(path).unwrap_err().to_string() == "Cannot delete a shared root"); + assert!(super::super::remove_file(path).is_err()); + host_upload_respects_overwrite_support(path, &output).await; + overwrite_requires_matching_confirmation(path, &output).await; + for (name, succeeds) in [("notes.txt", true), ("failed.txt", false)] { + receive(&format!("{path}/{name}"), succeeds, &output).await; + } + let destination = format!("{path}/notes.txt"); + let entry = stat(&destination).unwrap().unwrap(); + let digest = super::super::FileTransferDigest { + file_size: entry.size, + last_modified: entry.modified_time, + ..Default::default() + }; + assert!(matches!( + super::super::is_write_need_confirmation(false, &destination, &digest).unwrap(), + super::super::DigestCheckResult::NeedConfirm(digest) if digest.is_identical + )); + provider_error_at_eof(path).await; + send_and_cancel(path, &output).await; + write_tests::failed_block_cleans_pending_receive(path, &output).await; + write_tests::failed_finalization_discards_partial_data(path, &output).await; + write_tests::replacement_preserves_opened_digest(path, &output).await; + write_tests::changed_source_without_timestamp_rejects_resume(path, &output).await; + let mut empty = receiving(&format!("{path}/empty.txt")); + empty.write(block(0, b"")).await.unwrap(); + assert_eq!(std::fs::metadata(&output).unwrap().len(), 0); + std::fs::remove_file(output).unwrap(); +} + +#[cfg(unix)] +async fn send_and_cancel(path: &str, output: &Path) { + let mut sender = sending(&format!("{path}/notes.txt"), false); + sender.init_data_stream_for_cm().await.unwrap(); + assert_eq!( + sender.read().await.unwrap().unwrap().data.as_ref(), + b"hello, world" + ); + assert!(sender.read().await.unwrap().unwrap().data.is_empty()); + let mut canceled = receiving(&format!("{path}/canceled.txt")); + confirm_overwrite(&mut canceled, 0).await; + canceled.write(block(0, b"partial")).await.unwrap(); + canceled.cancel_document().await.unwrap(); + assert!(!output.exists()); +} + +#[cfg(unix)] +async fn provider_error_at_eof(path: &str) { + let mut sender = sending(&format!("{path}/broken.txt"), true); + assert!(sender.init_data_stream_for_cm().await.unwrap().is_some()); + confirm_overwrite(&mut sender, 0).await; + assert!(sender.read().await.unwrap().is_some()); + assert!(sender.read().await.is_err()); + assert!(sender.init_data_stream_for_cm().await.is_err()); + assert!(sender.read().await.unwrap().is_none()); + assert!(sender.job_completed()); + assert_eq!(sender.job_error().as_deref(), Some("Provider read failed")); +} + +#[test] +fn document_paths_cannot_escape_the_selected_location() { + for path in [ + "/@rustdesk/tree/id/folder/../secret", + "/@rustdesk//Download", + "/@rustdesk/tree/id/a\\b", + "/@rustdesk/file/id/report.txt", + "/@rustdesk/save/id/report.txt", + "/@rustdesk/Download/report.txt", + ] { + assert!(validate(path).is_err(), "{}", path); + } + assert!(validate("/@rustdesk/tree/id/folder/report.txt").is_ok()); +} diff --git a/libs/base/src/fs/tests/android_write_tests.rs b/libs/base/src/fs/tests/android_write_tests.rs new file mode 100644 index 000000000..af9a37acc --- /dev/null +++ b/libs/base/src/fs/tests/android_write_tests.rs @@ -0,0 +1,153 @@ +use super::*; + +pub(super) fn open_source(output: &Path, request: &Value) -> ResultType { + use std::{fs::File, io::Write, os::fd::IntoRawFd}; + + let fd = if request["path"].as_str().unwrap().ends_with("stream.txt") { + let (reader, mut writer) = std::os::unix::net::UnixStream::pair()?; + writer.write_all(&std::fs::read(output)?)?; + reader.into_raw_fd() + } else { + let file = File::open(output)?; + let replacement = output.with_extension("replacement"); + if replacement.exists() { + std::fs::rename(replacement, output)?; + } + file.into_raw_fd() + }; + Ok(json!({"fd": fd, "descriptor": request["path"]})) +} + +pub(super) async fn replacement_preserves_opened_digest(path: &str, output: &Path) { + use filetime::{set_file_mtime, FileTime}; + + std::fs::write(output, b"old contents").unwrap(); + set_file_mtime(output, FileTime::from_unix_time(1, 0)).unwrap(); + let replacement = output.with_extension("replacement"); + std::fs::write(&replacement, b"new contents").unwrap(); + set_file_mtime(&replacement, FileTime::from_unix_time(2, 0)).unwrap(); + let path = format!("{path}/notes.txt"); + let mut sender = sending(&path, true); + assert_eq!( + sender.init_data_stream_for_cm().await.unwrap(), + Some((1, 12)) + ); + confirm_overwrite(&mut sender, 0).await; + assert_eq!( + sender.read().await.unwrap().unwrap().data.as_ref(), + b"old contents" + ); + assert!(sender.read().await.unwrap().unwrap().data.is_empty()); + let mut next = sending(&path, true); + assert_eq!(next.init_data_stream_for_cm().await.unwrap(), Some((2, 12))); +} + +pub(super) async fn failed_block_cleans_pending_receive(path: &str, output: &Path) { + for name in ["write-error.txt", "write-error-broken.txt"] { + let mut job = receiving(&format!("{path}/{name}")); + job.is_remote = false; + let block = block(0, b"more data"); + // Tokio queues the first write and reports its I/O failure on the next block. + job.write(block.clone()).await.unwrap(); + assert_eq!(std::fs::read(output).unwrap(), b"partial"); + let error = job.write(block).await.unwrap_err(); + assert!(error.downcast_ref::().is_some()); + assert_eq!(job.job_error(), Some(error.to_string())); + assert!( + !output.exists(), + "A failed receive must discard partial data" + ); + } +} + +pub(super) async fn failed_finalization_discards_partial_data(path: &str, output: &Path) { + for name in ["write-error.txt", "broken.txt", "discard-error-broken.txt"] { + let mut job = receiving(&format!("{path}/{name}")); + confirm_overwrite(&mut job, 0).await; + job.write(block(0, b"partial data")).await.unwrap(); + let error = job.finish_document().await.unwrap_err(); + assert_eq!(job.job_error(), Some(error.to_string())); + if name.ends_with("broken.txt") { + assert!(error.to_string().contains("Provider read failed")); + } else { + assert!(error.downcast_ref::().is_some()); + } + if name.starts_with("discard-error") { + assert!(error.to_string().contains("Provider cleanup failed")); + assert!(output.exists()); + std::fs::remove_file(output).unwrap(); + } else { + assert!( + !output.exists(), + "A failed finalization must discard partial data" + ); + } + } +} + +pub(super) async fn changed_source_without_timestamp_rejects_resume(path: &str, output: &Path) { + std::fs::write(output, b"old contents").unwrap(); + filetime::set_file_mtime(output, filetime::FileTime::from_unix_time(0, 0)).unwrap(); + stale_metadata_uses_current_digest(&format!("{path}/notes.txt")).await; + let path = format!("{path}/stream.txt"); + let mut sender = sending(&path, true); + let original_digest = sender.init_data_stream_for_cm().await.unwrap(); + assert_eq!(original_digest, Some((0, b"old contents".len() as u64))); + confirm_overwrite(&mut sender, 0).await; + assert_eq!( + sender.read().await.unwrap().unwrap().data.as_ref(), + b"old contents" + ); + drop(sender); + + std::fs::write(output, b"new contents").unwrap(); + let mut resumed = sending(&path, true); + assert_eq!( + resumed.init_data_stream_for_cm().await.unwrap(), + original_digest + ); + let mut confirmation = FileTransferSendConfirmRequest { + id: resumed.id(), + ..Default::default() + }; + confirmation.set_offset_blk(b"old ".len() as u32); + resumed.confirm(&confirmation).await; + assert_eq!( + resumed.job_error().as_deref(), + Some("Cannot resume a document with an unknown modification time; restart the transfer") + ); + assert!(resumed.read().await.unwrap().is_none()); + assert!(resumed.job_completed()); + + let mut fresh = sending(&path, true); + fresh.init_data_stream_for_cm().await.unwrap(); + confirm_overwrite(&mut fresh, 0).await; + assert_eq!( + fresh.read().await.unwrap().unwrap().data.as_ref(), + b"new contents" + ); + assert!(fresh.read().await.unwrap().unwrap().data.is_empty()); + std::fs::remove_file(output).unwrap(); +} + +async fn stale_metadata_uses_current_digest(path: &str) { + for (size, modified_time) in [(12, 1), (13, 0)] { + let mut sender = sending(path, true) + .with_files(vec![FileEntry { + size, + modified_time, + ..Default::default() + }]) + .unwrap(); + assert_eq!( + sender.init_data_stream_for_cm().await.unwrap(), + Some((0, b"old contents".len() as u64)) + ); + confirm_overwrite(&mut sender, 0).await; + assert_eq!( + sender.read().await.unwrap().unwrap().data.as_ref(), + b"old contents" + ); + assert!(sender.read().await.unwrap().unwrap().data.is_empty()); + } +} diff --git a/libs/scrap/src/android/ffi.rs b/libs/scrap/src/android/ffi.rs index 26f6a50f4..82a070a5f 100644 --- a/libs/scrap/src/android/ffi.rs +++ b/libs/scrap/src/android/ffi.rs @@ -506,7 +506,43 @@ pub extern "system" fn Java_ffi_FFI_onAppStart(mut env: JNIEnv, _class: JClass, let java_vm = jvm.get_java_vm_pointer() as *mut c_void; let context_jobject = context.as_obj().as_raw() as *mut c_void; *APPLICATION_CONTEXT.write().unwrap() = Some(context); + *JVM.write().unwrap() = Some(jvm); + if let Err(error) = base::fs::android::initialize(call_document_storage) { + log::error!("Unable to initialize document storage: {}", error); + } try_init_rustls_platform_verifier(&mut env, context_jobject); } } } + +fn call_document_storage(request: &str) -> hbb_common::ResultType { + use hbb_common::anyhow::anyhow; + + const LOCAL_REFS: i32 = 4; + let context = APPLICATION_CONTEXT + .read() + .unwrap() + .clone() + .ok_or(anyhow!("Android application context is unavailable"))?; + let jvm = JVM.read().unwrap(); + let jvm = jvm.as_ref().ok_or(anyhow!("Android VM is unavailable"))?; + let mut env = jvm.attach_current_thread_as_daemon()?; + let result = env.with_local_frame(LOCAL_REFS, |env| -> JniResult { + let request = env.new_string(request)?; + let value = env + .call_method( + &context, + "rustDocumentStorage", + "(Ljava/lang/String;)Ljava/lang/String;", + &[JValue::Object(&JObject::from(request))], + )? + .l()?; + let value = JString::from(value); + let value = env.get_string(&value)?; + Ok(value.into()) + }); + if result.is_err() && env.exception_check()? { + env.exception_clear()?; + } + Ok(result?) +} diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index eeec5ad8a..cb6867124 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -430,6 +430,19 @@ impl Remote { } } self.handle_disconnected(round); + #[cfg(target_os = "android")] + for job in &mut self.write_jobs { + if let Err(error) = job.cancel_document().await { + const CLEANUP_ERROR_LOG_INTERVAL: Duration = Duration::from_secs(60); + hbb_common::throttled_log!( + CLEANUP_ERROR_LOG_INTERVAL, + warn, + "Failed to clean up document transfer {} on disconnect: {}", + job.id(), + error + ); + } + } } fn handle_disconnected(&self, round: u32) { @@ -845,7 +858,33 @@ impl Remote { .await ); } else { - match fs::TransferJob::new_read( + #[cfg(target_os = "android")] + let result = { + let source = fs::DataSource::FilePath(PathBuf::from(&path)); + let destination = to.clone(); + let read = move || { + fs::TransferJob::new_read( + id, + r#type, + destination, + source, + file_num, + include_hidden, + is_remote, + od, + ) + }; + if fs::android::is_path(path.as_ref()) { + tokio::task::spawn_blocking(read) + .await + .map_err(hbb_common::anyhow::Error::from) + .and_then(|result| result) + } else { + read() + } + }; + #[cfg(not(target_os = "android"))] + let result = fs::TransferJob::new_read( id, r#type, to.clone(), @@ -854,7 +893,8 @@ impl Remote { include_hidden, is_remote, od, - ) { + ); + match result { Err(err) => { self.handle_job_status(id, -1, Some(err.to_string())); } @@ -915,7 +955,33 @@ impl Remote { job.is_last_job = true; self.write_jobs.push(job); } else { - match fs::TransferJob::new_read( + #[cfg(target_os = "android")] + let result = { + let source = fs::DataSource::FilePath(PathBuf::from(&path)); + let destination = to.clone(); + let read = move || { + fs::TransferJob::new_read( + id, + r#type, + destination, + source, + file_num, + include_hidden, + is_remote, + od, + ) + }; + if fs::android::is_path(path.as_ref()) { + tokio::task::spawn_blocking(read) + .await + .map_err(hbb_common::anyhow::Error::from) + .and_then(|result| result) + } else { + read() + } + }; + #[cfg(not(target_os = "android"))] + let result = fs::TransferJob::new_read( id, r#type, to.clone(), @@ -924,7 +990,8 @@ impl Remote { include_hidden, is_remote, od, - ) { + ); + match result { Err(err) => { self.handle_job_status(id, -1, Some(err.to_string())); } @@ -1076,7 +1143,21 @@ impl Remote { self.remove_jobs .insert(id, RemoveJob::new(Vec::new(), path, sep, is_remote)); } else { - match fs::get_recursive_files(&path, include_hidden) { + #[cfg(target_os = "android")] + let result = if fs::android::is_path(path.as_ref()) { + let path = path.clone(); + tokio::task::spawn_blocking(move || { + fs::get_recursive_files(&path, include_hidden) + }) + .await + .map_err(hbb_common::anyhow::Error::from) + .and_then(|result| result) + } else { + fs::get_recursive_files(&path, include_hidden) + }; + #[cfg(not(target_os = "android"))] + let result = fs::get_recursive_files(&path, include_hidden); + match result { Ok(entries) => { self.handler.update_folder_files( id, @@ -1122,7 +1203,18 @@ impl Remote { msg_out.set_file_action(file_action); allow_err!(peer.send(&msg_out).await); } else { - match fs::remove_file(&path) { + #[cfg(target_os = "android")] + let result = if fs::android::is_path(path.as_ref()) { + tokio::task::spawn_blocking(move || fs::remove_file(&path)) + .await + .map_err(hbb_common::anyhow::Error::from) + .and_then(|result| result) + } else { + fs::remove_file(&path) + }; + #[cfg(not(target_os = "android"))] + let result = fs::remove_file(&path); + match result { Err(err) => { self.handle_job_status(id, file_num, Some(err.to_string())); } @@ -1144,7 +1236,18 @@ impl Remote { msg_out.set_file_action(file_action); allow_err!(peer.send(&msg_out).await); } else { - match fs::create_dir(&path) { + #[cfg(target_os = "android")] + let result = if fs::android::is_path(path.as_ref()) { + tokio::task::spawn_blocking(move || fs::create_dir(&path)) + .await + .map_err(hbb_common::anyhow::Error::from) + .and_then(|result| result) + } else { + fs::create_dir(&path) + }; + #[cfg(not(target_os = "android"))] + let result = fs::create_dir(&path); + match result { Err(err) => { self.handle_job_status(id, -1, Some(err.to_string())); } @@ -1167,6 +1270,18 @@ impl Remote { msg_out.set_file_action(file_action); allow_err!(peer.send(&msg_out).await); } else { + #[cfg(target_os = "android")] + let err = if fs::android::is_path(path.as_ref()) { + tokio::task::spawn_blocking(move || fs::rename_file(&path, &new_name)) + .await + .map_err(hbb_common::anyhow::Error::from) + .and_then(|result| result) + } else { + fs::rename_file(&path, &new_name) + } + .err() + .map(|e| e.to_string()); + #[cfg(not(target_os = "android"))] let err = fs::rename_file(&path, &new_name) .err() .map(|e| e.to_string()); @@ -1301,6 +1416,12 @@ impl Remote { msg_out.set_file_action(file_action); allow_err!(peer.send(&msg_out).await); if let Some(job) = fs::remove_job(id, &mut self.write_jobs) { + #[cfg(target_os = "android")] + let mut job = job; + #[cfg(target_os = "android")] + if let Err(error) = job.cancel_document().await { + self.handle_job_status(id, job.file_num(), Some(error.to_string())); + } job.remove_download_file(); } let _ = fs::remove_job(id, &mut self.read_jobs); @@ -1908,7 +2029,26 @@ impl Remote { crate::is_support_file_transfer_resume_num( peer_ver, ); - match job.download_digest(is_support_resume, &digest) { + #[cfg(target_os = "android")] + let digest_result = if job.is_document() { + let path = write_path.clone(); + let digest = digest.clone(); + let resume = is_support_resume && job.is_resume; + tokio::task::spawn_blocking(move || { + fs::is_write_need_confirmation( + resume, &path, &digest, + ) + }) + .await + .map_err(hbb_common::anyhow::Error::from) + .and_then(|result| result) + } else { + job.download_digest(is_support_resume, &digest) + }; + #[cfg(not(target_os = "android"))] + let digest_result = + job.download_digest(is_support_resume, &digest); + match digest_result { Ok(res) => match res { DigestCheckResult::IsSame => { let req = FileTransferSendConfirmRequest { @@ -2005,11 +2145,17 @@ impl Remote { let mut job_type = fs::JobType::Generic; let mut printer_data = None; if let Some(job) = fs::remove_job(d.id, &mut self.write_jobs) { + #[cfg(target_os = "android")] + let mut job = job; err = job .finish_download() .err() .map(|err| err.to_string()) .or_else(|| job.job_error()); + #[cfg(target_os = "android")] + if let Err(error) = job.finish_document().await { + err = Some(error.to_string()); + } job_type = job.r#type; printer_data = match job.get_buf_data().await { Ok(d) => d, @@ -2057,6 +2203,20 @@ impl Remote { } } Some(file_response::Union::Error(e)) => { + #[cfg(target_os = "android")] + if let Some(job) = fs::get_job(e.id, &mut self.write_jobs) { + if let Err(error) = job.cancel_document().await { + const CLEANUP_ERROR_LOG_INTERVAL: Duration = + Duration::from_secs(60); + hbb_common::throttled_log!( + CLEANUP_ERROR_LOG_INTERVAL, + warn, + "Failed to clean up document transfer {}: {}", + e.id, + error + ); + } + } let job_type = fs::remove_job(e.id, &mut self.write_jobs) .or_else(|| fs::remove_job(e.id, &mut self.read_jobs)) .map(|j| j.r#type) diff --git a/src/common.rs b/src/common.rs index d00d94adf..53831cc5f 100644 --- a/src/common.rs +++ b/src/common.rs @@ -239,8 +239,7 @@ 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). +/// Android peers may access only the current shared file-transfer location. /// /// 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 @@ -253,6 +252,22 @@ pub fn need_fs_cm_send_files() -> bool { pub fn is_peer_path_allowed(path: &str, allow_empty: bool) -> bool { use std::path::{Component, Path, PathBuf}; + if base::fs::android::is_path(Path::new(path)) { + return match base::fs::android::allowed(path) { + Ok(allowed) => allowed, + Err(error) => { + const ERROR_LOG_INTERVAL: Duration = Duration::from_secs(60); + hbb_common::throttled_log!( + ERROR_LOG_INTERVAL, + warn, + "Document access denied: {}", + error + ); + false + } + }; + } + // Canonicalize the deepest existing ancestor and re-append the missing tail. fn resolve(path: &Path) -> Option { let mut tail: Vec = Vec::new(); @@ -279,7 +294,7 @@ pub fn is_peer_path_allowed(path: &str, allow_empty: bool) -> bool { if !path.is_absolute() || path.components().any(|c| c == Component::ParentDir) { return false; } - let home = Config::get_home(); + let home = base::fs::get_path(&base::fs::get_home_as_string()); let home = home.canonicalize().unwrap_or(home); if home.as_os_str().is_empty() { return false; @@ -294,6 +309,24 @@ pub fn is_peer_path_allowed(_path: &str, _allow_empty: bool) -> bool { true } +#[cfg(target_os = "android")] +pub async fn is_peer_path_allowed_async(path: &str, allow_empty: bool) -> bool { + let path = path.to_owned(); + match tokio::task::spawn_blocking(move || is_peer_path_allowed(&path, allow_empty)).await { + Ok(allowed) => allowed, + Err(error) => { + const ERROR_LOG_INTERVAL: Duration = Duration::from_secs(60); + hbb_common::throttled_log!( + ERROR_LOG_INTERVAL, + error, + "File-transfer access check failed: {}", + error + ); + false + } + } +} + #[inline] pub fn is_main() -> bool { *IS_MAIN diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index f142074cf..bd9bd9657 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -60,7 +60,7 @@ fn initialize(app_dir: &str, custom_client_config: &str) { .with_tag("ffi"), // logs will show under mytag tag ); #[cfg(not(debug_assertions))] - hbb_common::init_log(false, ""); + crate::platform::android_log::initialize(); #[cfg(feature = "mediacodec")] scrap::mediacodec::check_mediacodec(); crate::common::test_rendezvous_server(); @@ -854,6 +854,13 @@ pub fn session_read_local_dir_sync( path: String, show_hidden: bool, ) -> String { + #[cfg(target_os = "android")] + if fs::android::is_path(&fs::get_path(&path)) { + return match fs::read_dir(&fs::get_path(&path), show_hidden) { + Ok(fd) => make_fd_to_json(fd.id, path, &fd.entries), + Err(error) => serde_json::json!({"error": error.to_string()}).to_string(), + }; + } if let Ok(fd) = fs::read_dir(&fs::get_path(&path), show_hidden) { return make_fd_to_json(fd.id, path, &fd.entries); } @@ -3034,6 +3041,8 @@ pub mod server_side { if let Ok(home_dir) = env.get_string(&home_dir) { *config::APP_HOME_DIR.write().unwrap() = home_dir.into(); } + #[cfg(not(debug_assertions))] + crate::platform::android_log::initialize(); 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 172ba0405..3c79f5e33 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/az.rs b/src/lang/az.rs index ecacc1aad..e63f978bf 100644 --- a/src/lang/az.rs +++ b/src/lang/az.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/be.rs b/src/lang/be.rs index 134132154..d2d19e19d 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/bg.rs b/src/lang/bg.rs index 674208ac4..68bc7430c 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ca.rs b/src/lang/ca.rs index b936e9a2e..061ab7b78 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/cn.rs b/src/lang/cn.rs index b87d47176..b470a7fda 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", "关"), ("api_server_connection_tip", "自定义服务器正在使用未加密的 HTTP。请联系管理员启用 HTTPS。"), ("api_server_empty_tip", "API 服务器留空时,自定义服务器默认使用 HTTP。请联系管理员启用 HTTPS。"), + ("RustDesk storage", "RustDesk 存储空间"), + ("Other folders", "其他文件夹"), + ("Choose which folder remote devices can access.", "选择允许远程设备访问的文件夹。"), + ("android-logs-folder-tip", "请选择 Android → media,用于保存日志。"), + ("Choose folder", "选择文件夹"), + ("Please select Android → media.", "请选择 Android → media。"), ].iter().cloned().collect(); } diff --git a/src/lang/cs.rs b/src/lang/cs.rs index fb0bd330b..b520a7ea0 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/da.rs b/src/lang/da.rs index cf2fc7c35..d779efff6 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/de.rs b/src/lang/de.rs index a4bb78fca..d4a83465b 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/el.rs b/src/lang/el.rs index 980450aa2..6dccbda99 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/en.rs b/src/lang/en.rs index 4709a0d0b..f3c654f0f 100644 --- a/src/lang/en.rs +++ b/src/lang/en.rs @@ -290,5 +290,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("shortcut-key-not-supported", "\"{}\" can't be used as a shortcut."), ("api_server_connection_tip", "Your custom server uses unencrypted HTTP. Contact your administrator to enable HTTPS."), ("api_server_empty_tip", "With API Server empty, your custom server defaults to HTTP. Contact your administrator to enable HTTPS."), + ("android-logs-folder-tip", "Choose Android → media to store log files."), ].iter().cloned().collect(); } diff --git a/src/lang/eo.rs b/src/lang/eo.rs index d550e3000..5434d4ac5 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/es.rs b/src/lang/es.rs index f2670cee1..cdfb0fcee 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/et.rs b/src/lang/et.rs index d32b6116c..88de96b16 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/eu.rs b/src/lang/eu.rs index 6c292d027..3ab92a123 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fa.rs b/src/lang/fa.rs index 7e3e23bd9..a037c2a95 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -813,5 +813,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("shortcut-key-not-supported", "این کلید برای میانبر پشتیبانی نمی‌شود."), ("On", "روشن"), ("Off", "خاموش"), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fi.rs b/src/lang/fi.rs index b46c5e54e..0cea33773 100644 --- a/src/lang/fi.rs +++ b/src/lang/fi.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fr.rs b/src/lang/fr.rs index 1a320bee7..6c7318361 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ge.rs b/src/lang/ge.rs index 16beeede9..582f34b6e 100644 --- a/src/lang/ge.rs +++ b/src/lang/ge.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/gl.rs b/src/lang/gl.rs index 3624d4d81..dbf1134de 100644 --- a/src/lang/gl.rs +++ b/src/lang/gl.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/gu.rs b/src/lang/gu.rs index 72ca26833..f792358a3 100644 --- a/src/lang/gu.rs +++ b/src/lang/gu.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/he.rs b/src/lang/he.rs index da6ee7b36..747b3dbed 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/hi.rs b/src/lang/hi.rs index 68357b3ce..562732bd2 100644 --- a/src/lang/hi.rs +++ b/src/lang/hi.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/hr.rs b/src/lang/hr.rs index a13796f7b..e5d2994ac 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/hu.rs b/src/lang/hu.rs index 3dcf4031a..3e8598ae5 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/id.rs b/src/lang/id.rs index dd6402c32..2ff8b5b40 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/it.rs b/src/lang/it.rs index 1cedab86d..a29da3d95 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", "OFF"), ("api_server_connection_tip", "Il server personalizzato usa 'HTTP non crittografato'. Per abilitare HTTPS contatta l'amministratore."), ("api_server_empty_tip", "Con 'Server API' vuoto, il server personalizzato predefinito è 'HTTP'. Per abilitare HTTPS contatta l'amministratore."), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ja.rs b/src/lang/ja.rs index e1661feb8..a6d02a023 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ko.rs b/src/lang/ko.rs index 8a691d4a7..27d989640 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", "끔"), ("api_server_connection_tip", "사용 중인 맞춤형 서버가 암호화되지 않은 HTTP를 사용합니다. HTTPS를 사용하도록 관리자에게 문의하세요."), ("api_server_empty_tip", "API 서버가 비어 있으면 사용자 지정 서버는 기본적으로 HTTP로 설정됩니다. HTTPS를 사용하도록 관리자에게 문의하세요."), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/kz.rs b/src/lang/kz.rs index 1f02923cd..b5cb79eb6 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/lt.rs b/src/lang/lt.rs index 6d71fe793..b1bc19224 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/lv.rs b/src/lang/lv.rs index 0b62990f8..a6e7e0647 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ml.rs b/src/lang/ml.rs index 1dcfe3068..b3ad4d0ff 100644 --- a/src/lang/ml.rs +++ b/src/lang/ml.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/nb.rs b/src/lang/nb.rs index a080248d8..5f9f096de 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/nl.rs b/src/lang/nl.rs index d1a2d34fd..e7ae1132b 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", "Uit"), ("api_server_connection_tip", "De door jou gebruikte aangepaste server maakt gebruik van onversleuteld HTTP. Neem contact op met de beheerder om HTTPS in te schakelen."), ("api_server_empty_tip", "Als de API-server leeg is, wordt de aangepaste server standaard ingesteld op HTTP. Neem contact op met de beheerder om HTTPS in te schakelen."), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/pl.rs b/src/lang/pl.rs index 202d075be..f50007d0f 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", "Wyłączone"), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/pt_BR.rs b/src/lang/pt_BR.rs index 78e3132a5..c19d15cbf 100644 --- a/src/lang/pt_BR.rs +++ b/src/lang/pt_BR.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", "Desativado"), ("api_server_connection_tip", "Seu servidor personalizado está usando HTTP sem criptografia. Entre em contato com o administrador para habilitar o HTTPS."), ("api_server_empty_tip", "Quando o campo Servidor de API está vazio, seu servidor personalizado utiliza HTTP por padrão. Entre em contato com o administrador para habilitar o HTTPS."), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index 46d6db05d..a276f4ebb 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", "Desligado"), ("api_server_connection_tip", "O seu servidor personalizado utiliza HTTP não encriptado. Contacte o seu administrador para ativar o HTTPS."), ("api_server_empty_tip", "Com o servidor da API vazio, o seu servidor personalizado utiliza HTTP por predefinição. Contacte o seu administrador para ativar o HTTPS."), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ro.rs b/src/lang/ro.rs index 8a190c6de..29c4a048c 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ru.rs b/src/lang/ru.rs index 6fb51a351..07471a135 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", "откл"), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sc.rs b/src/lang/sc.rs index 0d7b11b46..92054a7a8 100644 --- a/src/lang/sc.rs +++ b/src/lang/sc.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sk.rs b/src/lang/sk.rs index b6361ec56..a396f7402 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sl.rs b/src/lang/sl.rs index 68346a40e..a925caca6 100644 --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sq.rs b/src/lang/sq.rs index 057f51ff7..7537534c5 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sr.rs b/src/lang/sr.rs index b09289399..4a7836ded 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sv.rs b/src/lang/sv.rs index 69ef3cd78..27570ac7c 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ta.rs b/src/lang/ta.rs index fc967030a..f5223752e 100644 --- a/src/lang/ta.rs +++ b/src/lang/ta.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/template.rs b/src/lang/template.rs index d0fe5079a..04249f308 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/th.rs b/src/lang/th.rs index cd989476d..bc71765c6 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/tr.rs b/src/lang/tr.rs index 4e65f8fa1..f6e3a0051 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", "Kapalı"), ("api_server_connection_tip", "Özel sunucunuz şifrelenmemiş HTTP kullanıyor. HTTPS'yi etkinleştirmesi için yöneticinize başvurun."), ("api_server_empty_tip", "API sunucusu alanı boş bırakıldığında özel sunucunuz varsayılan olarak HTTP kullanır. HTTPS'yi etkinleştirmesi için yöneticinize başvurun."), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/tw.rs b/src/lang/tw.rs index 375fbabb5..e6e998478 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", "自訂伺服器正在使用未加密的 HTTP。請聯絡管理員啟用 HTTPS。"), ("api_server_empty_tip", "API 伺服器留空時,自訂伺服器預設使用 HTTP。請聯絡管理員啟用 HTTPS。"), + ("RustDesk storage", "RustDesk 儲存空間"), + ("Other folders", "其他資料夾"), + ("Choose which folder remote devices can access.", "選擇允許遠端裝置存取的資料夾。"), + ("android-logs-folder-tip", "請選擇 Android → media,用於儲存日誌。"), + ("Choose folder", "選擇資料夾"), + ("Please select Android → media.", "請選擇 Android → media。"), ].iter().cloned().collect(); } diff --git a/src/lang/uk.rs b/src/lang/uk.rs index 304a9075f..283212806 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ur.rs b/src/lang/ur.rs index 0c589d720..a1603b525 100644 --- a/src/lang/ur.rs +++ b/src/lang/ur.rs @@ -815,6 +815,12 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/lang/vi.rs b/src/lang/vi.rs index 0ce38a9a4..1bab155fe 100644 --- a/src/lang/vi.rs +++ b/src/lang/vi.rs @@ -815,5 +815,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Off", ""), ("api_server_connection_tip", ""), ("api_server_empty_tip", ""), + ("RustDesk storage", ""), + ("Other folders", ""), + ("Choose which folder remote devices can access.", ""), + ("android-logs-folder-tip", ""), + ("Choose folder", ""), + ("Please select Android → media.", ""), ].iter().cloned().collect(); } diff --git a/src/platform/android_log.rs b/src/platform/android_log.rs new file mode 100644 index 000000000..2b41621d3 --- /dev/null +++ b/src/platform/android_log.rs @@ -0,0 +1,144 @@ +use base::fs::android; +use hbb_common::{ + config::Config, + flexi_logger::{ + opt_format, + writers::{FileLogWriter, LogWriter}, + Age, Cleanup, Criterion, DeferredNow, FileSpec, FlexiLoggerError, Logger, Naming, + }, + log::{Level, LevelFilter, Log, Record}, + log_throttle::LogThrottle, +}; +use std::{ + io, + sync::{ + atomic::{AtomicBool, Ordering}, + Once, + }, + time::Duration, +}; + +const ERROR_INTERVAL: Duration = Duration::from_secs(60); +const DEFAULT_LOG_MAX_BYTES: u64 = 16 * 1024 * 1024; +const DEFAULT_LOG_FILE_COUNT: usize = 90; + +// The first bridge request initializes Kotlin's persisted log-folder selection. +static PUBLIC_LOGS_ENABLED: AtomicBool = AtomicBool::new(true); + +#[no_mangle] +pub extern "system" fn Java_com_carriez_flutter_1hbb_DocumentLogs_setEnabled( + _env: jni::JNIEnv, + _instance: jni::objects::JObject, + enabled: jni::sys::jboolean, +) { + PUBLIC_LOGS_ENABLED.store(enabled != jni::sys::JNI_FALSE, Ordering::Relaxed); +} + +pub fn initialize() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + let result = Logger::try_with_env_or_str("debug,reqwest=warn,hyper_util=warn,rustls=warn,webrtc-sctp=warn,webrtc=warn,webrtc_ice::agent::agent_internal=error,webrtc::peer_connection=error") + .and_then(|logger| { + let default_writer = FileLogWriter::builder(FileSpec::default().directory(Config::log_path())) + .format(opt_format) + .rotate( + Criterion::AgeOrSize(Age::Day, DEFAULT_LOG_MAX_BYTES), + Naming::Timestamps, + Cleanup::KeepLogFiles(DEFAULT_LOG_FILE_COUNT), + ) + .try_build(); + logger.log_to_writer(Box::new(DocumentWriter { default_writer })).start() + }); + if let Err(error) = result { + report_error(&error); + } + }); +} + +struct DocumentWriter { + default_writer: Result, +} + +impl DocumentWriter { + fn write_default(&self, now: &mut DeferredNow, record: &Record) -> io::Result<()> { + self.default_writer + .as_ref() + .map_err(|error| io::Error::new(io::ErrorKind::Other, error.to_string()))? + .write(now, record) + } +} + +impl LogWriter for DocumentWriter { + fn write(&self, now: &mut DeferredNow, record: &Record) -> io::Result<()> { + // JNI logs while detaching threads; calling back into JNI there is unsafe. + if record.target() == "jni" || record.target().starts_with("jni::") { + logcat().log(record); + return Ok(()); + } + if !PUBLIC_LOGS_ENABLED.load(Ordering::Relaxed) { + return self.write_default(now, record); + } + let mut message = Vec::new(); + opt_format(&mut message, now, record)?; + message.push(b'\n'); + let message = String::from_utf8(message) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + let result = android::request(serde_json::json!({ + "op": "log_write", "message": message + })); + match result { + Ok(value) if value.as_bool() == Some(true) => Ok(()), + Ok(value) if value.as_bool() == Some(false) => self.write_default(now, record), + result => { + let error = match result { + Err(error) => error.to_string(), + _ => "Invalid document logger response".to_owned(), + }; + let result = self.write_default(now, record).map_err(|default_error| { + io::Error::new( + io::ErrorKind::Other, + format!( + "Public log directory: {error}; default log directory: {default_error}" + ), + ) + }); + match &result { + Ok(()) => report_error(&format!( + "{error}; writing to the original default log directory" + )), + Err(error) => report_error(error), + } + result + } + } + } + + fn flush(&self) -> io::Result<()> { + self.default_writer + .as_ref() + .map_or(Ok(()), |writer| writer.flush()) + } +} + +fn logcat() -> android_logger::AndroidLogger { + android_logger::AndroidLogger::new( + android_logger::Config::default() + .with_max_level(LevelFilter::Debug) + .with_tag("ffi"), + ) +} + +fn report_error(error: &dyn std::fmt::Display) { + static ERRORS: LogThrottle = LogThrottle::new(ERROR_INTERVAL); + if let Some(count) = ERRORS.due() { + // Reporting through log::error! here would call this writer recursively. + logcat().log( + &Record::builder() + .level(Level::Error) + .args(format_args!( + "Android file logging failed: {error} (x{count})" + )) + .build(), + ); + } +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 2be25e4d0..add10e78f 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -23,6 +23,9 @@ pub mod linux; #[cfg(target_os = "linux")] pub mod gtk_sudo; +#[cfg(target_os = "android")] +pub mod android_log; + #[cfg(not(any(target_os = "android", target_os = "ios")))] use base::message_proto::CursorData; #[cfg(all( diff --git a/src/server/connection.rs b/src/server/connection.rs index fd7cfec35..b0cbcb6dd 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -2182,12 +2182,18 @@ impl Connection { if let Some((dir, show_hidden)) = self.file_transfer.clone() { self.keyboard = false; let is_existing_dir = !dir.is_empty() && std::path::Path::new(&dir).is_dir(); + #[cfg(target_os = "android")] + let is_existing_dir = is_existing_dir || fs::android::is_path(std::path::Path::new(&dir)); + #[cfg(target_os = "android")] + let is_allowed_dir = + is_existing_dir && crate::common::is_peer_path_allowed_async(&dir, false).await; + #[cfg(not(target_os = "android"))] 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: {}", + "Use the default file-transfer location because access to the initial directory was denied: {}", dir ); } @@ -3597,7 +3603,9 @@ impl Connection { _ => None, }; if let Some((path, job_id, allow_empty)) = checked { - if !crate::common::is_peer_path_allowed(path, allow_empty) { + if !crate::common::is_peer_path_allowed_async(path, allow_empty) + .await + { log::warn!( "Reject file action outside the app workspace: {}", path @@ -3613,12 +3621,15 @@ impl Connection { let destination = std::path::Path::new(&r.path) .parent() .map(|parent| parent.join(&r.new_name)); - let allowed = destination + let allowed = match destination .as_deref() .and_then(std::path::Path::to_str) - .map_or(false, |path| { - crate::common::is_peer_path_allowed(path, false) - }); + { + Some(path) => { + crate::common::is_peer_path_allowed_async(path, false).await + } + None => false, + }; if !allowed { log::warn!( "Reject rename destination outside the app workspace: {:?}", @@ -3638,6 +3649,16 @@ impl Connection { self.read_dir(&rd.path, rd.include_hidden); } Some(file_action::Union::AllFiles(f)) => { + #[cfg(target_os = "android")] + if fs::android::is_path(std::path::Path::new(&f.path)) { + self.send_fs(ipc::FS::ReadAllFiles { + path: f.path, + id: f.id, + include_hidden: f.include_hidden, + conn_id: self.inner.id(), + }); + return true; + } if crate::common::need_fs_cm_send_files() { self.send_fs(ipc::FS::ReadAllFiles { path: f.path, @@ -5592,7 +5613,32 @@ impl Connection { path: String, check_file_limit: bool, ) { - match fs::TransferJob::new_read( + #[cfg(target_os = "android")] + let result = { + let is_document = matches!(&data_source, fs::DataSource::FilePath(path) if fs::android::is_path(path)); + let read = move || { + fs::TransferJob::new_read( + id, + job_type, + "".to_string(), + data_source, + file_num, + include_hidden, + false, + overwrite_detection, + ) + }; + if is_document { + tokio::task::spawn_blocking(read) + .await + .map_err(hbb_common::anyhow::Error::from) + .and_then(|result| result) + } else { + read() + } + }; + #[cfg(not(target_os = "android"))] + let result = fs::TransferJob::new_read( id, job_type, "".to_string(), @@ -5601,7 +5647,8 @@ impl Connection { include_hidden, false, overwrite_detection, - ) { + ); + match result { Err(err) => { self.send(fs::new_error(id, err, 0)).await; } diff --git a/src/ui_cm_interface.rs b/src/ui_cm_interface.rs index 3242e1183..64e13705b 100644 --- a/src/ui_cm_interface.rs +++ b/src/ui_cm_interface.rs @@ -968,6 +968,19 @@ pub async fn start_listen( } } cm.remove_connection(current_id, true); + for job in &mut write_jobs { + if let Err(error) = job.cancel_document().await { + const CLEANUP_ERROR_LOG_INTERVAL: std::time::Duration = + std::time::Duration::from_secs(60); + hbb_common::throttled_log!( + CLEANUP_ERROR_LOG_INTERVAL, + warn, + "Failed to clean up document transfer {} on disconnect: {}", + job.id(), + error + ); + } + } } #[cfg(not(any(target_os = "ios")))] @@ -1006,7 +1019,7 @@ async fn handle_fs( _ => None, }; if let Some((path, id, file_num, allow_empty)) = checked { - if !crate::common::is_peer_path_allowed(path, allow_empty) { + if !crate::common::is_peer_path_allowed_async(path, allow_empty).await { log::warn!("Reject file operation outside the app workspace: {}", path); if id >= 0 { send_raw(fs::new_error(id, "Permission denied", file_num), tx); @@ -1018,12 +1031,10 @@ async fn handle_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) - }); + let allowed = match destination.as_deref().and_then(std::path::Path::to_str) { + Some(path) => crate::common::is_peer_path_allowed_async(path, false).await, + None => false, + }; if !allowed { log::warn!( "Reject rename destination outside the app workspace: {:?}", @@ -1102,6 +1113,12 @@ async fn handle_fs( } ipc::FS::CancelWrite { id } => { if let Some(job) = fs::remove_job(id, write_jobs) { + #[cfg(target_os = "android")] + let mut job = job; + #[cfg(target_os = "android")] + if let Err(error) = job.cancel_document().await { + send_raw(fs::new_error(id, error, job.file_num()), tx); + } job.remove_download_file(); if let Some(tx) = tx_log { if let Err(e) = tx.send(serialize_transfer_job(&job, false, true, "")) { @@ -1112,6 +1129,13 @@ async fn handle_fs( } ipc::FS::WriteDone { id, file_num } => { if let Some(job) = fs::remove_job(id, write_jobs) { + #[cfg(target_os = "android")] + let mut job = job; + #[cfg(target_os = "android")] + if let Err(error) = job.finish_document().await { + send_raw(fs::new_error(id, error, file_num), tx); + return; + } job.modify_time(); send_raw(fs::new_done(id, file_num), tx); tx_log.map(|tx| tx.send(serialize_transfer_job(&job, true, false, ""))); @@ -1119,6 +1143,20 @@ async fn handle_fs( } ipc::FS::WriteError { id, file_num, err } => { if let Some(job) = fs::remove_job(id, write_jobs) { + #[cfg(target_os = "android")] + let mut job = job; + #[cfg(target_os = "android")] + if let Err(error) = job.cancel_document().await { + const CLEANUP_ERROR_LOG_INTERVAL: std::time::Duration = + std::time::Duration::from_secs(60); + hbb_common::throttled_log!( + CLEANUP_ERROR_LOG_INTERVAL, + warn, + "Failed to clean up document transfer {}: {}", + id, + error + ); + } tx_log.map(|tx| tx.send(serialize_transfer_job(&job, false, false, &err))); send_raw(fs::new_error(job.id(), err, file_num), tx); } @@ -1169,7 +1207,20 @@ async fn handle_fs( if let Some(file) = job.files().get(file_num as usize) { if let fs::DataSource::FilePath(p) = &job.data_source { let path = get_string(&fs::TransferJob::join(p, &file.name)); - match is_write_need_confirmation(is_resume, &path, &digest) { + #[cfg(target_os = "android")] + let digest_result = if fs::android::is_path(path.as_ref()) { + spawn_blocking(move || { + is_write_need_confirmation(is_resume, &path, &digest) + }) + .await + .map_err(hbb_common::anyhow::Error::from) + .and_then(|result| result) + } else { + is_write_need_confirmation(is_resume, &path, &digest) + }; + #[cfg(not(target_os = "android"))] + let digest_result = is_write_need_confirmation(is_resume, &path, &digest); + match digest_result { Ok(digest_result) => { job.set_digest(file_size, last_modified); match digest_result { @@ -1598,7 +1649,11 @@ async fn read_empty_dirs(dir: &str, include_hidden: bool, tx: &UnboundedSender) { let path = { if dir.is_empty() { - Config::get_home() + #[cfg(target_os = "android")] + let home = fs::get_path(&fs::get_home_as_string()); + #[cfg(not(target_os = "android"))] + let home = Config::get_home(); + home } else { fs::get_path(dir) } @@ -1676,6 +1731,14 @@ async fn remove_dir(path: String, id: i32, recursive: bool, tx: &UnboundedSender let path = fs::get_path(&path); handle_result( spawn_blocking(move || { + #[cfg(target_os = "android")] + if fs::android::is_path(&path) { + return if recursive { + fs::android::remove_empty_tree(&fs::get_string(&path)) + } else { + fs::android::remove_empty_dir(&fs::get_string(&path)) + }; + } if recursive { fs::remove_all_empty_dir(&path) } else {