Keep the log after the session that produced it

Everything this application knew about a failure went to stderr on the
desktop and to logcat on Android, and both are gone the moment the
terminal closes or the ring buffer wraps. That is fine when the person
debugging is sitting at the machine. It is useless for the case NFR-OPS-1
actually describes, and the one Android makes normal: somebody reproduces
a bug on a tablet, and then sends us a file.

A large amount of Android behaviour has never run on a device — image
intents read over JNI, an ExportProvider, a class loaded through the
activity's class loader, memory-pressure eviction, lost-root recovery —
and the single most likely failure of the lot, the activity's loader not
resolving our classes from android_main's thread, produces one line that
scrolls past. That line is now in a file, with the thread that emitted it
named beside it.

dr_plat::state answers "where does this platform keep state for this
app": $XDG_STATE_HOME/darkroom on Linux, and on Android whatever the
entry point declares. Separate from configuration and from the catalog
for the reason XDG separates them — state is the thing nobody backs up
and the user may delete without consequence.

dr_plat::diagnostics is the sink. Two files of 4 MiB, so the worst case
is a number rather than a discovery on a full phone; one line per write
with no BufWriter anywhere, because on Android processes are killed
rather than ended and a buffered log loses exactly the line it was kept
for; and redaction applied at the sink rather than at the call sites,
since a rule every author has to remember is not a rule. It tees the
platform's own logger rather than replacing it, so logcat is unchanged —
losing that while debugging would have made this a downgrade.

Android logs to external_data_path, not internal. Both are app-private
and both survive backgrounding; what separates them is that
/data/data/<pkg>/files needs run-as against a debuggable build to read
and /sdcard/Android/data/<pkg>/files is a plain adb pull from any build.
A log nobody can retrieve is not a diagnostic. The consequence is that
anyone holding the tablet can read it, which is why the redaction is
where it is, and why configuration stays on internal_data_path.

What is redacted is what NFR-SEC-2 and NFR-OPS-1 name: credentials and
tokens, found by the keyword that nearly always sits next to them, plus
the two forms that carry one with no keyword at all — an Authorization
scheme and a URL's userinfo. What is deliberately not redacted is
filesystem paths and the names of the user's photographs. They are in
neither requirement's list, and "failed to decode <redacted>" is not a
diagnostic; the preview-and-consent step NFR-OPS-1 asks for governs those
better than scrubbing would, because it lets the user look.

The over-redaction failure is tested as carefully as the under-redaction
one. A scrubber that eats "using basic sRGB as the fallback" makes a log
useless without ever being caught.
This commit is contained in:
2026-08-30 10:40:54 +02:00
parent 126beaf7ed
commit 465f5a7ffa
10 changed files with 1319 additions and 6 deletions
+4
View File
@@ -19,6 +19,10 @@ dr-ui.workspace = true
# For `account::set_data_dir`: only the platform entry point knows where Android
# lets this app keep files, and it must be set before any store is opened.
dr-sync.workspace = true
# For `state::set_state_dir` and `diagnostics::install`, for the same reason and
# with the same ordering constraint: the log file has to be opened somewhere the
# platform will actually let us write, and only this file knows where that is.
dr-plat.workspace = true
# Directly, not just through dr-ui: `android_main` takes an `AndroidApp` and
# calls `slint::android::init`, both of which come from this crate. The backend
# feature comes from dr-ui's target-specific dependency.
+52 -3
View File
@@ -6,8 +6,10 @@
//! * There are no command-line paths. Android's SAF hands out document URIs,
//! not filesystem paths (ARCH §6.9), so the viewer opens with an empty
//! browsing list and the library grid is the only way in.
//! * Logging goes to logcat. `env_logger` writes to stderr, which Android
//! discards.
//! * Logging goes to logcat *and* to a file. `env_logger` writes to stderr,
//! which Android discards; logcat replaces it, and a rotating file beside it
//! replaces the thing logcat cannot be — a record that outlives the session
//! and can be sent to somebody (NFR-OPS-1, [`dr_plat::diagnostics`]).
//!
//! The first of those has one exception, and it is the launch `Intent`: a
//! gallery, a file manager or the share sheet can name images to open, and
@@ -29,11 +31,39 @@ mod intents;
/// Android application entry point, called by android-activity's glue.
#[no_mangle]
fn android_main(app: slint::android::AndroidApp) {
android_logger::init_once(
// Before the logger, because the logger needs somewhere to write, and
// before any `log::` call at all, because records emitted before this line
// reach nothing.
//
// **The external directory, not the internal one, and the difference is
// the entire point of the file.** Both are app-private and both survive
// backgrounding — the volatile one is the *cache* directory, which is not
// in play here. What separates them is retrieval:
// `/data/data/<pkg>/files` needs `run-as` against a debuggable build or
// root to read, and `/sdcard/Android/data/<pkg>/files` is a plain
// `adb pull` from any build, needing no permission since API 19. A log
// nobody can get off the device does not do the job NFR-OPS-1 describes.
//
// The consequence is that anyone holding the tablet can read it, which is
// why `dr_plat::diagnostics` redacts at the sink and why configuration —
// the account list, and the credential reference beside it — stays on
// `internal_data_path` below rather than moving here (NFR-SEC-2).
let external = app.external_data_path();
if let Some(dir) = external.clone().or_else(|| app.internal_data_path()) {
dr_plat::set_state_dir(dir);
}
// `AndroidLogger` rather than `init_once`, so logcat can be *teed* rather
// than replaced: `init_once` installs itself as the global logger and
// there is only one of those. Everything that reached logcat before this
// change still reaches it, at the same level and under the same tag; the
// file is strictly additional.
let console = android_logger::AndroidLogger::new(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Info)
.with_tag("DarkRoom"),
);
let logging = dr_plat::diagnostics::install(Box::new(console), log::LevelFilter::Info);
// Panics go to stderr, and Android discards stderr. Without this hook a
// worker thread that panics is invisible: the process survives, the
@@ -45,6 +75,25 @@ fn android_main(app: slint::android::AndroidApp) {
log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION"));
// Said in logcat as well as in the file, because the first thing anybody
// asked for a log needs is where it is — and on a device that is a path
// nobody can guess and a command nobody remembers.
match &logging {
dr_plat::Installed::ToFile(path) => {
log::info!("logging to {}", path.display());
if external.is_none() {
log::warn!(
"no external storage; the log is app-private and needs \
`adb shell run-as paris.tourolle.darkroom cat files/darkroom.log` \
on a debuggable build"
);
}
}
dr_plat::Installed::ConsoleOnly(why) => {
log::warn!("no log file this session, only logcat: {why}");
}
}
// Before anything opens a store: Android has no $HOME and no XDG
// directories, so the default guess resolves to a path the app cannot
// write. Nothing failed loudly — the session list went to a doomed path, so