Merge: answer Android's memory warnings, and stop reporting a lost root as an empty library

FR-PLAT-AND-5 in full, FR-PLAT-AND-2 in part -- the recovery is built and
live for Nextcloud roots, the SAF cause it names does not exist yet.

FR-PLAT-AND-4 and FR-PLAT-AND-6 are not here, both blocked behind the
same gap: assemble-apk.sh compiles no Java, so the APK cannot carry a
Service or a FileProvider. The container has JDK 17 and build-tools 36;
the build step is what is missing.

Verified: fmt, clippy --workspace --all-targets -D warnings, and 1043
tests across dr-catalog, dr-sync, dr-sync-folder, dr-sync-nextcloud,
dr-plat and dr-ui. The aarch64 target was checked before the branch was
finished but not after; no device was available.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 22:19:46 +02:00
co-authored by Claude Opus 5
16 changed files with 1002 additions and 39 deletions
+20 -5
View File
@@ -216,10 +216,17 @@ impl std::fmt::Debug for FolderBackend {
impl FolderBackend {
/// Open the folder at `root`.
///
/// The directory must exist now. It may stop existing later — a drive
/// unplugged, a mount dropped — and that surfaces per-operation as
/// [`RemoteError::Network`], which is what puts the app into offline mode
/// and leaves the catalog readable, exactly as a dead server does.
/// The directory must exist now, and not existing is
/// [`RemoteError::RootUnavailable`] — the library folder could not be
/// opened, which is the whole of what this knows. A drive unplugged
/// between sessions and a path typed wrongly at setup are the same
/// observation from here, and both are answered the same way: keep the
/// catalog, say which folder, and offer it again (FR-PLAT-AND-2).
///
/// A mount dropped *during* a session surfaces per-operation as
/// [`RemoteError::Network`] instead, which is what puts the app into
/// offline mode and leaves the catalog readable, exactly as a dead server
/// does.
pub fn new(root: impl Into<PathBuf>) -> Result<Self, RemoteError> {
Self::with_vfs(root, Arc::new(NoVfs))
}
@@ -232,7 +239,15 @@ impl FolderBackend {
pub fn with_vfs(root: impl Into<PathBuf>, vfs: Arc<dyn Vfs>) -> Result<Self, RemoteError> {
let root = root.into();
if !root.is_dir() {
return Err(RemoteError::Configuration(format!(
// TRACES: FR-PLAT-AND-2
// Not `Configuration`, which is where this lived while there was
// nothing better. The distinction that matters is not "was the
// account written wrongly" — which nothing here can know — but
// "can this library be opened", and a caller that knows the
// library was working yesterday can act on the second answer:
// mark what it holds as offline rather than deleting it, and ask
// for the folder again (FR-CAT-9).
return Err(RemoteError::RootUnavailable(format!(
"{} is not a folder",
root.display()
)));
+13 -4
View File
@@ -48,13 +48,22 @@ fn names(entries: &[RemoteEntry]) -> Vec<String> {
// --- opening --------------------------------------------------------------
/// TRACES: FR-PLAT-AND-2
#[test]
fn a_missing_folder_is_a_configuration_error_not_a_network_one() {
// It must not put the app into offline mode: nothing was unreachable, the
// account names somewhere that is not a folder.
fn a_missing_folder_is_an_unavailable_root_not_a_network_failure() {
// Still not offline mode — nothing was unreachable over a wire, and
// reporting it as a network failure would tell the user to wait for a
// connection that is working.
//
// `RootUnavailable` rather than `Configuration`, because the caller that
// has to act on this is the one whose library worked yesterday: an
// ejected card is indistinguishable from a mistyped path here, and only
// the first of those has a catalog full of ratings to protect.
let err = FolderBackend::new("/definitely/not/here").unwrap_err();
assert!(matches!(err, RemoteError::Configuration(_)), "{err:?}");
assert!(matches!(err, RemoteError::RootUnavailable(_)), "{err:?}");
assert!(err.indicates_lost_root());
assert!(!err.indicates_offline());
assert!(err.to_string().contains("/definitely/not/here"), "{err}");
}
// --- listing --------------------------------------------------------------