From bcdd510254ebe37827e22a5ffeb944321361e97c Mon Sep 17 00:00:00 2001 From: Lucas Fernandes Nogueira Date: Tue, 18 Mar 2025 08:29:07 -0300 Subject: [PATCH] feat(core): resolve file names from Android content URIs (#13012) * feat(core): resolve file names from Android content URIs This PR adds a new Android path plugin function to resolve file names from content URIs. `PathResolver::file_name` was added to expose this API on Rust, and the existing `@tauri-apps/api/path` basename and extname function now leverages it on Android. Closes https://github.com/tauri-apps/plugins-workspace/issues/1775 Tauri core port from https://github.com/tauri-apps/plugins-workspace/pull/2421 Co-authored-by: VulnX * update change file [skip ci] Co-authored-by: VulnX <62636727+VulnX@users.noreply.github.com> --------- Co-authored-by: VulnX <62636727+VulnX@users.noreply.github.com> --- .changes/path-file-name-android-api.md | 6 +++ .changes/path-file-name-android.md | 5 +++ .../src/main/java/app/tauri/PathPlugin.kt | 39 ++++++++++++++++ crates/tauri/src/path/android.rs | 45 ++++++++++++++++++- crates/tauri/src/path/desktop.rs | 18 +++++++- crates/tauri/src/path/plugin.rs | 21 +++++---- 6 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 .changes/path-file-name-android-api.md create mode 100644 .changes/path-file-name-android.md diff --git a/.changes/path-file-name-android-api.md b/.changes/path-file-name-android-api.md new file mode 100644 index 000000000..a27ab989e --- /dev/null +++ b/.changes/path-file-name-android-api.md @@ -0,0 +1,6 @@ +--- +"tauri": minor:feat +"@tauri-apps/api": minor:feat +--- + +The `path` basename and extname APIs now accept Android content URIs, such as the paths returned by the dialog plugin. diff --git a/.changes/path-file-name-android.md b/.changes/path-file-name-android.md new file mode 100644 index 000000000..64aa03b9b --- /dev/null +++ b/.changes/path-file-name-android.md @@ -0,0 +1,5 @@ +--- +"tauri": minor:feat +--- + +Added `PathResolver::file_name` to resolve file names from content URIs on Android (leverating `std::path::Path::file_name` on other platforms). diff --git a/crates/tauri/mobile/android/src/main/java/app/tauri/PathPlugin.kt b/crates/tauri/mobile/android/src/main/java/app/tauri/PathPlugin.kt index 6e4b249d6..1ce1c2f06 100644 --- a/crates/tauri/mobile/android/src/main/java/app/tauri/PathPlugin.kt +++ b/crates/tauri/mobile/android/src/main/java/app/tauri/PathPlugin.kt @@ -5,9 +5,13 @@ package app.tauri import android.app.Activity +import android.database.Cursor +import android.net.Uri import android.os.Build import android.os.Environment +import android.provider.OpenableColumns import app.tauri.annotation.Command +import app.tauri.annotation.InvokeArg import app.tauri.annotation.TauriPlugin import app.tauri.plugin.Plugin import app.tauri.plugin.Invoke @@ -15,6 +19,11 @@ import app.tauri.plugin.JSObject const val TAURI_ASSETS_DIRECTORY_URI = "asset://localhost/" +@InvokeArg +class GetFileNameFromUriArgs { + lateinit var uri: String +} + @TauriPlugin class PathPlugin(private val activity: Activity): Plugin(activity) { private fun resolvePath(invoke: Invoke, path: String?) { @@ -23,6 +32,15 @@ class PathPlugin(private val activity: Activity): Plugin(activity) { invoke.resolve(obj) } + @Command + fun getFileNameFromUri(invoke: Invoke) { + val args = invoke.parseArgs(GetFileNameFromUriArgs::class.java) + val name = getRealNameFromURI(activity, Uri.parse(args.uri)) + val res = JSObject() + res.put("name", name) + invoke.resolve(res) + } + @Command fun getAudioDir(invoke: Invoke) { resolvePath(invoke, activity.getExternalFilesDir(Environment.DIRECTORY_MUSIC)?.absolutePath) @@ -91,3 +109,24 @@ class PathPlugin(private val activity: Activity): Plugin(activity) { resolvePath(invoke, Environment.getExternalStorageDirectory().absolutePath) } } + +fun getRealNameFromURI(activity: Activity, contentUri: Uri): String? { + var cursor: Cursor? = null + try { + val projection = arrayOf(OpenableColumns.DISPLAY_NAME) + cursor = activity.contentResolver.query(contentUri, projection, null, null, null) + + cursor?.let { + val columnIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (it.moveToFirst()) { + return it.getString(columnIndex) + } + } + } catch (e: Exception) { + Logger.error("failed to get real name from URI $e") + } finally { + cursor?.close() + } + + return null // Return null if no file name could be resolved +} diff --git a/crates/tauri/src/path/android.rs b/crates/tauri/src/path/android.rs index 902208fe8..03b1f5f93 100644 --- a/crates/tauri/src/path/android.rs +++ b/crates/tauri/src/path/android.rs @@ -4,7 +4,10 @@ use super::Result; use crate::{plugin::PluginHandle, Runtime}; -use std::path::PathBuf; +use std::{ + ffi::OsStr, + path::{Path, PathBuf}, +}; /// A helper class to access the mobile path APIs. pub struct PathResolver(pub(crate) PluginHandle); @@ -20,7 +23,47 @@ struct PathResponse { path: PathBuf, } +#[derive(serde::Serialize)] +struct GetFileNameFromUriRequest<'a> { + uri: &'a str, +} + +#[derive(serde::Deserialize)] +struct GetFileNameFromUriResponse { + name: Option, +} + impl PathResolver { + /// Returns the final component of the `Path`, if there is one. + /// + /// If the path is a normal file, this is the file name. If it's the path of a directory, this + /// is the directory name. + /// + /// Returns [`None`] if the path terminates in `..`. + /// + /// On Android this also supports checking the file name of content URIs, such as the values returned by the dialog plugin. + /// + /// If you are dealing with plain file system paths or not worried about Android content URIs, prefer [`Path::file_name`]. + pub fn file_name(&self, path: &str) -> Option { + if path.starts_with("content://") || path.starts_with("file://") { + self + .0 + .run_mobile_plugin::( + "getFileNameFromUri", + GetFileNameFromUriRequest { uri: path }, + ) + .map(|r| r.name) + .unwrap_or_else(|e| { + log::error!("failed to get file name from URI: {e}"); + None + }) + } else { + Path::new(path) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + } + } + fn call_resolve(&self, dir: &str) -> Result { self .0 diff --git a/crates/tauri/src/path/desktop.rs b/crates/tauri/src/path/desktop.rs index 7a114fd52..8cb6c3045 100644 --- a/crates/tauri/src/path/desktop.rs +++ b/crates/tauri/src/path/desktop.rs @@ -4,7 +4,7 @@ use super::{Error, Result}; use crate::{AppHandle, Manager, Runtime}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// The path resolver is a helper class for general and application-specific path APIs. pub struct PathResolver(pub(crate) AppHandle); @@ -16,6 +16,22 @@ impl Clone for PathResolver { } impl PathResolver { + /// Returns the final component of the `Path`, if there is one. + /// + /// If the path is a normal file, this is the file name. If it's the path of a directory, this + /// is the directory name. + /// + /// Returns [`None`] if the path terminates in `..`. + /// + /// On Android this also supports checking the file name of content URIs, such as the values returned by the dialog plugin. + /// + /// If you are dealing with plain file system paths or not worried about Android content URIs, prefer [`Path::file_name`]. + pub fn file_name(&self, path: &str) -> Option { + Path::new(path) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + } + /// Returns the path to the user's audio directory. /// /// ## Platform-specific diff --git a/crates/tauri/src/path/plugin.rs b/crates/tauri/src/path/plugin.rs index 660006def..c214ce144 100644 --- a/crates/tauri/src/path/plugin.rs +++ b/crates/tauri/src/path/plugin.rs @@ -169,8 +169,9 @@ pub fn dirname(path: String) -> Result { } #[command(root = "crate")] -pub fn extname(path: String) -> Result { - match Path::new(&path) +pub fn extname(app: AppHandle, path: String) -> Result { + let file_name = app.path().file_name(&path).ok_or(Error::NoExtension)?; + match Path::new(&file_name) .extension() .and_then(std::ffi::OsStr::to_str) { @@ -180,8 +181,8 @@ pub fn extname(path: String) -> Result { } #[command(root = "crate")] -pub fn basename(path: &str, ext: Option<&str>) -> Result { - let file_name = Path::new(path).file_name().map(|f| f.to_string_lossy()); +pub fn basename(app: AppHandle, path: &str, ext: Option<&str>) -> Result { + let file_name = app.path().file_name(path); match file_name { Some(p) => { let maybe_stripped = if let Some(ext) = ext { @@ -251,36 +252,38 @@ pub(crate) fn init() -> TauriPlugin { #[cfg(test)] mod tests { + use crate::test::mock_app; #[test] fn basename() { + let app = mock_app(); let path = "/path/to/some-json-file.json"; assert_eq!( - super::basename(path, Some(".json")).unwrap(), + super::basename(app.handle().clone(), path, Some(".json")).unwrap(), "some-json-file" ); let path = "/path/to/some-json-file.json"; assert_eq!( - super::basename(path, Some("json")).unwrap(), + super::basename(app.handle().clone(), path, Some("json")).unwrap(), "some-json-file." ); let path = "/path/to/some-json-file.html.json"; assert_eq!( - super::basename(path, Some(".json")).unwrap(), + super::basename(app.handle().clone(), path, Some(".json")).unwrap(), "some-json-file.html" ); let path = "/path/to/some-json-file.json.json"; assert_eq!( - super::basename(path, Some(".json")).unwrap(), + super::basename(app.handle().clone(), path, Some(".json")).unwrap(), "some-json-file.json" ); let path = "/path/to/some-json-file.json.html"; assert_eq!( - super::basename(path, Some(".json")).unwrap(), + super::basename(app.handle().clone(), path, Some(".json")).unwrap(), "some-json-file.json.html" ); }