Android "Send logs to host": the logcat-teed ring uploads over the client's own mTLS #339
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>()
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user