iris-android-app: transcript-screen feature -- I5's Android integration

Extends the existing tabs demo shell (I2/E5's Gradle project, JNI
registration) with a second, mutually-exclusive AndroidAppState rather than
building a third shell -- it already has the working IrisView/MainActivity
Java and the register_view_class wiring, and the only thing a transcript
screen needs on top is a different Client type (the same axis
tabs_ui::build vs. transcript_ui::build already varies along on winit).

`--no-default-features --features transcript-screen` builds
transcript_client::TranscriptClient instead of the plain tabs Client:
fetches the sandbox's session list, opens the first one, and follows it
live, reusing desktop-app's app.rs shape (fold_event/group_tool_runs/
fold_page/raw_seq, a generation counter) almost verbatim. The one real
difference is the redraw path -- android-view has no winit::EventLoopProxy,
so Tasks gained redraw_handle() (iris/src/task.rs) to let a caller request
a frame after each TaskCtx::update from inside a still-running task, not
just once when the whole future completes.

Deliberate simplification, not a template: there is no session list or
enrollment UI here. build.rs bakes the sandbox's host/port/token plus the
pinned CA in at build time from AI_APP_TRANSCRIPT_HOST/_PORT/_TOKEN and
AI_APP_CA, the same trust-boundary reasoning as the Compose app's
GeneratePinnedCert Gradle task, extended to also bake the enrollment since
building a real one (Keystore-sealed storage, a QR/link scanner) is E3's
scope, not this box's. Recorded in RUST.md's I5 box.

tabs-ui and the transcript-screen deps are now both optional, gated by
mutually exclusive tabs-screen (default) / transcript-screen features --
building one screen with the other's default deps still active tripped
Cargo's unused_dependencies lint.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-05 13:26:10 -04:00
1 parent 78aff64844
commit aa3d11471f
6 files changed
+820 -4

No files matched your search

+363
View File
@@ -558,6 +558,12 @@ dependencies = [
"arrayvec", "arrayvec",
] ]
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.8.0" version = "0.8.0"
@@ -734,6 +740,16 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "client-core"
version = "0.1.0"
dependencies = [
"event-model",
"serde",
"serde_json",
"ureq",
]
[[package]] [[package]]
name = "clipboard-win" name = "clipboard-win"
version = "5.4.1" version = "5.4.1"
@@ -779,6 +795,35 @@ dependencies = [
"crossbeam-utils", "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]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@@ -886,6 +931,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]] [[package]]
name = "dispatch" name = "dispatch"
version = "0.2.0" version = "0.2.0"
@@ -1048,6 +1099,14 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "event-model"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]] [[package]]
name = "exr" name = "exr"
version = "1.74.2" version = "1.74.2"
@@ -1179,6 +1238,15 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" 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]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.34" version = "0.3.34"
@@ -1253,6 +1321,26 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.3.4" version = "0.3.4"
@@ -1423,6 +1511,22 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" 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]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.3.0" version = "2.3.0"
@@ -1551,6 +1655,27 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" 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]] [[package]]
name = "image" name = "image"
version = "0.25.10" version = "0.25.10"
@@ -1640,9 +1765,13 @@ version = "0.1.0"
dependencies = [ dependencies = [
"android-view", "android-view",
"android_logger", "android_logger",
"client-core",
"event-model",
"iris", "iris",
"log", "log",
"serde_json",
"tabs-ui", "tabs-ui",
"transcript-ui",
] ]
[[package]] [[package]]
@@ -1676,6 +1805,12 @@ dependencies = [
"either", "either",
] ]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]] [[package]]
name = "jni" name = "jni"
version = "0.21.1" version = "0.21.1"
@@ -2096,6 +2231,12 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]] [[package]]
name = "num-derive" name = "num-derive"
version = "0.4.2" version = "0.4.2"
@@ -2744,6 +2885,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.21" version = "0.2.21"
@@ -2796,6 +2943,25 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "pulp" name = "pulp"
version = "0.22.3" version = "0.22.3"
@@ -3075,6 +3241,20 @@ version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" 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]] [[package]]
name = "roxmltree" name = "roxmltree"
version = "0.21.1" version = "0.21.1"
@@ -3125,6 +3305,41 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.23" version = "1.0.23"
@@ -3207,6 +3422,19 @@ dependencies = [
"syn 3.0.5", "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]] [[package]]
name = "serde_repr" name = "serde_repr"
version = "0.1.21" version = "0.1.21"
@@ -3363,6 +3591,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swash" name = "swash"
version = "0.2.10" version = "0.2.10"
@@ -3490,6 +3724,36 @@ dependencies = [
"zune-jpeg", "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]] [[package]]
name = "tiny-skia" name = "tiny-skia"
version = "0.11.4" version = "0.11.4"
@@ -3596,6 +3860,16 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "transcript-ui"
version = "0.1.0"
dependencies = [
"client-core",
"event-model",
"iris",
"pulldown-cmark",
]
[[package]] [[package]]
name = "tree_magic_mini" name = "tree_magic_mini"
version = "3.2.2" version = "3.2.2"
@@ -3634,6 +3908,12 @@ dependencies = [
"keyboard-types", "keyboard-types",
] ]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@@ -3652,6 +3932,62 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" 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]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@@ -3696,6 +4032,12 @@ dependencies = [
"winapi-util", "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]] [[package]]
name = "wasip2" name = "wasip2"
version = "1.0.4+wasi-0.2.12" version = "1.0.4+wasi-0.2.12"
@@ -3889,6 +4231,15 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "weezl" name = "weezl"
version = "0.1.12" version = "0.1.12"
@@ -4748,6 +5099,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.5" version = "0.2.5"
@@ -4789,6 +5146,12 @@ version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]] [[package]]
name = "zune-core" name = "zune-core"
version = "0.5.3" version = "0.5.3"
+17 -1
View File
@@ -17,10 +17,26 @@ crate-type = ["cdylib"]
[dependencies] [dependencies]
iris = { path = "../" } iris = { path = "../" }
tabs-ui = { path = "../tabs-ui" }
android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" } android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" }
android_logger = "0.15.0" android_logger = "0.15.0"
log = "0.4.28" 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] [profile.release]
panic = "abort" panic = "abort"
+92
View File
@@ -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::<u16>()
.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}")
})
}
+30 -3
View File
@@ -1,5 +1,5 @@
//! The android-view demo app: iris's `tabs` widget tree (`tabs_ui::build`, //! The android-view demo app: by default, iris's `tabs` widget tree
//! shared with the winit example) running through //! (`tabs_ui::build`, shared with the winit example) running through
//! `iris::android`'s `ViewPeer`. This is RUST.md's I2 pass condition made //! `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. //! 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 //! wrapping `iris::android::new_peer`'s generic function in a concrete
//! `extern "system" fn`, since `register_view_class` wants a plain //! `extern "system" fn`, since `register_view_class` wants a plain
//! function pointer. //! 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::{ use android_view::{
Context, View, Context, View,
@@ -18,19 +32,26 @@ use android_view::{
}, },
register_view_class, register_view_class,
}; };
#[cfg(not(feature = "transcript-screen"))]
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState}; use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
#[cfg(not(feature = "transcript-screen"))]
use iris::prelude::*; use iris::prelude::*;
use log::LevelFilter; use log::LevelFilter;
use std::ffi::c_void; use std::ffi::c_void;
#[cfg(feature = "transcript-screen")]
mod transcript_client;
/// The app's `View` subclass, matching the Java side's package -- /// The app's `View` subclass, matching the Java side's package --
/// `app/src/main/java/dev/iris/android/demo/IrisView.java`. /// `app/src/main/java/dev/iris/android/demo/IrisView.java`.
const VIEW_CLASS: &str = "dev/iris/android/demo/IrisView"; const VIEW_CLASS: &str = "dev/iris/android/demo/IrisView";
#[cfg(not(feature = "transcript-screen"))]
pub struct Client { pub struct Client {
ui_state: AndroidUiState, ui_state: AndroidUiState,
} }
#[cfg(not(feature = "transcript-screen"))]
impl HasAndroidUiState for Client { impl HasAndroidUiState for Client {
fn android_state(&self) -> &AndroidUiState { fn android_state(&self) -> &AndroidUiState {
&self.ui_state &self.ui_state
@@ -40,6 +61,7 @@ impl HasAndroidUiState for Client {
} }
} }
#[cfg(not(feature = "transcript-screen"))]
impl AndroidAppState for Client { impl AndroidAppState for Client {
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self { fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
// `widgets.info` is the winit example's frame-debug readout, kept // `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>( extern "system" fn new_view_peer<'local>(
env: JNIEnv<'local>, env: JNIEnv<'local>,
view: View<'local>, view: View<'local>,
context: Context<'local>, context: Context<'local>,
) -> jlong { ) -> jlong {
iris::android::new_peer::<Client>(env, view, context) iris::android::new_peer::<ActiveClient>(env, view, context)
} }
/// # Safety /// # Safety
+306
View File
@@ -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<transcript_ui::TranscriptScreen>,
/// 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<TranscriptItem>,
/// 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<String>,
/// 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<AtomicU64>,
}
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<UreqTransport, String> {
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: HasEvents>(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>) -> 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<Self>, _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<Self>, 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<Self>) {
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<Self>, 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<Vec<serde_json::Value>, 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<Self>) {
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<Self>, 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, &[]);
}
});
}
}
+12
View File
@@ -72,6 +72,18 @@ impl<Rsc: HasState> Tasks<Rsc> {
) )
} }
/// 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<dyn RequestRedraw> {
self.redraw.clone()
}
pub fn spawn<F: AsyncFnOnce(TaskCtx<Rsc>) + 'static + std::marker::Send>(&mut self, task: F) pub fn spawn<F: AsyncFnOnce(TaskCtx<Rsc>) + 'static + std::marker::Send>(&mut self, task: F)
where where
F::CallOnceFuture: Send, F::CallOnceFuture: Send,