Open picked files through the content resolver, not std::fs
Loading a GPX on Android failed for every file in the picker. The dialog
plugin fires ACTION_GET_CONTENT, which returns a `content://` URI, and
`load_profile_from_path` handed that straight to `std::fs::read_to_string`
— "no such file or directory" for a file the rider is looking at. Picking
from Nextcloud makes it plainer: a document provider backed by a server
may have no local file at all until the resolver opens the stream, so
there was never a path to find.
So the command now takes a `FilePath` and reads through tauri-plugin-fs,
which opens a path directly and a URI via the resolver. The plugin is
here for `FsExt` alone; nothing in ui/ calls its commands, so the
capabilities are unchanged.
Two things that were derived from the filename can no longer be:
- GPX is detected by content. A document id need not contain a name,
let alone an extension. No YAML profile begins with `<`.
- The route name falls back to the GPX's own <name>. Providers over
real storage encode the filename in the last segment, but an opaque
row id would have made a wretched route name.
save_fit had the same bug on the export side — PathBuf::from on a
save-dialog URI — and now writes down a resolver descriptor when handed
one.
Note for anyone rebuilding locally: gen/android/tauri.settings.gradle is
autogenerated and lists each plugin's Android project, so the new
plugin's Kotlin only reaches the APK after `cargo tauri android init` and
scripts/sync-android-sources.sh. CI already runs both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+1
@@ -255,6 +255,7 @@ dependencies = [
|
|||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
|
"tauri-plugin-fs",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ bikecontrol-fit = { workspace = true }
|
|||||||
|
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
|
# Not for the frontend — nothing in `ui/` calls the fs commands. This is here
|
||||||
|
# for `FsExt::read_to_string`, the one API that can read what the *picker*
|
||||||
|
# returns on Android: a `content://` URI rather than a path. See
|
||||||
|
# `read_picked_file` in commands.rs.
|
||||||
|
tauri-plugin-fs = "2"
|
||||||
|
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
|||||||
+123
-15
@@ -4,12 +4,13 @@
|
|||||||
//! they mutate Rust-side state and the resulting truth comes back on the event
|
//! they mutate Rust-side state and the resulting truth comes back on the event
|
||||||
//! channel. The UI never assumes a command took effect (§4.3).
|
//! channel. The UI never assumes a command took effect (§4.3).
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use bikecontrol_core::gpx::{self, SmoothingConfig};
|
use bikecontrol_core::gpx::{self, SmoothingConfig};
|
||||||
use bikecontrol_core::profile::Profile;
|
use bikecontrol_core::profile::Profile;
|
||||||
use bikecontrol_core::types::{ControlMode, RiderConfig, SafetyLimits};
|
use bikecontrol_core::types::{ControlMode, RiderConfig, SafetyLimits};
|
||||||
use tauri::{AppHandle, State};
|
use tauri::{AppHandle, State};
|
||||||
|
use tauri_plugin_fs::{FilePath, FsExt, OpenOptions};
|
||||||
|
|
||||||
use bikecontrol_ble::PodId;
|
use bikecontrol_ble::PodId;
|
||||||
|
|
||||||
@@ -188,8 +189,7 @@ pub fn ride_summary(state: State<'_, AppState>) -> Option<RideSummary> {
|
|||||||
/// Returns the path actually written, so the UI can confirm it rather than
|
/// Returns the path actually written, so the UI can confirm it rather than
|
||||||
/// claiming success against a path it merely proposed.
|
/// claiming success against a path it merely proposed.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: String) -> Cmd<String> {
|
pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: FilePath) -> Cmd<String> {
|
||||||
let dest = PathBuf::from(&path);
|
|
||||||
let source = {
|
let source = {
|
||||||
let inner = state.lock();
|
let inner = state.lock();
|
||||||
let summary = inner
|
let summary = inner
|
||||||
@@ -199,8 +199,15 @@ pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: String) -> Cmd
|
|||||||
PathBuf::from(&summary.fit_path)
|
PathBuf::from(&summary.fit_path)
|
||||||
};
|
};
|
||||||
|
|
||||||
recording::save_copy(&source, &dest)?;
|
match &path {
|
||||||
let written = dest.display().to_string();
|
FilePath::Path(dest) => recording::save_copy(&source, dest)?,
|
||||||
|
// Android: the save dialog returns a `content://` URI for a document
|
||||||
|
// the provider has already created. There is no directory to make and
|
||||||
|
// no path to copy to — the bytes go down a descriptor the resolver
|
||||||
|
// opens, which is the same reason `read_picked_file` exists.
|
||||||
|
FilePath::Url(_) => write_through_resolver(&app, &path, &source)?,
|
||||||
|
}
|
||||||
|
let written = path.to_string();
|
||||||
if let Some(summary) = state.lock().last_summary.as_mut() {
|
if let Some(summary) = state.lock().last_summary.as_mut() {
|
||||||
summary.saved_path = Some(written.clone());
|
summary.saved_path = Some(written.clone());
|
||||||
}
|
}
|
||||||
@@ -209,6 +216,28 @@ pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: String) -> Cmd
|
|||||||
Ok(written)
|
Ok(written)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copy the activity into a document the rider chose from an Android picker.
|
||||||
|
///
|
||||||
|
/// The `std::fs` path in `recording::save_copy` cannot do this: there is no
|
||||||
|
/// filesystem path on the other end, only a URI the content resolver can turn
|
||||||
|
/// into a writable descriptor. Still a copy, never a move, for the reason
|
||||||
|
/// `save_copy` documents — the automatic file in the rides directory has to
|
||||||
|
/// survive a failed export.
|
||||||
|
fn write_through_resolver(app: &AppHandle, dest: &FilePath, source: &Path) -> Result<(), String> {
|
||||||
|
let mut from = std::fs::File::open(source)
|
||||||
|
.map_err(|e| format!("{} is gone — nothing to save: {e}", source.display()))?;
|
||||||
|
let mut to = app
|
||||||
|
.fs()
|
||||||
|
.open(
|
||||||
|
dest.clone(),
|
||||||
|
OpenOptions::new().write(true).truncate(true).clone(),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("could not save to {dest}: {e}"))?;
|
||||||
|
std::io::copy(&mut from, &mut to)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| format!("could not save to {dest}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn reset_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
pub fn reset_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||||||
state.lock().reset_ride();
|
state.lock().reset_ride();
|
||||||
@@ -453,22 +482,101 @@ fn parse_profile(text: &str, name: &str, is_gpx: bool) -> Result<Profile, String
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load a profile from a path on disk. GPX is detected by extension, everything
|
/// Read a file the rider picked, wherever it actually lives.
|
||||||
/// else is treated as the YAML profile format.
|
///
|
||||||
|
/// `std::fs` is not enough, and Android is why. The dialog plugin's picker
|
||||||
|
/// fires `ACTION_GET_CONTENT`, which hands back a `content://` URI rather than
|
||||||
|
/// a path; `std::fs::read_to_string` on one of those fails with "no such file
|
||||||
|
/// or directory" — an error the rider gets for a file they are looking at in
|
||||||
|
/// the picker. And for a provider backed by a server rather than storage
|
||||||
|
/// (Nextcloud, Drive) there may be no local file at all until the resolver
|
||||||
|
/// opens the stream, so no amount of path-guessing could have found one.
|
||||||
|
///
|
||||||
|
/// `tauri_plugin_fs` is the piece that knows the difference: a plain path is
|
||||||
|
/// opened directly, a URI goes through the Android content resolver for a file
|
||||||
|
/// descriptor. On desktop it is `std::fs` with extra steps.
|
||||||
|
fn read_picked_file(app: &AppHandle, path: &FilePath) -> Result<String, String> {
|
||||||
|
app.fs()
|
||||||
|
.read_to_string(path.clone())
|
||||||
|
.map_err(|e| format!("{path}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a picked file is GPX, decided by content rather than by name.
|
||||||
|
///
|
||||||
|
/// The extension is not always there to read: a `content://` URI carries a
|
||||||
|
/// document id, which for some providers contains no filename at all. XML is
|
||||||
|
/// unmistakable next to the YAML profile format — no YAML document begins with
|
||||||
|
/// `<` — so the first non-space character is the reliable test and the
|
||||||
|
/// extension is only a fast path.
|
||||||
|
fn looks_like_gpx(path: &FilePath, text: &str) -> bool {
|
||||||
|
path.to_string().to_ascii_lowercase().ends_with(".gpx") || text.trim_start().starts_with('<')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The filename behind a picked file, if there is one to be had.
|
||||||
|
///
|
||||||
|
/// A `FilePath::Path` always has a stem. A URI might: providers over real
|
||||||
|
/// storage encode the path in the last segment, percent-escaped but with the
|
||||||
|
/// extension intact (`primary%3ADownload%2Fventoux.gpx`). Others use opaque row
|
||||||
|
/// ids, which would make a terrible route name — so "does it end in an
|
||||||
|
/// extension we know" is the test, and anything else gets `None` and falls back
|
||||||
|
/// to the name inside the GPX.
|
||||||
|
fn picked_file_stem(path: &FilePath) -> Option<String> {
|
||||||
|
match path {
|
||||||
|
FilePath::Path(p) => p.file_stem().map(|s| s.to_string_lossy().to_string()),
|
||||||
|
FilePath::Url(url) => {
|
||||||
|
let (stem, ext) = url.path_segments()?.next_back()?.rsplit_once('.')?;
|
||||||
|
if !matches!(ext.to_ascii_lowercase().as_str(), "gpx" | "yaml" | "yml") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// The escaped separators are all that stands between the document
|
||||||
|
// id and the name inside it.
|
||||||
|
let decoded = stem
|
||||||
|
.replace("%2F", "/")
|
||||||
|
.replace("%2f", "/")
|
||||||
|
.replace("%3A", ":")
|
||||||
|
.replace("%3a", ":");
|
||||||
|
let name = decoded.rsplit(['/', ':']).next().unwrap_or(&decoded);
|
||||||
|
(!name.is_empty()).then(|| name.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The route's own name: `<metadata><name>`, else the first `<trk><name>`.
|
||||||
|
///
|
||||||
|
/// The fallback when the picker gave us no filename to use. Matches on local
|
||||||
|
/// names so a namespaced document (`<g:trk>`) is not silently skipped, for the
|
||||||
|
/// same reason `core::gpx` does.
|
||||||
|
fn gpx_name(xml: &str) -> Option<String> {
|
||||||
|
let doc = roxmltree::Document::parse(xml).ok()?;
|
||||||
|
let named = |parent: &str| {
|
||||||
|
doc.descendants()
|
||||||
|
.find(|n| n.is_element() && n.tag_name().name() == parent)?
|
||||||
|
.children()
|
||||||
|
.find(|c| c.is_element() && c.tag_name().name() == "name")?
|
||||||
|
.text()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
};
|
||||||
|
named("metadata").or_else(|| named("trk"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a profile the rider picked: a path on desktop, a `content://` URI on
|
||||||
|
/// Android. GPX is detected by content, everything else is treated as the YAML
|
||||||
|
/// profile format.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn load_profile_from_path(
|
pub fn load_profile_from_path(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
path: String,
|
path: FilePath,
|
||||||
) -> Cmd<ProfileView> {
|
) -> Cmd<ProfileView> {
|
||||||
let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?;
|
let text = read_picked_file(&app, &path)?;
|
||||||
let stem = std::path::Path::new(&path)
|
let is_gpx = looks_like_gpx(&path, &text);
|
||||||
.file_stem()
|
let name = picked_file_stem(&path)
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
.or_else(|| gpx_name(&text))
|
||||||
.unwrap_or_else(|| "Profile".into());
|
.unwrap_or_else(|| "Profile".into());
|
||||||
let is_gpx = path.to_ascii_lowercase().ends_with(".gpx");
|
let profile = parse_profile(&text, &name, is_gpx)?;
|
||||||
let profile = parse_profile(&text, &stem, is_gpx)?;
|
let (view, geom) = profile_view::build(&profile, path.to_string());
|
||||||
let (view, geom) = profile_view::build(&profile, path);
|
|
||||||
state.lock().set_profile(profile, view.clone(), geom);
|
state.lock().set_profile(profile, view.clone(), geom);
|
||||||
emit_ride_state(&app);
|
emit_ride_state(&app);
|
||||||
notify(
|
notify(
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ pub fn run() {
|
|||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_fs::init())
|
||||||
.manage(AppState::new())
|
.manage(AppState::new())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
// ride lifecycle
|
// ride lifecycle
|
||||||
|
|||||||
Reference in New Issue
Block a user