diff --git a/iris/android-app/Cargo.lock b/iris/android-app/Cargo.lock index 632eaf0..ccfc1f2 100644 --- a/iris/android-app/Cargo.lock +++ b/iris/android-app/Cargo.lock @@ -558,6 +558,12 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bit-set" version = "0.8.0" @@ -734,6 +740,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "client-core" +version = "0.1.0" +dependencies = [ + "event-model", + "serde", + "serde_json", + "ureq", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -779,6 +795,35 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -886,6 +931,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "dispatch" version = "0.2.0" @@ -1048,6 +1099,14 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "event-model" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "exr" version = "1.74.2" @@ -1179,6 +1238,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -1253,6 +1321,26 @@ dependencies = [ "windows-link", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1423,6 +1511,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "icu_collections" version = "2.3.0" @@ -1551,6 +1655,27 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "image" version = "0.25.10" @@ -1640,9 +1765,13 @@ version = "0.1.0" dependencies = [ "android-view", "android_logger", + "client-core", + "event-model", "iris", "log", + "serde_json", "tabs-ui", + "transcript-ui", ] [[package]] @@ -1676,6 +1805,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jni" version = "0.21.1" @@ -2096,6 +2231,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-derive" version = "0.4.2" @@ -2744,6 +2885,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2796,6 +2943,25 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.1", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "pulp" version = "0.22.3" @@ -3075,6 +3241,20 @@ version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "roxmltree" version = "0.21.1" @@ -3125,6 +3305,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -3207,6 +3422,19 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -3363,6 +3591,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swash" version = "0.2.10" @@ -3490,6 +3724,36 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -3596,6 +3860,16 @@ dependencies = [ "once_cell", ] +[[package]] +name = "transcript-ui" +version = "0.1.0" +dependencies = [ + "client-core", + "event-model", + "iris", + "pulldown-cmark", +] + [[package]] name = "tree_magic_mini" version = "3.2.2" @@ -3634,6 +3908,12 @@ dependencies = [ "keyboard-types", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -3652,6 +3932,62 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +dependencies = [ + "base64", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3696,6 +4032,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -3889,6 +4231,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -4748,6 +5099,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" @@ -4789,6 +5146,12 @@ version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + [[package]] name = "zune-core" version = "0.5.3" diff --git a/iris/android-app/Cargo.toml b/iris/android-app/Cargo.toml index 814b538..fc5d751 100644 --- a/iris/android-app/Cargo.toml +++ b/iris/android-app/Cargo.toml @@ -17,10 +17,26 @@ crate-type = ["cdylib"] [dependencies] iris = { path = "../" } -tabs-ui = { path = "../tabs-ui" } android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" } android_logger = "0.15.0" log = "0.4.28" +# `tabs-screen` (default, I2/I4's demo) and `transcript-screen` (I5's +# Android integration) are mutually exclusive -- one `ActiveClient` type is +# compiled in, never both (`lib.rs`'s doc comment) -- so both sets of deps +# are optional and each screen's feature pulls in only its own. Without +# this, building `--features transcript-screen` alone (default features +# still on) left `tabs-ui` linked but never referenced under that cfg, +# which Cargo's `unused_dependencies` lint (on by default) correctly flags. +tabs-ui = { path = "../tabs-ui", optional = true } +transcript-ui = { path = "../transcript-ui", optional = true } +client-core = { path = "../../client-core", optional = true } +event-model = { path = "../../event-model", optional = true } +serde_json = { version = "1", features = ["float_roundtrip"], optional = true } + +[features] +default = ["tabs-screen"] +tabs-screen = ["dep:tabs-ui"] +transcript-screen = ["dep:transcript-ui", "dep:client-core", "dep:event-model", "dep:serde_json"] [profile.release] panic = "abort" diff --git a/iris/android-app/build.rs b/iris/android-app/build.rs new file mode 100644 index 0000000..18704fe --- /dev/null +++ b/iris/android-app/build.rs @@ -0,0 +1,92 @@ +// Only does anything under the `transcript-screen` feature (RUST.md's I5 +// Android integration) -- the plain tabs build (I2/I4) needs none of this +// and stays untouched, same reasoning as the feature gate in Cargo.toml. +// +// Bakes the sandbox server's host, port, token and pinned CA in at build +// time, the same way `app/androidApp/build.gradle.kts`'s +// `GeneratePinnedCert` task bakes the CA for the Compose app -- see that +// file's comment for why reading the machine's own certificate at build +// time is the right trust boundary. This build additionally bakes the +// host/port/token, which the Compose app does not: that app enrolls at +// runtime from a scanned QR/deep link, and a from-scratch enrollment UI +// (Keystore-sealed token storage, a QR/link scanner) is real, separate +// scope this integration does not need to build to answer RUST.md's +// question -- there is nothing here yet resembling `ServerConfig.kt`. So +// this is a **deliberate simplification for this rig only**: an APK built +// this way is good for exactly the emulator/server pair that built it, and +// must never be treated as a template for a real enrollment flow. Recorded +// in RUST.md's I5 box rather than left to be rediscovered. +use std::path::PathBuf; + +fn main() { + if std::env::var_os("CARGO_FEATURE_TRANSCRIPT_SCREEN").is_none() { + return; + } + println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_HOST"); + println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_PORT"); + println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_TOKEN"); + println!("cargo:rerun-if-env-changed=AI_APP_CA"); + println!("cargo:rerun-if-env-changed=XDG_CONFIG_HOME"); + + let host = require_env( + "AI_APP_TRANSCRIPT_HOST", + "the sandbox server's host as the emulator reaches it, e.g. 10.0.2.2", + ); + let port = require_env( + "AI_APP_TRANSCRIPT_PORT", + "the sandbox server's port -- app/ui-sandbox.sh's start banner prints it", + ); + let token = require_env( + "AI_APP_TRANSCRIPT_TOKEN", + "the bearer token -- ~/.config/ai-app/sandbox-token, or the start banner's enrollment link", + ); + + let ca_path = std::env::var_os("AI_APP_CA") + .map(PathBuf::from) + .unwrap_or_else(|| { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| { + let home = std::env::var_os("HOME").expect("HOME must be set"); + PathBuf::from(home).join(".config") + }); + base.join("ai-app").join("certs").join("ca.pem") + }); + let ca_pem = std::fs::read_to_string(&ca_path).unwrap_or_else(|e| { + panic!( + "no CA certificate at {} ({e}).\n\ + Start ai-server (or app/ui-sandbox.sh) once on this machine first -- it \ + generates the CA this build pins. Set AI_APP_CA=/path/to/ca.pem to build \ + against a different one.", + ca_path.display() + ) + }); + let ca_pem = ca_pem.trim(); + if !ca_pem.starts_with("-----BEGIN CERTIFICATE-----") { + panic!("{} is not a PEM certificate.", ca_path.display()); + } + + let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").unwrap()); + let generated = format!( + "// Generated by build.rs from {host}:{port} and {ca}. Do not edit.\n\ + pub const HOST: &str = {host_lit:?};\n\ + pub const PORT: u16 = {port};\n\ + pub const TOKEN: &str = {token_lit:?};\n\ + pub const CA_PEM: &str = {ca_lit:?};\n", + host = host, + port = port + .parse::() + .unwrap_or_else(|e| panic!("AI_APP_TRANSCRIPT_PORT={port:?} is not a u16: {e}")), + ca = ca_path.display(), + host_lit = host, + token_lit = token, + ca_lit = ca_pem, + ); + std::fs::write(out_dir.join("pinned_config.rs"), generated).unwrap(); +} + +fn require_env(name: &str, what: &str) -> String { + std::env::var(name).unwrap_or_else(|_| { + panic!("{name} must be set to build the transcript-screen feature -- {what}") + }) +} diff --git a/iris/android-app/src/lib.rs b/iris/android-app/src/lib.rs index 2c663a2..6a8cc32 100644 --- a/iris/android-app/src/lib.rs +++ b/iris/android-app/src/lib.rs @@ -1,5 +1,5 @@ -//! The android-view demo app: iris's `tabs` widget tree (`tabs_ui::build`, -//! shared with the winit example) running through +//! The android-view demo app: by default, iris's `tabs` widget tree +//! (`tabs_ui::build`, shared with the winit example) running through //! `iris::android`'s `ViewPeer`. This is RUST.md's I2 pass condition made //! concrete -- there is no UI here beyond what `tabs-ui` already draws. //! @@ -9,6 +9,20 @@ //! wrapping `iris::android::new_peer`'s generic function in a concrete //! `extern "system" fn`, since `register_view_class` wants a plain //! function pointer. +//! +//! **`transcript-screen` feature (RUST.md's I5 Android integration):** with +//! `--features transcript-screen`, `new_view_peer` instantiates +//! `transcript_client::TranscriptClient` instead of the tabs `Client` +//! below, against a real `ai-server` (see that module's doc). Chosen over a +//! third shell crate: this one already has the Gradle project, the +//! `IrisView`/`MainActivity` Java, and the JNI registration I2 built and +//! measured against, and the only thing a transcript screen needs on top +//! is a different `AndroidAppState` -- the same axis `tabs_ui::build` vs. +//! `transcript_ui::build` already varies along on the winit side (compare +//! `iris/examples/tabs.rs` and `iris/transcript-ui/examples/transcript.rs`). +//! A build picks one screen or the other, never both, so `Client` and +//! `TranscriptClient` are cfg-gated apart rather than switched at runtime -- +//! there is no in-app navigation to switch *to* on either side yet. use android_view::{ Context, View, @@ -18,19 +32,26 @@ use android_view::{ }, register_view_class, }; +#[cfg(not(feature = "transcript-screen"))] use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState}; +#[cfg(not(feature = "transcript-screen"))] use iris::prelude::*; use log::LevelFilter; use std::ffi::c_void; +#[cfg(feature = "transcript-screen")] +mod transcript_client; + /// The app's `View` subclass, matching the Java side's package -- /// `app/src/main/java/dev/iris/android/demo/IrisView.java`. const VIEW_CLASS: &str = "dev/iris/android/demo/IrisView"; +#[cfg(not(feature = "transcript-screen"))] pub struct Client { ui_state: AndroidUiState, } +#[cfg(not(feature = "transcript-screen"))] impl HasAndroidUiState for Client { fn android_state(&self) -> &AndroidUiState { &self.ui_state @@ -40,6 +61,7 @@ impl HasAndroidUiState for Client { } } +#[cfg(not(feature = "transcript-screen"))] impl AndroidAppState for Client { fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc) -> Self { // `widgets.info` is the winit example's frame-debug readout, kept @@ -61,12 +83,17 @@ impl AndroidAppState for Client { } } +#[cfg(not(feature = "transcript-screen"))] +type ActiveClient = Client; +#[cfg(feature = "transcript-screen")] +type ActiveClient = transcript_client::TranscriptClient; + extern "system" fn new_view_peer<'local>( env: JNIEnv<'local>, view: View<'local>, context: Context<'local>, ) -> jlong { - iris::android::new_peer::(env, view, context) + iris::android::new_peer::(env, view, context) } /// # Safety diff --git a/iris/android-app/src/transcript_client.rs b/iris/android-app/src/transcript_client.rs new file mode 100644 index 0000000..ea09982 --- /dev/null +++ b/iris/android-app/src/transcript_client.rs @@ -0,0 +1,306 @@ +//! RUST.md's I5 Android integration: `transcript-ui`'s screen filling the +//! whole window on android-view, against a real `ai-server` through +//! `client-core` -- the missing half `iris-android-app` (I2) only had for +//! `tabs-ui` until now. Behind the `transcript-screen` Cargo feature so the +//! plain build (`cargo ndk build`, no `--features`) stays exactly the tabs +//! demo I2/I4 already measured against. +//! +//! **Deliberate simplification, recorded rather than left to be +//! rediscovered (RUST.md's I5 box has the full account)**: there is no +//! session list and no enrollment UI here. The server, port, token and +//! pinned CA are baked in at build time (`build.rs`'s +//! `AI_APP_TRANSCRIPT_HOST`/`_PORT`/`_TOKEN`/`AI_APP_CA`), and the first +//! session `ApiClient::fetch_sessions` returns is opened automatically -- +//! there is nothing to tap to get there, which is what `transcript-bench.sh` +//! and `ui-trace` need to land straight on the screen under test. A real +//! app needs `desktop-app`'s `EnrolledServer`/QR-link flow or E3's +//! Keystore-sealed `ServerConfig.kt`; building a second one of those was +//! not this pass's job. +//! +//! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** -- +//! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from +//! `client_core::transcript_fold`, a `generation` counter guarding against +//! a stale background response, and a full rebuild of the widget tree on +//! every event (same tradeoff, same reason: `push_row` cannot update a row +//! already on screen, and this rig's conversations are small). What +//! differs is only the redraw mechanism: android-view has no +//! `winit::EventLoopProxy`, so this uses `iris::task::Tasks::redraw_handle` +//! (new, added alongside this box) to request a frame after each +//! `TaskCtx::update` instead of relying on `Tasks::spawn`'s single +//! end-of-future redraw -- see that method's own doc for why. + +use client_core::api::{ApiClient, UreqTransport}; +use client_core::event_stream::{StreamItem, follow_session_events}; +use client_core::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs}; +use event_model::SeqEvent; +use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState}; +use iris::prelude::*; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +mod pinned { + include!(concat!(env!("OUT_DIR"), "/pinned_config.rs")); +} + +pub struct TranscriptClient { + ui_state: AndroidUiState, + screen: Option, + /// The folded transcript as of the last rebuild -- kept here (not + /// re-derived) for the same reason `desktop-app`'s `Client::items` + /// exists: a live `StreamEvent` only carries one new wire event, and + /// `fold_event` needs everything folded so far to fold it in. + items: Vec, + /// The session currently open -- `None` only before the first fetch + /// resolves. Read back by `apply_event`'s rebuild, which has no session + /// id of its own (a live `SeqEvent` doesn't carry one). + session_id: Option, + /// Bumped every time a new session load starts; a background response + /// checks it before touching state, so a slow reply for a session this + /// screen has moved on from can't overwrite what replaced it. There is + /// only ever one session here (no list to switch away to), but the + /// guard still matters for the *first* fetch racing a `stop`/`start`. + generation: Arc, +} + +impl HasAndroidUiState for TranscriptClient { + fn android_state(&self) -> &AndroidUiState { + &self.ui_state + } + fn android_state_mut(&mut self) -> &mut AndroidUiState { + &mut self.ui_state + } +} + +/// Builds one `UreqTransport` from the config `build.rs` baked in. Called +/// twice per session load, same as `desktop-app`'s `build_transport` +/// closure -- `ApiClient` and the live-stream follow each need their own, +/// since `UreqTransport` holds its own `ureq::Agent`. +fn build_transport() -> Result { + let base_url = format!("https://{}:{}", pinned::HOST, pinned::PORT); + UreqTransport::new( + base_url, + pinned::TOKEN.to_string(), + pinned::CA_PEM.as_bytes(), + ) + .map_err(|e| e.to_string()) +} + +fn placeholder(rsc: &mut Rsc, message: &str) -> StrongWidget { + wtext(message.to_string()) + .color(Color::WHITE) + .wrap(true) + .pad(16) + .add_strong(rsc) + .any() +} + +impl AndroidAppState for TranscriptClient { + fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc) -> Self { + let loading = placeholder(rsc, "Loading sessions..."); + ui_state.set_root(loading); + + let mut client = Self { + ui_state, + screen: None, + items: Vec::new(), + session_id: None, + generation: Arc::new(AtomicU64::new(0)), + }; + client.spawn_fetch_sessions(rsc); + client + } + + fn back_pressed(&mut self, _rsc: &mut AndroidRsc, _render: &mut UiRenderState) -> bool { + // No screen stack of its own -- same "let the activity finish" + // answer `iris-android-app`'s tabs `Client` already gives. + false + } +} + +impl TranscriptClient { + fn show_message(&mut self, rsc: &mut AndroidRsc, message: &str) { + let widget = placeholder(rsc, message); + self.android_state_mut().set_root(widget); + self.screen = None; + } + + fn spawn_fetch_sessions(&mut self, rsc: &mut AndroidRsc) { + let redraw = rsc.tasks.redraw_handle(); + let my_generation = self.generation.load(Ordering::SeqCst); + let generation = self.generation.clone(); + rsc.spawn_task(async move |mut ctx| { + let outcome = match build_transport() { + Ok(transport) => ApiClient::new(transport) + .fetch_sessions() + .map_err(|e| e.to_string()), + Err(e) => Err(format!("couldn't set up TLS: {e}")), + }; + ctx.update(move |state: &mut TranscriptClient, rsc| { + if generation.load(Ordering::SeqCst) != my_generation { + return; + } + match outcome { + Ok(sessions) => match sessions.into_iter().next() { + Some(session) => state.select_session(rsc, session.id), + None => state.show_message(rsc, "No sessions on the sandbox server."), + }, + Err(message) => { + state.show_message(rsc, &format!("Couldn't list sessions: {message}")) + } + } + }); + redraw.request_redraw(); + }); + } + + /// Loads the opening page, then follows the live SSE stream for the + /// rest of this session's life -- `desktop-app`'s `select_session` + /// almost verbatim, with `Proxy::send_event` replaced by `ctx.update` + + /// `redraw.request_redraw()` (see this module's doc). + fn select_session(&mut self, rsc: &mut AndroidRsc, session_id: String) { + let my_generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1; + self.items.clear(); + self.session_id = Some(session_id.clone()); + self.show_message(rsc, "Loading transcript..."); + + let redraw = rsc.tasks.redraw_handle(); + let live_generation = self.generation.clone(); + rsc.spawn_task(async move |mut ctx| { + let transports = + build_transport().and_then(|rest| build_transport().map(|stream| (rest, stream))); + let (rest, stream_transport) = match transports { + Ok(pair) => pair, + Err(e) => { + let message = format!("couldn't set up TLS: {e}"); + ctx.update(move |state: &mut TranscriptClient, rsc| { + if live_generation.load(Ordering::SeqCst) == my_generation { + state.show_message(rsc, &message); + } + }); + redraw.request_redraw(); + return; + } + }; + let api = ApiClient::new(rest); + + // The most recent 200 events, coalesced -- the same page size + // `desktop-app` uses; RUST.md's I3/history-paging work is what + // a real scrollback would reuse (out of scope here, same as + // E4). + let page: Result, String> = api + .fetch_transcript_page(&session_id, None, 200, true) + .map_err(|e| e.to_string()); + // The wire `seq` of the last line, not a folded item's `seq()` + // -- see `client_core::transcript_fold::raw_seq`'s doc for why + // resuming from the latter re-delivers deltas already folded + // into an in-progress reply. + let after = page + .as_ref() + .ok() + .and_then(|values| values.last()) + .and_then(client_core::transcript_fold::raw_seq) + .unwrap_or(0); + let result = page.and_then(|values| fold_page(&values)); + + { + let live_generation = live_generation.clone(); + ctx.update(move |state: &mut TranscriptClient, rsc| { + if live_generation.load(Ordering::SeqCst) != my_generation { + return; + } + match result { + Ok(items) => { + state.items = items; + state.rebuild_transcript(rsc); + } + Err(message) => { + state.show_message(rsc, &format!("Couldn't load transcript: {message}")) + } + } + }); + } + redraw.request_redraw(); + + if live_generation.load(Ordering::SeqCst) != my_generation { + return; + } + // The outer closure here is an `FnMut` -- `follow_session_events` + // calls it once per line -- so it captures `live_generation` by + // move and re-clones it for each inner `ctx.update` closure + // rather than moving a shared `stop`-style helper into itself: + // a value moved out of an `FnMut`'s captures on one call leaves + // nothing there for the next. + let _ = + follow_session_events( + &stream_transport, + &session_id, + after, + move |item| match item { + StreamItem::Open | StreamItem::Reset => { + live_generation.load(Ordering::SeqCst) == my_generation + } + StreamItem::Event { event, .. } => { + if live_generation.load(Ordering::SeqCst) != my_generation { + return false; + } + let live_generation = live_generation.clone(); + ctx.update(move |state: &mut TranscriptClient, rsc| { + if live_generation.load(Ordering::SeqCst) != my_generation { + return; + } + state.apply_event(rsc, &event); + }); + redraw.request_redraw(); + true + } + }, + ); + }); + } + + /// Rebuilds the whole widget tree from `self.items` -- same tradeoff as + /// `desktop-app`'s `rebuild_transcript` (this module's doc comment). + /// Reads `self.session_id` rather than taking one, since every caller + /// (the opening page, and every live event) already has it set there. + fn rebuild_transcript(&mut self, rsc: &mut AndroidRsc) { + let in_progress = self + .screen + .as_ref() + .map(|screen| screen.composer.field.edit(rsc).text.text().to_string()) + .filter(|t| !t.is_empty()); + + let rows = group_tool_runs(&self.items); + let (screen, tree) = transcript_ui::build_tree(rsc, rows); + + if let Some(text) = in_progress { + screen.composer.field.edit(rsc).set(&text); + } + if let Some(session_id) = self.session_id.clone() { + let field = screen.composer.field; + rsc.register_event(field, Submit, move |ctx, rsc| { + let text = field.edit(rsc).take(); + let text = text.trim().to_string(); + if !text.is_empty() { + ctx.state.send_message(session_id.clone(), text); + } + }); + } + + self.android_state_mut().set_root(tree); + self.screen = Some(screen); + } + + fn apply_event(&mut self, rsc: &mut AndroidRsc, event: &SeqEvent) { + self.items = fold_event(&self.items, event); + self.rebuild_transcript(rsc); + } + + fn send_message(&mut self, session_id: String, text: String) { + std::thread::spawn(move || { + if let Ok(transport) = build_transport() { + let api = ApiClient::new(transport); + let _ = api.send_message(&session_id, &text, &[]); + } + }); + } +} diff --git a/iris/src/task.rs b/iris/src/task.rs index ddad795..752a6f9 100644 --- a/iris/src/task.rs +++ b/iris/src/task.rs @@ -72,6 +72,18 @@ impl Tasks { ) } + /// The same redraw handle `spawn`'s wrapper calls once, after a whole + /// task's future completes -- exposed so a caller running its own + /// longer-lived loop *inside* a spawned task (a live SSE follow, here) + /// can ask for a frame after each `TaskCtx::update`, not just at the + /// end. Without this a caller has no way to get a redraw mid-stream, + /// which is exactly the gap `iris/desktop-app`'s `app.rs` module doc + /// names for why it uses winit's `Proxy` instead of `Tasks` -- Android + /// has no `Proxy`, so this is what closes the same gap there. + pub fn redraw_handle(&self) -> Arc { + self.redraw.clone() + } + pub fn spawn) + 'static + std::marker::Send>(&mut self, task: F) where F::CallOnceFuture: Send,