iris android app: told which server by an enrol link, not by its build
The APK is cross-compiled here and run against the server on the host, so everything build.rs baked in (AI_APP_TRANSCRIPT_HOST/_PORT/_TOKEN and this machine's CA) was good for exactly the pair that built it -- and a token in a delivered artifact besides. MainActivity registers aiapp://enroll, hands the URI and the app's private files directory to Rust, and client_core::config stores it 0600; transcript_client reads it afresh per transport, so opening a new link repoints a running app. Diagnostics says which of three things is true, because they want different actions: 'enrolled: host:port', 'not enrolled -- open the enrol link from Dev Updater', and 'enrolment unreadable: ...' for the case nothing could be found out. The last is why status() has an Unknown arm at all. ui-sandbox.sh's printed enrol command now carries the CA, which is what makes it work for an app with no baked copy. Verified on this checkout's emulator: fresh install reads 'not enrolled', the intent enrols (log: 'enrolled with 10.0.2.2:8519', enrollment.json -rw-------), Diagnostics then reads 'enrolled: 10.0.2.2:8519', and the CA reconstructed from that link is byte-identical to the machine's ca.pem and validates the server over curl. Android offered the chooser between this app and the Compose one, which is the intended behaviour. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
22210a42f5
commit
d8562d96a3
9 files changed
+340
-105
No files matched your search
+16
-1
@@ -404,12 +404,27 @@ done
|
||||
# Percent-encoded because the app URL-decodes the deep link's query: a
|
||||
# token with '+' in it enrols as one with a space, and nothing reports it.
|
||||
enc=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$TOKEN")
|
||||
# The CA rides in the link (`wg_app_link::enroll::ca_param`: base64url of
|
||||
# the DER, which needs no percent-encoding). The Compose app ignores it and
|
||||
# pins the copy its APK was built with; the iris app has no baked copy at
|
||||
# all -- it is cross-compiled and could be pointed at any machine -- so
|
||||
# without this it enrols and then trusts nothing. Minted here rather than by
|
||||
# `--enroll-link` because this token is the sandbox's own, carried across
|
||||
# restarts so the emulator stays enrolled (see the top of this file).
|
||||
ca=$(python3 - "$CERTS/ca.pem" <<'CA'
|
||||
import base64, sys
|
||||
pem = open(sys.argv[1]).read()
|
||||
body = pem.split("-----BEGIN CERTIFICATE-----")[1].split("-----END CERTIFICATE-----")[0]
|
||||
der = base64.b64decode("".join(body.split()))
|
||||
print(base64.urlsafe_b64encode(der).decode().rstrip("="))
|
||||
CA
|
||||
)
|
||||
cat <<INFO
|
||||
sandbox: server $pid on 127.0.0.1:$PORT, log $LOG
|
||||
sandbox: 9 invented Claude Code sessions under $PROJECTS (one of them ${BIG_MB}MB)
|
||||
|
||||
enrol the emulator (once; it survives sandbox restarts):
|
||||
adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$enc'"
|
||||
adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$enc&ca=$ca'"
|
||||
|
||||
drive it:
|
||||
./ui-sandbox.sh spawn [title] an echo session; prints its id
|
||||
|
||||
Generated
+1
@@ -744,6 +744,7 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
name = "client-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"event-model",
|
||||
"log",
|
||||
"pulldown-cmark",
|
||||
|
||||
@@ -24,6 +24,21 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- The enrollment link Dev Updater's Enroll button opens
|
||||
(what `ai-server` mints), the same one the Compose app
|
||||
in `app/` registers: which app answers it is the phone
|
||||
owner's choice at the moment of the tap, and both being
|
||||
offered is the intended behaviour rather than a clash.
|
||||
BROWSABLE so a link tapped in another app reaches here,
|
||||
and `android:host` so this app is not offered for every
|
||||
aiapp:// URI a future route invents. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="aiapp" android:host="enroll" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data android:name="android.app.lib_name" android:value="main" />
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package dev.iris.android.demo;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.WindowInsets;
|
||||
@@ -20,9 +22,28 @@ public final class MainActivity extends Activity {
|
||||
System.loadLibrary("main");
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's private directory, where the Rust side keeps its enrollment
|
||||
* (`src/enrollment.rs`). Handed over before the view is built, because
|
||||
* the client the view creates reads the enrollment as it starts.
|
||||
*/
|
||||
private static native void nativeSetFilesDir(String path);
|
||||
|
||||
/**
|
||||
* One `aiapp://enroll?host=&port=&token=&ca=` link, as Dev Updater's
|
||||
* Enroll button opens it. Parsed and stored on the Rust side, which is
|
||||
* where the enrollment lives for the desktop app too -- nothing about
|
||||
* the link's format is known here.
|
||||
*/
|
||||
private static native void nativeEnroll(String uri);
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle state) {
|
||||
super.onCreate(state);
|
||||
// Before the view: creating it starts the Rust client, which asks
|
||||
// straight away which server it is enrolled with.
|
||||
nativeSetFilesDir(getFilesDir().getAbsolutePath());
|
||||
handleEnrollmentIntent(getIntent());
|
||||
IrisView view = new IrisView(this);
|
||||
view.setLayoutParams(new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||||
@@ -99,6 +120,35 @@ public final class MainActivity extends Activity {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A link that arrives while the activity is already up. `singleTop` is
|
||||
* not set, so this is the resumed case only -- the fresh-launch case
|
||||
* goes through `onCreate`'s `getIntent`. `setIntent` so a later
|
||||
* `getIntent` reports the one actually being acted on rather than the
|
||||
* one this activity started with.
|
||||
*/
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
handleEnrollmentIntent(intent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands a VIEW intent's URI to the Rust side, which decides whether it
|
||||
* is an enrollment link -- the scheme is checked here only so a launch
|
||||
* intent (which carries no data) costs nothing.
|
||||
*/
|
||||
private static void handleEnrollmentIntent(Intent intent) {
|
||||
if (intent == null) {
|
||||
return;
|
||||
}
|
||||
Uri data = intent.getData();
|
||||
if (data != null) {
|
||||
nativeEnroll(data.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/** Read one `WindowInsets` and hand it to the Rust side. The only
|
||||
* place that reads these fields, so the static dispatch and the
|
||||
* animation callback above cannot come to report different things. */
|
||||
|
||||
+18
-79
@@ -2,82 +2,29 @@
|
||||
// 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.
|
||||
// **Nothing about the server this app talks to is baked in any more.** It
|
||||
// used to be (`AI_APP_TRANSCRIPT_HOST`/`_PORT`/`_TOKEN` plus the machine's
|
||||
// own CA), which made an APK good for exactly the emulator/server pair
|
||||
// that built it -- and useless for the case that matters, an APK
|
||||
// cross-compiled in this VM and run against the server on the host. The
|
||||
// destination arrives at runtime instead, from an `aiapp://enroll` link
|
||||
// carrying the CA with it (`src/enrollment.rs`), the same way the Compose
|
||||
// app and `desktop-app` are told.
|
||||
//
|
||||
// What is left here is the log upload's own destination, which is on its
|
||||
// way out for a different reason (Dev Updater is growing a runtime-log
|
||||
// view of its own, 2026-09-07) and is left untouched for that change.
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
if std::env::var_os("CARGO_FEATURE_TRANSCRIPT_SCREEN").is_none() {
|
||||
return;
|
||||
}
|
||||
// Where this build sends its own log ring, if anywhere. Emitted for
|
||||
// every build that has `client-core` (so the `bench` build, which
|
||||
// returns below, gets it too): the log route is the one thing a bench
|
||||
// APK on a phone with no `logcat` needs a server for even though it
|
||||
// opens a checked-in fixture and talks to nothing else.
|
||||
//
|
||||
// **Optional, unlike the transcript config below.** A build with none
|
||||
// of these set still keeps its ring and still shows it in `Copy
|
||||
// report`; it just has nowhere to send it. So the same `build-apk.sh`
|
||||
// works on a machine that has not decided where logs go.
|
||||
// Where this build sends its own log ring, if anywhere -- the one
|
||||
// thing left that a build is told rather than enrolled with. A build
|
||||
// told none of it still keeps its ring and still shows it in `Copy
|
||||
// report`; it just has nowhere to send it.
|
||||
emit_log_config();
|
||||
// P0's bench build (docs/RUST.md) opens the checked-in fixture with no
|
||||
// server at all -- `bench_client.rs` never references the `pinned`
|
||||
// module this generates, so requiring a live server's host/port/token/
|
||||
// CA to build it (as plain `transcript-screen` does, below) would be a
|
||||
// pointless requirement for a build that talks to nothing.
|
||||
if std::env::var_os("CARGO_FEATURE_BENCH").is_some() {
|
||||
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_pem = read_pinned_ca();
|
||||
|
||||
let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
|
||||
let generated = format!(
|
||||
"// Generated by build.rs from {host}:{port}. 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}")),
|
||||
host_lit = host,
|
||||
token_lit = token,
|
||||
ca_lit = ca_pem,
|
||||
);
|
||||
std::fs::write(out_dir.join("pinned_config.rs"), generated).unwrap();
|
||||
}
|
||||
|
||||
/// Writes `log_config.rs` into `OUT_DIR`: the server this build's log ring
|
||||
@@ -133,10 +80,8 @@ fn emit_log_config() {
|
||||
}
|
||||
|
||||
/// The CA this machine's `ai-server` signs with: `AI_APP_CA`, else
|
||||
/// `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`. One reader for both generated
|
||||
/// configs -- the log destination and the transcript destination are the
|
||||
/// same server's certificate, and two copies of this would be two ways to
|
||||
/// disagree about which one was pinned.
|
||||
/// `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`. Only the log upload pins this
|
||||
/// now; the screen's own server arrives with its CA at enrolment time.
|
||||
fn read_pinned_ca() -> String {
|
||||
let ca_path = std::env::var_os("AI_APP_CA")
|
||||
.map(PathBuf::from)
|
||||
@@ -165,9 +110,3 @@ fn read_pinned_ca() -> String {
|
||||
);
|
||||
ca_pem
|
||||
}
|
||||
|
||||
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}")
|
||||
})
|
||||
}
|
||||
@@ -559,8 +559,13 @@ impl BenchClient {
|
||||
// composer up" cannot be told from "the listener never fired"
|
||||
// without it (`AndroidUiState::insets_report`).
|
||||
format!(
|
||||
"{renderer}\n{}\n{}",
|
||||
"{renderer}\n{}\n{}\n{}",
|
||||
self.android_state().insets_report(),
|
||||
// Which server this build talks to, and what to do when the
|
||||
// answer is "none" -- the bench itself opens a checked-in
|
||||
// fixture and needs no server, so this pane is the only place
|
||||
// an enrolment can be seen to have taken.
|
||||
crate::enrollment::status_line(),
|
||||
crate::app_log::diagnostics_line(self.log_upload.as_ref())
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Which `ai-server` this app talks to, and how it was told.
|
||||
//!
|
||||
//! The parsing, the file and its owner-only mode are
|
||||
//! `client_core::config` (`EnrolledServer`/`EnrollmentStore`), shared with
|
||||
//! the desktop app. What is genuinely this platform's, and all that is
|
||||
//! here, is the intent plumbing: Android hands an `aiapp://enroll?...`
|
||||
//! link to `MainActivity`, which passes it and the app's private files
|
||||
//! directory across JNI (see `lib.rs`'s two exported functions).
|
||||
//!
|
||||
//! **Why the app is told at runtime rather than at build time.** The APK
|
||||
//! is cross-compiled in a VM and run against the server on the host, whose
|
||||
//! CA and token are not this machine's -- so nothing about the destination
|
||||
//! can be baked in, and no token or CA may sit in a repo or a delivered
|
||||
//! artifact either way. The CA arrives with the link (`ca` parameter,
|
||||
//! `wg_app_link::enroll::ca_param`), which is what makes an APK built
|
||||
//! anywhere able to pin the server it is pointed at.
|
||||
//!
|
||||
//! The files directory is process-wide state, which this project otherwise
|
||||
//! avoids: it arrives from the activity, and `AndroidAppState::new` -- the
|
||||
//! first thing that wants the enrollment -- has no parameter it could come
|
||||
//! in through. Same shape, and the same reason, as
|
||||
//! `client_core::log_ring`'s process ring.
|
||||
|
||||
#[cfg(not(feature = "bench"))]
|
||||
use client_core::api::UreqTransport;
|
||||
use client_core::config::{EnrolledServer, EnrollmentStore};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// `Context.getFilesDir()`, handed over by `MainActivity` before it builds
|
||||
/// the view. Set once per process; a second call with a different path is
|
||||
/// a programmer error rather than something to recover from, and a second
|
||||
/// call with the same one is what a re-created activity does.
|
||||
static FILES_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
pub fn set_files_dir(dir: PathBuf) {
|
||||
if let Err(existing) = FILES_DIR.set(dir.clone()) {
|
||||
assert_eq!(
|
||||
existing, dir,
|
||||
"the app's files directory was set twice with different paths"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `None` before `MainActivity` has handed the directory over -- which is
|
||||
/// **not** the same as "not enrolled", and is why [`status`] has a state
|
||||
/// for it (UI_RULES: design the unknown state first).
|
||||
fn store() -> Option<EnrollmentStore> {
|
||||
FILES_DIR.get().map(EnrollmentStore::new)
|
||||
}
|
||||
|
||||
/// What this app has been told, or why it has not been.
|
||||
pub enum Status {
|
||||
Enrolled(EnrolledServer),
|
||||
/// Nothing has been enrolled yet: the ordinary first-run state.
|
||||
NotEnrolled,
|
||||
/// The question could not be answered -- the activity never handed a
|
||||
/// files directory over, or the file is there and unreadable. Kept
|
||||
/// apart from `NotEnrolled` because the two want different actions
|
||||
/// from whoever is looking.
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
pub fn status() -> Status {
|
||||
let Some(store) = store() else {
|
||||
return Status::Unknown("the activity never handed over a files directory".to_string());
|
||||
};
|
||||
match store.load() {
|
||||
Ok(Some(server)) => Status::Enrolled(server),
|
||||
Ok(None) => Status::NotEnrolled,
|
||||
Err(error) => Status::Unknown(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// One line for the diagnostics pane. The three states read differently on
|
||||
/// purpose: "not enrolled" says what to do about it, and "couldn't tell"
|
||||
/// must not be mistaken for it.
|
||||
///
|
||||
/// Only the bench build has a pane to put this in -- same gate, and the
|
||||
/// same reason, as `app_log::diagnostics_line`. The transcript build says
|
||||
/// the same things where they matter to it, in the message
|
||||
/// [`transport`]'s error becomes on screen.
|
||||
#[cfg(feature = "bench")]
|
||||
pub fn status_line() -> String {
|
||||
match status() {
|
||||
Status::Enrolled(server) => format!("enrolled: {}:{}", server.host, server.port),
|
||||
Status::NotEnrolled => "not enrolled -- open the enrol link from Dev Updater".to_string(),
|
||||
Status::Unknown(why) => format!("enrolment unreadable: {why}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an `aiapp://enroll?...` link and saves it, replacing whatever
|
||||
/// was enrolled before -- opening a link is how somebody says "this server
|
||||
/// now", including after the old one's token was rotated.
|
||||
///
|
||||
/// The returned `Err` is the message for a person: this is called from a
|
||||
/// tap on a link, and a link that did nothing with nothing said is the
|
||||
/// failure the UI rules are most insistent about.
|
||||
pub fn apply_link(uri: &str) -> Result<EnrolledServer, String> {
|
||||
let server = EnrolledServer::parse_link(uri)?;
|
||||
let store = store().ok_or("the app has no files directory to save an enrollment in")?;
|
||||
store
|
||||
.save(&server)
|
||||
.map_err(|error| format!("couldn't save the enrollment: {error}"))?;
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
/// A transport for the enrolled server, pinning the CA the link carried.
|
||||
///
|
||||
/// Gated to the same builds as `transcript_client`, its only caller: the
|
||||
/// bench build opens a checked-in fixture and reaches no server, so
|
||||
/// compiling this into it would be a warning about dead code that is
|
||||
/// dead on purpose.
|
||||
///
|
||||
/// Every failure here is a sentence a screen can show, because there is
|
||||
/// nowhere else for it to go: this app has no `logcat` on the phone it is
|
||||
/// built for.
|
||||
#[cfg(not(feature = "bench"))]
|
||||
pub fn transport() -> Result<UreqTransport, String> {
|
||||
let server = match status() {
|
||||
Status::Enrolled(server) => server,
|
||||
Status::NotEnrolled => {
|
||||
return Err("Not enrolled yet -- open the enrol link from Dev Updater.".to_string());
|
||||
}
|
||||
Status::Unknown(why) => return Err(format!("Couldn't read the enrollment: {why}")),
|
||||
};
|
||||
let ca_pem = server.ca_pem.as_ref().ok_or(
|
||||
"The enrollment link carried no CA, so there is nothing to pin. \
|
||||
Enrol again with a link minted by this server.",
|
||||
)?;
|
||||
UreqTransport::new(server.base_url(), &server.token, ca_pem.as_bytes())
|
||||
.map_err(|error| error.message)
|
||||
}
|
||||
@@ -40,6 +40,7 @@ use android_view::{
|
||||
Context, View,
|
||||
jni::{
|
||||
JNIEnv, JavaVM,
|
||||
objects::{JClass, JString},
|
||||
sys::{JNI_VERSION_1_6, JavaVM as RawJavaVM, jint, jlong},
|
||||
},
|
||||
register_view_class,
|
||||
@@ -60,6 +61,11 @@ mod app_log;
|
||||
mod bench_client;
|
||||
#[cfg(feature = "bench")]
|
||||
mod bench_jni;
|
||||
/// Which server this app talks to, told to it at runtime by an
|
||||
/// `aiapp://enroll` link. Only where `client-core` is linked -- the plain
|
||||
/// tabs demo makes no network call and has nothing to enrol against.
|
||||
#[cfg(feature = "transcript-screen")]
|
||||
mod enrollment;
|
||||
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
|
||||
mod transcript_client;
|
||||
|
||||
@@ -142,3 +148,80 @@ pub unsafe extern "system" fn JNI_OnLoad(vm: *mut RawJavaVM, _: *mut c_void) ->
|
||||
iris::android::register_native_methods(&mut env, VIEW_CLASS);
|
||||
JNI_VERSION_1_6
|
||||
}
|
||||
|
||||
/// `MainActivity.nativeSetFilesDir` -- the app's private directory, handed
|
||||
/// over before the view exists because that is where the enrollment is
|
||||
/// read from and written to (`enrollment`'s module doc).
|
||||
///
|
||||
/// Exported by name rather than registered through `RegisterNatives`: the
|
||||
/// view's methods are registered because `android-view` owns that class
|
||||
/// and hands out one function pointer, whereas these two are this app's
|
||||
/// own activity and the mangled name is the whole of what is needed.
|
||||
///
|
||||
/// Declared in every build, including the tabs demo that has no
|
||||
/// `client-core` to store anything -- a `native` method Java declares and
|
||||
/// the library does not export is an `UnsatisfiedLinkError` when the class
|
||||
/// loads, which would take down a build that merely shares the activity.
|
||||
///
|
||||
/// # Safety
|
||||
/// Called by the JVM with the arguments its `native` declaration names.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeSetFilesDir(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
dir: JString,
|
||||
) {
|
||||
let Some(dir) = jstring(&mut env, dir) else {
|
||||
return;
|
||||
};
|
||||
#[cfg(feature = "transcript-screen")]
|
||||
enrollment::set_files_dir(std::path::PathBuf::from(&dir));
|
||||
log::debug!("iris app: files directory is {dir}");
|
||||
}
|
||||
|
||||
/// `MainActivity.nativeEnroll` -- one `aiapp://enroll?...` link, from the
|
||||
/// VIEW intent that started or resumed the activity.
|
||||
///
|
||||
/// Logged either way rather than answered: the activity has nothing to do
|
||||
/// with the result, and where the enrollment shows up is the diagnostics
|
||||
/// pane (`enrollment::status_line`), which reads the stored answer rather
|
||||
/// than being told it.
|
||||
///
|
||||
/// # Safety
|
||||
/// Called by the JVM with the arguments its `native` declaration names.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeEnroll(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
uri: JString,
|
||||
) {
|
||||
let Some(uri) = jstring(&mut env, uri) else {
|
||||
return;
|
||||
};
|
||||
#[cfg(feature = "transcript-screen")]
|
||||
match enrollment::apply_link(&uri) {
|
||||
// Never the token: `wg-app-link`'s enroll module forbids logging
|
||||
// it, and this line would otherwise be the one place it leaked.
|
||||
Ok(server) => log::info!("iris app: enrolled with {}:{}", server.host, server.port),
|
||||
Err(error) => log::warn!("iris app: that enrolment link was refused -- {error}"),
|
||||
}
|
||||
#[cfg(not(feature = "transcript-screen"))]
|
||||
log::warn!("iris app: {uri} arrived, but this build has no server to enrol with");
|
||||
}
|
||||
|
||||
/// A `JString` as a Rust `String`, or `None` for a null or non-UTF-8 one --
|
||||
/// neither is worth taking the app down for, and both are logged where
|
||||
/// they happen.
|
||||
fn jstring(env: &mut JNIEnv, value: JString) -> Option<String> {
|
||||
if value.is_null() {
|
||||
log::warn!("iris app: the activity passed a null string across JNI");
|
||||
return None;
|
||||
}
|
||||
match env.get_string(&value) {
|
||||
Ok(value) => Some(value.into()),
|
||||
Err(error) => {
|
||||
log::warn!("iris app: couldn't read a string from the activity -- {error}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,15 @@
|
||||
//!
|
||||
//! **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.
|
||||
//! session list here -- the first session `ApiClient::fetch_sessions`
|
||||
//! returns is opened automatically, since 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.
|
||||
//!
|
||||
//! Which server it opens it against is no longer baked in: it is the
|
||||
//! enrollment an `aiapp://enroll` link left behind (`crate::enrollment`,
|
||||
//! and `desktop-app`'s identical `--link`), because an APK
|
||||
//! cross-compiled here cannot pin the CA of a server on the host.
|
||||
//!
|
||||
//! **Reuses `iris/desktop-app`'s `app.rs` shape almost exactly** --
|
||||
//! `fold_event`/`group_tool_runs`/`fold_page`/`raw_seq` from
|
||||
@@ -46,10 +46,6 @@ 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,
|
||||
/// The screen's own content -- everything under the fixed
|
||||
@@ -89,18 +85,16 @@ impl HasAndroidUiState for TranscriptClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
/// Builds one `UreqTransport` from the stored enrollment. 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`.
|
||||
///
|
||||
/// Read afresh each time rather than held: opening a new enrolment link
|
||||
/// while the app is running is how somebody points it at another server,
|
||||
/// and a cached transport would keep talking to the old one.
|
||||
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())
|
||||
crate::enrollment::transport()
|
||||
}
|
||||
|
||||
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
||||
|
||||
Reference in new issue
Block a user