Android: one logcat tag for the Rust side, and say what it is
Build Debug APK / build (push) Successful in 5m1s

Forwarded records were tagged with the Rust module path they came from, so
`adb logcat -s <the app>` returned silence and a raised verbosity looked
broken. The per-module tags that cost us buy nothing back: logcat's filterspec
splits at the first colon, so `iroh_gossip::net:V` parses as tag
`iroh_gossip` with priority `:` and is rejected — a tag containing `::` is
unaddressable.

So: one fixed tag for everything crossing the FFI boundary, with the record's
target moved to the head of the message where grep still reaches it. The
backend is held privately rather than installed via `android_logger::init_once`,
which would take the `log` global and cut our runtime filtering out of the path.

The tag is exported over uniffi and shown under Log Verbosity in Settings —
which tag to filter on is not guessable from outside the process.
This commit is contained in:
Greg Shuflin
2026-08-25 16:26:09 -07:00
parent 9127726ddb
commit 873f7ee400
4 changed files with 112 additions and 19 deletions
+5 -17
View File
@@ -372,29 +372,17 @@ since it isn't worth taking before it shows up in a profile.
`iroh_gossip::net - dial failed: No addressing information available` for an Android peer
it knows about via the ensemble's pkarr registry.
- **How to confirm**: check whether Android ever logs successful DHT publish/resolve
activity from `iroh_mainline_address_lookup`, or only silence. Quicker still: `adb shell`
into the emulator and see whether an arbitrary raw UDP send/receive succeeds at all.
activity from `iroh_mainline_address_lookup`, or only silence — with "Show iroh logs" +
Verbose on in Settings, `adb logcat -s synchronicity | grep mainline` (every record
forwarded from Rust carries that one tag, with the module path at the head of the
message). Quicker still: `adb shell` into the emulator and see whether an arbitrary raw
UDP send/receive succeeds at all.
- **If confirmed**: the DoH trick doesn't transfer (DHT isn't HTTP), so likely a TCP-based
DHT bootstrap/proxy, or disabling `DhtAddressLookup` on Android in favour of the working
DoH path plus persisted/gossip relay-URL caching, or emulator network config. **Testing
on a physical device is the fastest way to rule this in or out** — if a real phone
bootstraps via DHT, the bug is emulator-specific and low priority.
### Android logcat filtering: forwarded Rust logs are tagged by module path
- **Description**: `ConfigurableAndroidLogger` (`rust/lib/src/log.rs`) forwards records to
`android_logger::log()` with the default `Config` (no `tag`). Without one,
`android_logger` uses each record's Rust module path as the logcat tag
(`iroh_gossip::net`, `synchronicity_lib::p2p::gossip`) rather than a fixed app tag.
- **Impact**: filtering logcat by tag or package name shows nothing, regardless of the
app's verbosity settings — which *are* wired correctly. The likely explanation for "I
don't see any iroh logs even with verbose logging on."
- **Workaround**: `adb logcat | grep -iE "iroh|gossip|synchronicity"`, no tag filter, with
"Show iroh logs" + Verbose enabled in Settings.
- **Possible fix**: set one fixed tag via `android_logger`'s `Config` and prepend the
module path into the message body. Trade-off: loses free per-module tag filtering for one
greppable tag — probably a net win, but worth deciding deliberately rather than as a side
effect of a bug fix.
### Android error surfacing, and the general sync-status signal it's part of
- **The general gap**: per-peer gossip reachability is visible (Connected/Degraded badges
on the Ensemble screen), but there is still no user-facing "a sync is happening / just
@@ -10,6 +10,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
@@ -31,6 +32,7 @@ import com.gregshuflin.synchronicity.ui.theme.ThemeState
import kotlinx.coroutines.launch
import uniffi.synchronicity.LogVerbosity
import uniffi.synchronicity.ThemeMode
import uniffi.synchronicity.androidLogTag
private const val TAG = "SettingsView"
@@ -207,6 +209,20 @@ fun SettingsView(
},
)
}
// Which tag to filter on is not guessable from outside: everything the Rust side
// logs — ours and iroh's alike — arrives under one tag, so say what it is rather
// than leaving a raised verbosity looking like it did nothing.
val logTag = remember { androidLogTag() }
SelectionContainer {
Text(
text =
"The Rust side logs to logcat under one tag: $logTag. " +
"Filter with: adb logcat -s $logTag",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
modifier = Modifier.padding(top = 4.dp),
)
}
// Timezone picker
SectionHeading(
+8
View File
@@ -322,6 +322,14 @@ pub fn update_android_log_filter(verbosity: LogVerbosity, show_iroh_logs: bool)
crate::log::update_android_log_filter(crate::log::LogConfig { verbosity, show_iroh_logs });
}
/// The logcat tag every record forwarded from Rust carries —
/// [`crate::log::ANDROID_LOG_TAG`]. Exported so the settings screen can tell
/// the user what to filter on without keeping its own copy of the string.
#[uniffi::export]
pub fn android_log_tag() -> String {
crate::log::ANDROID_LOG_TAG.to_string()
}
/// Called by the JVM when the shared library is first loaded. Captures the
/// JavaVM pointer into a static so initAndroidContext can use it without
/// calling GetJavaVM through jni-sys 0.4's macro-transformed union API.
+83 -2
View File
@@ -148,8 +148,62 @@ fn android_effective_level(config: &LogConfig, target: &str) -> log::LevelFilter
}
}
/// The logcat tag every forwarded Rust record carries.
///
/// # Why one fixed tag
///
/// Left to itself `android_logger` tags each record with the Rust module path
/// it came from — `iroh_gossip::net`, `synchronicity::p2p::gossip`. That reads
/// fine in a full dump and is useless for filtering, for two reasons. There is
/// no tag to guess: a person who reaches for `adb logcat -s <the app>` gets
/// silence and concludes the verbosity setting is broken. And the per-module
/// tags it trades that away for cannot be selected anyway — logcat's
/// filterspec is `tag:priority`, split at the *first* colon, so
/// `iroh_gossip::net:V` parses as tag `iroh_gossip` with priority `:` and is
/// rejected as malformed. Any tag containing `::` is unaddressable.
///
/// So: one tag for the whole Rust side, with the module path moved into the
/// message where `grep` can still reach it. `adb logcat -s synchronicity`
/// shows every forwarded record; `... | grep iroh_gossip` narrows it. Kotlin
/// keeps its own per-class tags — this is only about records crossing the FFI
/// boundary from Rust.
///
/// It shares its spelling with [`OWN_TARGET_PREFIX`] because both come from the
/// lib target's name, but they are not the same thing: that one selects *our*
/// records out of everyone's, this one labels every record we forward.
pub const ANDROID_LOG_TAG: &str = "synchronicity";
/// Writes the body of a logcat line: the record's target, then its message.
///
/// The target — not `module_path!()`, which `android_logger` would otherwise
/// use — because the target is what [`android_effective_level`] filters on, so
/// what a reader greps for is exactly what decided whether the line appears.
fn write_android_message(out: &mut dyn std::fmt::Write, record: &log::Record) -> std::fmt::Result {
write!(out, "{}: {}", record.target(), record.args())
}
/// The `android_logger` backend, configured with our fixed tag and message
/// format. Held privately rather than installed via `android_logger::init_once`,
/// which would race with us for the `log` crate's single global logger slot and
/// take our runtime filtering out of the path.
static ANDROID_BACKEND: std::sync::OnceLock<android_logger::AndroidLogger> =
std::sync::OnceLock::new();
fn android_backend() -> &'static android_logger::AndroidLogger {
ANDROID_BACKEND.get_or_init(|| {
android_logger::AndroidLogger::new(
android_logger::Config::default()
// Filtering is [`ConfigurableAndroidLogger`]'s job; this backend
// must not apply a second, unconfigurable one on top of it.
.with_max_level(log::LevelFilter::Trace)
.with_tag(ANDROID_LOG_TAG)
.format(write_android_message),
)
})
}
/// A `log::Log` implementation that pre-filters records using a runtime-updatable
/// `LogConfig` and forwards accepted records to `android_logger::log`, which
/// `LogConfig` and forwards accepted records to an `android_logger` backend, which
/// writes to the Android logcat buffer.
struct ConfigurableAndroidLogger;
@@ -161,7 +215,7 @@ impl log::Log for ConfigurableAndroidLogger {
fn log(&self, record: &log::Record) {
if self.enabled(record.metadata()) {
android_logger::log(record);
log::Log::log(android_backend(), record);
}
}
@@ -211,6 +265,33 @@ mod tests {
}
}
/// The module path has to survive somewhere once it stops being the tag,
/// or a single-tag logcat dump loses the only marker of where a line came
/// from — and with it the ability to tell an iroh line from one of ours.
#[test]
fn the_module_path_moves_into_the_message() {
let mut line = String::new();
write_android_message(
&mut line,
&log::Record::builder()
.target("iroh_gossip::net")
.level(log::Level::Info)
.args(format_args!("dial failed"))
.build(),
)
.expect("writing to a String cannot fail");
assert_eq!(line, "iroh_gossip::net: dial failed");
}
/// A tag containing `::` cannot appear in a logcat filterspec at all — it
/// is split at the first colon and rejected as a bad priority — so the one
/// tag we do use must be plainly selectable.
#[test]
fn the_fixed_tag_is_addressable_by_logcat() {
assert!(!ANDROID_LOG_TAG.contains(':'), "`adb logcat -s {ANDROID_LOG_TAG}` must parse");
assert!(!ANDROID_LOG_TAG.is_empty());
}
/// The floor is ours alone: iroh is the reason the default is low, and
/// lifting it here would reopen exactly the spam that motivated it.
#[test]