From f584eebb92ff8ea09f9941a6ce70f4a9f5672e71 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 19 Aug 2026 18:26:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(android):=20"Send=20logs=20to=20host"=20wo?= =?UTF-8?q?rks=20from=20the=20console=20=E2=80=94=20the=20ring,=20teed=20f?= =?UTF-8?q?rom=20logcat,=20uploads=20over=20the=20client's=20own=20mTLS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WP5b of punktfunk-planning design/console-ui-sweep-2026-08-19.md (the last open item): - pf-client-core: the logring's RING half (note/render/wallclock — std only) is Android-enabled; `send_to_host` stays desktop-gated with the rest of the ureq fetches. `wallclock` moves in from the session's ring_layer so every ring feeder stamps lines the same way. - Android native: JNI_OnLoad installs a RingTee — every `log` record goes to logcat AND into the ring, in the desktop ring_layer's line shape. `nativeRenderLogs(header)` hands Kotlin the rendered bundle. - Kotlin: `SkiaConsole.sendLogs` replaces the not-available stub — renders the ring and POSTs it to /api/v1/client-logs over `mtlsHttpClient` (the library/art path), noticing the desktop wording on success/failure. The upload deliberately stays on the Kotlin side: OkHttp already owns HTTPS-to-the-pinned-host on this platform, and pulling ureq+rustls into the .so for one POST would be a dependency change, not a feature. - console-ui: the host menu's "Send logs" desktop-only gate is gone — paired and reachable is the whole condition again; the pinning test flips to assert both platforms offer it. --- .../io/unom/punktfunk/console/SkiaConsole.kt | 50 +++++++++++++++++- .../io/unom/punktfunk/kit/NativeBridge.kt | 8 +++ clients/android/native/src/lib.rs | 51 ++++++++++++++++--- clients/android/native/src/logs.rs | 27 ++++++++++ clients/session/src/ring_layer.rs | 30 +---------- crates/pf-client-core/src/lib.rs | 6 ++- crates/pf-client-core/src/logring.rs | 31 +++++++++++ crates/pf-console-ui/src/screens/options.rs | 23 +++++---- 8 files changed, 179 insertions(+), 47 deletions(-) create mode 100644 clients/android/native/src/logs.rs diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/console/SkiaConsole.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/console/SkiaConsole.kt index f0df657f..0a77bee7 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/console/SkiaConsole.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/console/SkiaConsole.kt @@ -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 ( ) — 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") diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index 0f12f89c..9032e17f 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -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 diff --git a/clients/android/native/src/lib.rs b/clients/android/native/src/lib.rs index 4a41e6a5..326cc345 100644 --- a/clients/android/native/src/lib.rs +++ b/clients/android/native/src/lib.rs @@ -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 diff --git a/clients/android/native/src/logs.rs b/clients/android/native/src/logs.rs new file mode 100644 index 00000000..49ba0b4b --- /dev/null +++ b/clients/android/native/src/logs.rs @@ -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::() +} diff --git a/clients/session/src/ring_layer.rs b/clients/session/src/ring_layer.rs index 58f3d0be..d8ad0051 100644 --- a/clients/session/src/ring_layer.rs +++ b/clients/session/src/ring_layer.rs @@ -68,7 +68,7 @@ impl tracing_subscriber::Layer 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 tracing_subscriber::Layer 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::*; diff --git a/crates/pf-client-core/src/lib.rs b/crates/pf-client-core/src/lib.rs index b8aa4cb0..d0814abb 100644 --- a/crates/pf-client-core/src/lib.rs +++ b/crates/pf-client-core/src/lib.rs @@ -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. diff --git a/crates/pf-client-core/src/logring.rs b/crates/pf-client-core/src/logring.rs index bdd2e0f9..633688e2 100644 --- a/crates/pf-client-core/src/logring.rs +++ b/crates/pf-client-core/src/logring.rs @@ -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, diff --git a/crates/pf-console-ui/src/screens/options.rs b/crates/pf-console-ui/src/screens/options.rs index 3bdfa9d0..758e07e8 100644 --- a/crates/pf-console-ui/src/screens/options.rs +++ b/crates/pf-console-ui/src/screens/options.rs @@ -124,7 +124,10 @@ impl OptionsScreen { key.split('\0').next().unwrap_or(key) } - fn actions(&self, platform: crate::platform::Platform) -> Vec { + // `_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 { 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)); } -- 2.54.0