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>
This commit is contained in:
Lucas Fernandes Nogueira
2025-03-18 08:29:07 -03:00
committed by GitHub
parent 71cb1e26d7
commit bcdd510254
6 changed files with 123 additions and 11 deletions

View File

@@ -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.

View File

@@ -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).

View File

@@ -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
}

View File

@@ -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<R: Runtime>(pub(crate) PluginHandle<R>);
@@ -20,7 +23,47 @@ struct PathResponse {
path: PathBuf,
}
#[derive(serde::Serialize)]
struct GetFileNameFromUriRequest<'a> {
uri: &'a str,
}
#[derive(serde::Deserialize)]
struct GetFileNameFromUriResponse {
name: Option<String>,
}
impl<R: Runtime> PathResolver<R> {
/// 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<String> {
if path.starts_with("content://") || path.starts_with("file://") {
self
.0
.run_mobile_plugin::<GetFileNameFromUriResponse>(
"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<PathBuf> {
self
.0

View File

@@ -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<R: Runtime>(pub(crate) AppHandle<R>);
@@ -16,6 +16,22 @@ impl<R: Runtime> Clone for PathResolver<R> {
}
impl<R: Runtime> PathResolver<R> {
/// 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<String> {
Path::new(path)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
}
/// Returns the path to the user's audio directory.
///
/// ## Platform-specific

View File

@@ -169,8 +169,9 @@ pub fn dirname(path: String) -> Result<PathBuf> {
}
#[command(root = "crate")]
pub fn extname(path: String) -> Result<String> {
match Path::new(&path)
pub fn extname<R: Runtime>(app: AppHandle<R>, path: String) -> Result<String> {
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<String> {
}
#[command(root = "crate")]
pub fn basename(path: &str, ext: Option<&str>) -> Result<String> {
let file_name = Path::new(path).file_name().map(|f| f.to_string_lossy());
pub fn basename<R: Runtime>(app: AppHandle<R>, path: &str, ext: Option<&str>) -> Result<String> {
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<R: Runtime>() -> TauriPlugin<R> {
#[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"
);
}