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:
+123
-15
@@ -4,12 +4,13 @@
|
||||
//! 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).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bikecontrol_core::gpx::{self, SmoothingConfig};
|
||||
use bikecontrol_core::profile::Profile;
|
||||
use bikecontrol_core::types::{ControlMode, RiderConfig, SafetyLimits};
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri_plugin_fs::{FilePath, FsExt, OpenOptions};
|
||||
|
||||
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
|
||||
/// claiming success against a path it merely proposed.
|
||||
#[tauri::command]
|
||||
pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: String) -> Cmd<String> {
|
||||
let dest = PathBuf::from(&path);
|
||||
pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: FilePath) -> Cmd<String> {
|
||||
let source = {
|
||||
let inner = state.lock();
|
||||
let summary = inner
|
||||
@@ -199,8 +199,15 @@ pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: String) -> Cmd
|
||||
PathBuf::from(&summary.fit_path)
|
||||
};
|
||||
|
||||
recording::save_copy(&source, &dest)?;
|
||||
let written = dest.display().to_string();
|
||||
match &path {
|
||||
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() {
|
||||
summary.saved_path = Some(written.clone());
|
||||
}
|
||||
@@ -209,6 +216,28 @@ pub fn save_fit(app: AppHandle, state: State<'_, AppState>, path: String) -> Cmd
|
||||
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]
|
||||
pub fn reset_ride(app: AppHandle, state: State<'_, AppState>) -> Cmd<RideState> {
|
||||
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
|
||||
/// else is treated as the YAML profile format.
|
||||
/// Read a file the rider picked, wherever it actually lives.
|
||||
///
|
||||
/// `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]
|
||||
pub fn load_profile_from_path(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
path: String,
|
||||
path: FilePath,
|
||||
) -> Cmd<ProfileView> {
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let stem = std::path::Path::new(&path)
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
let text = read_picked_file(&app, &path)?;
|
||||
let is_gpx = looks_like_gpx(&path, &text);
|
||||
let name = picked_file_stem(&path)
|
||||
.or_else(|| gpx_name(&text))
|
||||
.unwrap_or_else(|| "Profile".into());
|
||||
let is_gpx = path.to_ascii_lowercase().ends_with(".gpx");
|
||||
let profile = parse_profile(&text, &stem, is_gpx)?;
|
||||
let (view, geom) = profile_view::build(&profile, path);
|
||||
let profile = parse_profile(&text, &name, is_gpx)?;
|
||||
let (view, geom) = profile_view::build(&profile, path.to_string());
|
||||
state.lock().set_profile(profile, view.clone(), geom);
|
||||
emit_ride_state(&app);
|
||||
notify(
|
||||
|
||||
Reference in New Issue
Block a user