Android "Send logs to host": the logcat-teed ring uploads over the client's own mTLS #339

Merged
enricobuehler merged 1 commits from worktree-console-ui-sendlogs-android into main 2026-08-19 16:28:28 +00:00
8 changed files with 179 additions and 47 deletions
@@ -37,8 +37,10 @@ import io.unom.punktfunk.models.ActiveSession
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
@@ -544,7 +546,7 @@ object SkiaConsole {
c.optJSONObject("FetchLibrary")?.let { fetchLibrary(it, refreshOnly = false) }
c.optJSONObject("RefreshRunning")?.let { fetchLibrary(it, refreshOnly = true) }
c.optJSONObject("Pair")?.let(::pair)
c.optJSONObject("SendLogs")?.let { notice("Sending logs isn't available on this device yet") }
c.optJSONObject("SendLogs")?.let(::sendLogs)
c.optJSONObject("SaveHost")?.let(::saveHost)
c.optJSONObject("UpdateHost")?.let(::updateHost)
c.optJSONObject("ForgetHost")?.let(::forgetHost)
@@ -617,6 +619,52 @@ object SkiaConsole {
pushHosts(); pushKnownHosts()
}
/**
* `ConsoleCmd::SendLogs` — the native log ring (`nativeRenderLogs`) posted to this
* paired host's `POST /api/v1/client-logs` over the same mTLS client the library fetch
* uses; the result comes back as a notice, in the desktop console's wording. The header
* mirrors the desktop's identity line (`punktfunk-session <ver> (<os> <arch>) — client
* log bundle`).
*/
private fun sendLogs(c: JSONObject) {
val addr = c.optString("addr"); val mgmt = c.optInt("mgmt"); val fp = c.optString("fp_hex")
val hostName = c.optString("host_name").ifEmpty { addr }
val id = identity
if (id == null) {
notice("Identity not ready yet — try again in a moment")
return
}
val version = appContext?.let { app ->
runCatching { app.packageManager.getPackageInfo(app.packageName, 0).versionName }.getOrNull()
} ?: "?"
val header = "punktfunk-android $version (android ${android.os.Build.VERSION.RELEASE}; " +
"${android.os.Build.SUPPORTED_ABIS.firstOrNull() ?: "?"}) — client log bundle"
ioPool.execute {
val err = runCatching {
val body = NativeBridge.nativeRenderLogs(header)
val client = io.unom.punktfunk.kit.library.mtlsHttpClient(
id.certPem, id.privateKeyPem, addr, fp,
)
val req = Request.Builder()
.url("https://$addr:$mgmt/api/v1/client-logs")
.post(body.toRequestBody("text/plain; charset=utf-8".toMediaType()))
.build()
client.newCall(req).execute().use { resp ->
if (resp.code == 200) "" else "host answered HTTP ${resp.code}"
}
}.getOrElse { it.message ?: "upload failed" }
main.post {
notice(
if (err.isEmpty()) {
"Logs sent to $hostName — download them from its web console's Logs page"
} else {
"Couldn't send logs — $err"
},
)
}
}
}
private fun pair(c: JSONObject) {
val addr = c.optString("addr"); val port = c.optInt("port")
val pin = c.optString("pin"); val name = c.optString("device_name")
@@ -146,6 +146,14 @@ object NativeBridge {
name: String,
): String
/**
* The native client's recent log ring rendered as one text bundle, oldest first,
* prefixed by [header] (this app's identity line) — the body for "Send logs to host"
* (`POST /api/v1/client-logs` over the same mTLS client the library fetch uses).
* Never empty; cheap (string copy, no I/O).
*/
external fun nativeRenderLogs(header: String): String
/**
* The machine token of the most recent failed [nativeConnect]/[nativePair], cleared on read
* (`""` when none) — call right after a `0` handle / `""` fingerprint. A typed host rejection
+45 -6
View File
@@ -34,6 +34,9 @@ mod audio;
// shell over EGL/GLES, on every ABI (the armv7 Skia archive is self-hosted — see Cargo.toml).
#[cfg(target_os = "android")]
mod console;
// "Send logs to host": the log-ring upload (`pf-client-core` is Android-target-only here).
#[cfg(target_os = "android")]
mod logs;
// The RESOLVED audio format + its ms ⇄ sample arithmetic, split out of `audio` and — unlike it —
// ungated, because that arithmetic is what a rate the ladder does not divide gets wrong (44 100 Hz
// used to come out 2.3 % off in every direction at once) and it must be provable without a phone.
@@ -60,22 +63,58 @@ mod wol;
// it off the main thread to light saved-host "online" pips independently of mDNS.
mod probe;
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
/// `punktfunk` tag. Core `tracing` events (transport warnings: socket-buffer clamp, QoS failures)
/// arrive here too: tracing's "log" feature — declared explicitly in Cargo.toml rather than relied
/// on via quinn's defaults — forwards them as `log` records since no tracing subscriber is ever
/// installed. Android-only — there is no JVM (and no logcat) on the host build.
/// Every `log` record, teed: to logcat (via [`android_logger::AndroidLogger`]) AND into
/// `pf_client_core::logring` — the source for the console's "Send logs to host" action
/// ([`logs`]). The ring line mirrors the desktop `ring_layer`'s shape (wallclock, level,
/// target, message) so a bundle reads the same on the host's Logs page whichever client
/// sent it. Both sinks share the crate's Info ceiling — the field ring gets exactly what
/// logcat gets, which also keeps per-frame DEBUG chatter out of it by construction.
#[cfg(target_os = "android")]
struct RingTee(android_logger::AndroidLogger);
#[cfg(target_os = "android")]
impl log::Log for RingTee {
fn enabled(&self, metadata: &log::Metadata) -> bool {
self.0.enabled(metadata)
}
fn log(&self, record: &log::Record) {
self.0.log(record);
pf_client_core::logring::note(format!(
"{} {:5} {} {}",
pf_client_core::logring::wallclock(),
record.level().as_str(),
record.target(),
record.args()
));
}
fn flush(&self) {
self.0.flush();
}
}
/// Initialize logging once when the JVM loads the library: logcat under the `punktfunk` tag,
/// teed into the client log ring (see [`RingTee`]). Core `tracing` events (transport warnings:
/// socket-buffer clamp, QoS failures) arrive here too: tracing's "log" feature — declared
/// explicitly in Cargo.toml rather than relied on via quinn's defaults — forwards them as
/// `log` records since no tracing subscriber is ever installed. Android-only — there is no
/// JVM (and no logcat) on the host build.
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
pub extern "system" fn JNI_OnLoad(
_vm: *mut jni::sys::JavaVM,
_reserved: *mut std::ffi::c_void,
) -> jint {
android_logger::init_once(
let logcat = android_logger::AndroidLogger::new(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Info)
.with_tag("punktfunk"),
);
// `set_boxed_logger` (unlike `init_once`) does not set the max level itself.
if log::set_boxed_logger(Box::new(RingTee(logcat))).is_ok() {
log::set_max_level(log::LevelFilter::Info);
}
log::info!(
"punktfunk_android loaded (core ABI v{})",
punktfunk_core::ABI_VERSION
+27
View File
@@ -0,0 +1,27 @@
//! JNI seam for "Send logs to host": hand Kotlin the client's recent log ring (fed by the
//! [`crate::RingTee`] logcat tee) rendered as one text bundle. The UPLOAD stays on the
//! Kotlin side — its mTLS OkHttp client (`mtlsHttpClient`, the library/art path) already
//! owns HTTPS-to-the-pinned-host on this platform, and `logring::send_to_host`'s ureq
//! agent is deliberately desktop-only. Android-gated (unlike [`crate::wol`]/[`crate::probe`])
//! because `pf-client-core` is an Android-target dependency of this crate.
use jni::errors::LogErrorAndDefault;
use jni::objects::{JObject, JString};
use jni::EnvUnowned;
/// `NativeBridge.nativeRenderLogs(header): String` — the ring as one text bundle, oldest
/// first, prefixed by `header` (the Kotlin side's identity line) and an eviction note when
/// the ring wrapped. Never empty (the header line is always present); cheap enough for any
/// thread, though the caller is about to do network anyway.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeRenderLogs<'local>(
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
header: JString<'local>,
) -> JString<'local> {
env.with_env(|env| {
let header: String = header.try_to_string(env)?;
env.new_string(pf_client_core::logring::render(&header))
})
.resolve::<LogErrorAndDefault>()
}
+1 -29
View File
@@ -68,7 +68,7 @@ impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for RingLayer {
event.record(&mut v);
pf_client_core::logring::note(format!(
"{} {:5} {} {}",
wallclock(),
pf_client_core::logring::wallclock(),
meta.level().as_str(),
meta.target(),
v.0
@@ -76,34 +76,6 @@ impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for RingLayer {
}
}
/// `2026-08-15T12:03:47.123Z` from the system clock — wall time, so a bundle correlates with
/// the host log it lands next to. No chrono dep; same civil-date derivation the host uses.
fn wallclock() -> String {
let ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let secs = (ms / 1000) as i64;
let days = secs.div_euclid(86_400);
let tod = secs.rem_euclid(86_400);
// Howard Hinnant's civil_from_days.
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mo <= 2 { y + 1 } else { y };
let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
format!(
"{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{:03}Z",
ms % 1000
)
}
#[cfg(test)]
mod tests {
use super::*;
+5 -1
View File
@@ -63,7 +63,11 @@ pub mod library;
// Per-host catalog cache, so a library screen has titles to show while a sleeping host boots.
#[cfg(any(target_os = "linux", windows))]
pub mod library_cache;
#[cfg(any(target_os = "linux", windows))]
// Android-enabled for the RING half (note/render — std only): the client's "Send logs to
// host" needs the ring on every platform. The `send_to_host` uploader inside stays
// desktop-gated with the rest of the ureq fetches; Android posts the rendered bundle
// through its own mTLS OkHttp client (`SkiaConsole.sendLogs`).
#[cfg(any(target_os = "linux", windows, target_os = "android"))]
pub mod logring;
// The `punktfunk://` grammar (design/client-deep-links.md §2): one parser/emitter for the
// shells, the session and the CLI, held to the Swift/Kotlin ports by a shared vector file.
+31
View File
@@ -55,6 +55,36 @@ pub fn note(mut line: String) {
}
}
/// `2026-08-15T12:03:47.123Z` from the system clock — wall time, so a bundle correlates with
/// the host log it lands next to. No chrono dep; same civil-date derivation the host uses.
/// Lives here (not in a shell) because every ring FEEDER wants the same stamp: the session's
/// `ring_layer` and the Android client's logcat tee both prefix their lines with it.
pub fn wallclock() -> String {
let ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let secs = (ms / 1000) as i64;
let days = secs.div_euclid(86_400);
let tod = secs.rem_euclid(86_400);
// Howard Hinnant's civil_from_days.
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mo <= 2 { y + 1 } else { y };
let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
format!(
"{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{:03}Z",
ms % 1000
)
}
/// The ring rendered as one text bundle, oldest first, prefixed by `header` (the shell's own
/// identity line — binary name, version, platform) and an eviction note when the ring wrapped.
pub fn render(header: &str) -> String {
@@ -79,6 +109,7 @@ pub fn render(header: &str) -> String {
/// trust as the library fetch: TLS client auth with the device identity, host pinned by
/// fingerprint. Errors reuse the library's classification (401/403 ⇒ `NotPaired`, a pin-verifier
/// rejection ⇒ `PinMismatch`), so the shell's existing error strings apply.
#[cfg(any(target_os = "linux", windows))]
pub fn send_to_host(
addr: &str,
mgmt_port: u16,
+13 -10
View File
@@ -124,7 +124,10 @@ impl OptionsScreen {
key.split('\0').next().unwrap_or(key)
}
fn actions(&self, platform: crate::platform::Platform) -> Vec<Action> {
// `_platform` is the seam platform-conditional rows plug into (Send logs used it until
// Android grew an uploader); unused today, kept so the next such row has its question
// already answered at every call site.
fn actions(&self, _platform: crate::platform::Platform) -> Vec<Action> {
let host = match &self.subject {
Subject::Host(h) => h,
// Deliberately not [Play, …]: the host menu does not repeat its tile's own A
@@ -146,9 +149,9 @@ impl OptionsScreen {
// error. This is the log-escape hatch for platforms whose own filesystem the user
// can't reach (Deck Gaming Mode, tvOS): the bundle lands on the host, listed in
// its web console next to the host's own logs.
// Only where a service exists to upload them: the Android client has no log-ring
// uploader yet, and a row that can only toast "not available" is a promise broken.
if host.paired && host.online && platform == crate::platform::Platform::Desktop {
// Every platform has an uploader now (Android's rides `nativeSendLogs` over the
// same `logring` the desktop drains), so paired-and-reachable is the whole gate.
if host.paired && host.online {
a.push(Action::SendLogs);
}
a.extend([
@@ -477,12 +480,12 @@ mod tests {
.contains(&Action::Wake));
}
/// "Send logs" is offered only where a service exists to act on it: the Android host
/// answers the command with a not-available notice (`SkiaConsole.drainCommands`), so
/// until its log-ring uploader lands the row must not render there. When that uploader
/// ships, this test is the line to flip alongside the gate in [`OptionsScreen::actions`].
/// "Send logs" is offered wherever a paired, reachable host can receive it — on BOTH
/// platforms since Android's uploader landed (`SkiaConsole.sendLogs` → `nativeSendLogs`
/// over the shared `logring`); before that the row was desktop-only, because a row that
/// can only toast "not available" is a promise broken.
#[test]
fn send_logs_is_desktop_only_until_android_can_upload() {
fn send_logs_is_offered_on_every_platform_with_an_uploader() {
let reachable = OptionsScreen::for_host(&HostRow {
paired: true,
online: true,
@@ -491,7 +494,7 @@ mod tests {
assert!(reachable
.actions(crate::platform::Platform::Desktop)
.contains(&Action::SendLogs));
assert!(!reachable
assert!(reachable
.actions(crate::platform::Platform::Android)
.contains(&Action::SendLogs));
}