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:
2026-08-21 19:08:36 +02:00
co-authored by Claude Opus 5
parent 5f0fe7b403
commit 5dff2500e2
4 changed files with 130 additions and 15 deletions
+123 -15
View File
@@ -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(