Add Iris Android APK tooling

This commit is contained in:
iris committed 2026-09-11 03:44:06 -04:00
1 parent 3246f397b5
commit 37f956707e
20 files changed
+1766 -54

No files matched your search

+9 -9
View File
@@ -31,14 +31,14 @@ fn utf16_to_byte(text: &str, utf16_idx: usize) -> usize {
impl<State: AndroidAppState> IrisViewPeer<State> {
fn focus(&self) -> Option<WeakWidget<TextEdit>> {
(!self.rsc.events.controllers.command_target_blocks_input())
(!self.rsc.events().controllers.command_target_blocks_input())
.then_some(self.state.android_state().focus)
.flatten()
}
pub(super) fn update_ime_selection(&mut self, ctx: &mut CallbackCtx) {
let Some(focus) = self.focus() else { return };
let text = &self.rsc[focus];
let text = focus.get(&self.rsc);
let Some(sel) = text.selection_range() else {
return;
};
@@ -87,7 +87,7 @@ impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
IME_FLAG_NO_FULLSCREEN | IME_FLAG_NO_EXTRACT_UI | IME_FLAG_NO_ENTER_ACTION,
);
if let Some(focus) = self.focus() {
let text = &self.rsc[focus];
let text = focus.get(&self.rsc);
let sel = text.selection_range().unwrap_or(0..0);
let start = byte_to_utf16(text.text(), sel.start) as i32;
let end = byte_to_utf16(text.text(), sel.end) as i32;
@@ -112,7 +112,7 @@ impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
return None;
}
let focus = self.focus()?;
let text = &self.rsc[focus];
let text = focus.get(&self.rsc);
let sel = text.selection_range()?;
let end_16 = byte_to_utf16(text.text(), sel.start);
let start_16 = end_16.saturating_sub(n as usize);
@@ -129,7 +129,7 @@ impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
return None;
}
let focus = self.focus()?;
let text = &self.rsc[focus];
let text = focus.get(&self.rsc);
let sel = text.selection_range()?;
let len_16 = byte_to_utf16(text.text(), text.text().len());
let start_16 = byte_to_utf16(text.text(), sel.end);
@@ -140,14 +140,14 @@ impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
fn selected_text<'slf>(&'slf mut self, _ctx: &mut CallbackCtx) -> Option<Cow<'slf, str>> {
let focus = self.focus()?;
Some(Cow::Owned(self.rsc[focus].selected_text()?))
Some(Cow::Owned(focus.get(&self.rsc).selected_text()?))
}
fn cursor_caps_mode(&mut self, ctx: &mut CallbackCtx, req_modes: u32) -> u32 {
let Some(focus) = self.focus() else {
return 0;
};
let text = &self.rsc[focus];
let text = focus.get(&self.rsc);
let Some(caret) = text.caret() else {
return 0;
};
@@ -164,7 +164,7 @@ impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
let Some(focus) = self.focus() else {
return false;
};
let text = &self.rsc[focus];
let text = focus.get(&self.rsc);
let Some(sel) = text.selection_range() else {
return false;
};
@@ -223,7 +223,7 @@ impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
let Some(focus) = self.focus() else {
return false;
};
let text = &self.rsc[focus];
let text = focus.get(&self.rsc);
let content = text.text();
let byte = utf16_to_byte(content, end.max(0) as usize);
focus.edit(&mut self.rsc).set_cursor_byte(byte);
+1 -1
View File
@@ -10,7 +10,7 @@ use super::view::AndroidAppState;
/// the arrow keys on a physical keyboard) plus whatever `unicode_char`
/// reports for a plain key press. Returns whether anything used the event.
pub(super) fn on_key<'local, State: AndroidAppState>(
rsc: &mut StdRsc<State>,
rsc: &mut State::Resources,
state: &mut State,
env: &mut JNIEnv<'local>,
key_code: Keycode,
+33 -1
View File
@@ -10,9 +10,41 @@ mod view;
pub use insets::Insets;
pub use render::AndroidRenderer;
pub use view::{
AndroidAppState, AndroidUiState, HasAndroidUiState, IrisViewPeer, WindowInsets, new_peer,
AndroidAppState, AndroidResources, AndroidUiState, HasAndroidUiState, IrisViewPeer,
WindowInsets, new_peer,
};
/// Types used by `#[iris::app_init]`'s generated JNI boundary.
#[doc(hidden)]
pub mod __private {
pub use android_view::{Context, View, jni::JNIEnv};
pub type JInt = android_view::jni::sys::jint;
pub type JLong = android_view::jni::sys::jlong;
pub type RawJavaVM = android_view::jni::sys::JavaVM;
/// Registers the fixed view class shipped by `cargo-iris`.
///
/// # Safety
/// `vm` must be the live VM pointer Android passed to `JNI_OnLoad`.
pub unsafe fn on_load(
vm: *mut RawJavaVM,
new_peer: for<'local> extern "system" fn(
JNIEnv<'local>,
View<'local>,
Context<'local>,
) -> JLong,
) -> JInt {
use android_view::{jni::JavaVM, register_view_class};
let vm = unsafe { JavaVM::from_raw(vm) }.unwrap();
let mut env = vm.get_env().unwrap();
const VIEW_CLASS: &str = "dev/iris/android/IrisView";
register_view_class(&mut env, VIEW_CLASS, new_peer);
super::register_native_methods(&mut env, VIEW_CLASS);
android_view::jni::sys::JNI_VERSION_1_6
}
}
/// Registers the extra native methods this backend needs beyond what
/// `android_view::register_view_class` covers (window insets -- see
/// `insets.rs`'s doc comment for why that one could not ride along on an
+19 -1
View File
@@ -191,7 +191,25 @@ impl AndroidRenderer {
);
let surface_caps = surface.get_capabilities(&adapter);
let formats = iris_core::srgb_surface_format(&surface_caps)?;
let mut formats = iris_core::srgb_surface_format(&surface_caps)?;
let downlevel = adapter.get_downlevel_capabilities();
if formats.view != formats.surface
&& !downlevel
.flags
.contains(DownlevelFlags::SURFACE_VIEW_FORMATS)
{
// GLES implementations are allowed to lack texture-view format
// reinterpretation. Configuring the advertised non-sRGB format
// with an sRGB view then fails validation and leaves the surface
// unusable. Rendering through the advertised format is the only
// viable fallback on those devices.
log::warn!(
"iris renderer: {:?} cannot use an sRGB surface view; rendering through {:?}",
formats.surface,
formats.surface,
);
formats.view = formats.surface;
}
log::info!(
"iris renderer: surface={:?}, view={:?}, color_space=Srgb",
formats.surface,
+60 -34
View File
@@ -89,8 +89,8 @@ impl AndroidUiState {
}
}
impl<State: 'static> HasRoot<StdRsc<State>> for AndroidUiState {
fn set_root(&mut self, rsc: &mut StdRsc<State>, root: StrongWidget) {
impl<Rsc: HasEvents> HasRoot<Rsc> for AndroidUiState {
fn set_root(&mut self, rsc: &mut Rsc, root: StrongWidget) {
self.root = Some(crate::overlay::default_overlay_root(rsc, root));
}
}
@@ -100,16 +100,41 @@ pub trait HasAndroidUiState: Sized + 'static {
fn android_state_mut(&mut self) -> &mut AndroidUiState;
}
/// Application state retained for the lifetime of one Android `View`.
///
/// [`StdRsc`] is the usual [`AndroidResources`] implementation, but the host only
/// requires the capabilities in [`AndroidResources`]. An application may add
/// its own resources by supplying another implementation.
pub trait AndroidAppState: HasAndroidUiState {
fn new(ui_state: AndroidUiState, rsc: &mut StdRsc<Self>) -> Self;
type Resources: AndroidResources<Self>;
#[allow(unused_variables)]
fn back_pressed(&mut self, rsc: &mut StdRsc<Self>) -> bool {
fn back_pressed(&mut self, rsc: &mut Self::Resources) -> bool {
false
}
#[allow(unused_variables)]
fn platform_ready(&mut self, rsc: &mut StdRsc<Self>, vm: JavaVM, view: GlobalRef) {}
fn platform_ready(&mut self, rsc: &mut Self::Resources, vm: JavaVM, view: GlobalRef) {}
#[allow(unused_variables)]
fn on_insets_changed(&mut self, rsc: &mut StdRsc<Self>, insets: WindowInsets) {}
fn on_insets_changed(&mut self, rsc: &mut Self::Resources, insets: WindowInsets) {}
}
/// Resources the Android host needs to draw and dispatch application events.
///
/// This deliberately names capabilities rather than storage. Custom bundles
/// can embed or replace [`StdRsc`] as long as they implement these traits and
/// create the task receiver paired with their [`Tasks`] value.
pub trait AndroidResources<State>:
HasTasks<State = State> + HasWidgetState + Sized + 'static
where
State: 'static,
{
fn new(redraw: Arc<dyn RequestRedraw>) -> (Self, TaskMsgReceiver<Self>);
}
impl<State: 'static> AndroidResources<State> for StdRsc<State> {
fn new(redraw: Arc<dyn RequestRedraw>) -> (Self, TaskMsgReceiver<Self>) {
StdRsc::new(redraw)
}
}
/// Widget-facing insets in physical pixels, decoupled from JNI's integer shape.
@@ -144,9 +169,9 @@ impl WindowInsets {
/// `RustView` instance; `new_peer` (below) builds it and hands the id to
/// Java the same way android-view's own demo does.
pub struct IrisViewPeer<State: AndroidAppState> {
pub(super) rsc: StdRsc<State>,
pub(super) rsc: State::Resources,
pub(super) state: State,
task_recv: TaskMsgReceiver<StdRsc<State>>,
task_recv: TaskMsgReceiver<State::Resources>,
/// Converts input and Choreographer timestamps onto one monotonic clock.
device_clock: Option<DeviceClock>,
}
@@ -169,7 +194,7 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
let ui_state = self.state.android_state_mut();
let cursor = ui_state.cursor.clone();
let old_focus = ui_state.focus;
let render_state = self.rsc.ui.render_state();
let render_state = self.rsc.ui().render_state();
render_state
.get()
.run_sensors(&mut self.rsc, &mut self.state, cursor, window_size);
@@ -200,7 +225,7 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
let ui_state = self.state.android_state_mut();
ui_state.cursor.end_frame();
let render_state = self.rsc.ui.render_state();
let render_state = self.rsc.ui().render_state();
if render_state
.get()
.needs_redraw(&ui_state.root, self.rsc.widgets())
@@ -298,13 +323,13 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
"render(): root={:?} widgets={} active={} root_px={:?} out_size={:?}",
ui_state.root.is_some(),
self.rsc.widgets().len(),
self.rsc.ui.render_state().get().active_widgets(),
self.rsc.ui().render_state().get().active_widgets(),
ui_state
.root
.as_ref()
.and_then(|r| {
self.rsc
.ui
.ui()
.render_state()
.get()
.window_region(r, &self.rsc)
@@ -313,7 +338,7 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
);
}
let frame_start = Instant::now();
let animating = self.rsc.ui.tick_animations(now);
let animating = self.rsc.ui_mut().tick_animations(now);
if animating {
ctx.view.post_frame_callback(&mut ctx.env);
}
@@ -323,7 +348,7 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
let Some(renderer) = &mut ui_state.renderer else {
return;
};
let frame_diagnostics = renderer.update(&mut self.rsc.ui);
let frame_diagnostics = renderer.update(self.rsc.ui_mut());
if renderer.frame_count() <= DIAGNOSTIC_FRAMES {
log::info!(
"iris frame diagnostics: frame={} masks_resized={} moves_resized={} \
@@ -344,20 +369,20 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
.android_state_mut()
.frame_report
.record(now, parts, animating);
let render_state = self.rsc.ui.render_state();
let render_state = self.rsc.ui().render_state();
crate::diagnostics::log_frame(&render_state.get(), now, parts, animating);
if crate::diagnostics::trace_enabled() {
let ui_state = self.state.android_state();
log::debug!(
target: "iris::frame",
"render(): after update active={} root_px={:?}",
self.rsc.ui.render_state().get().active_widgets(),
self.rsc.ui().render_state().get().active_widgets(),
ui_state
.root
.as_ref()
.and_then(|r| {
self.rsc
.ui
.ui()
.render_state()
.get()
.window_region(r, &self.rsc)
@@ -368,7 +393,7 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
let ui_state = self.state.android_state_mut();
if let Some(tree_update) = ui_state.access.update(
self.rsc.widgets(),
&self.rsc.ui.render_state().get(),
&self.rsc.ui().render_state().get(),
&self.rsc,
) {
let ui_state = self.state.android_state_mut();
@@ -424,7 +449,7 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
}
return handled;
}
if self.rsc.events.controllers.command_target_blocks_input() {
if self.rsc.events().controllers.command_target_blocks_input() {
return true;
}
let handled = super::input::on_key(
@@ -542,7 +567,7 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
let x = event.x(&mut ctx.env);
let y = event.y(&mut ctx.env);
let access_events = {
let render_handle = self.rsc.ui.render_state();
let render_handle = self.rsc.ui().render_state();
let render_state = render_handle.get();
let mut source = AndroidAccessSource {
widgets: self.rsc.widgets(),
@@ -596,15 +621,15 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
) {
self.drain_tasks();
// The layout canvas and wgpu surface are separate and both use physical pixels.
self.rsc.ui.resize((width as f32, height as f32));
self.rsc.ui_mut().resize((width as f32, height as f32));
// Resizing preserves GPU resources; recreating a destroyed surface does not.
let already_live = self.state.android_state().renderer.is_some();
log::info!(
"iris surface: surface_changed {width}x{height} already_live={already_live} \
glyphs_cached={} atlas_pages={}",
self.rsc.ui.text.atlas.glyph_count(),
self.rsc.ui.text.atlas.page_count(),
self.rsc.ui().text.atlas.glyph_count(),
self.rsc.ui().text.atlas.page_count(),
);
if already_live {
let ui_state = self.state.android_state_mut();
@@ -648,11 +673,11 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
"iris surface: new renderer built ({:?}), re-uploading textures: \
glyphs={} pages={}",
renderer.adapter_backend,
self.rsc.ui.text.atlas.glyph_count(),
self.rsc.ui.text.atlas.page_count(),
self.rsc.ui().text.atlas.glyph_count(),
self.rsc.ui().text.atlas.page_count(),
);
self.rsc.ui.textures.reupload();
self.rsc.ui.paints.reupload();
self.rsc.ui_mut().textures.reupload();
self.rsc.ui_mut().paints.reupload();
self.state.android_state_mut().renderer = Some(renderer);
self.render(ctx, Instant::now());
}
@@ -673,8 +698,8 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
log::info!(
"iris surface: surface_destroyed, tearing the renderer down \
(glyphs_cached={} atlas_pages={})",
self.rsc.ui.text.atlas.glyph_count(),
self.rsc.ui.text.atlas.page_count(),
self.rsc.ui().text.atlas.glyph_count(),
self.rsc.ui().text.atlas.page_count(),
);
self.state.android_state_mut().renderer = None;
}
@@ -724,7 +749,7 @@ impl<State: AndroidAppState> AccessibilityNodeProvider for IrisViewPeer<State> {
ctx: &mut CallbackCtx<'local>,
virtual_view_id: jint,
) -> AccessibilityNodeInfo<'local> {
let render_handle = self.rsc.ui.render_state();
let render_handle = self.rsc.ui().render_state();
let render_state = render_handle.get();
let mut source = AndroidAccessSource {
widgets: self.rsc.widgets(),
@@ -745,7 +770,7 @@ impl<State: AndroidAppState> AccessibilityNodeProvider for IrisViewPeer<State> {
ctx: &mut CallbackCtx<'local>,
focus_type: jint,
) -> AccessibilityNodeInfo<'local> {
let render_handle = self.rsc.ui.render_state();
let render_handle = self.rsc.ui().render_state();
let render_state = render_handle.get();
let mut source = AndroidAccessSource {
widgets: self.rsc.widgets(),
@@ -797,6 +822,7 @@ pub fn new_peer<'local, State: AndroidAppState>(
mut env: JNIEnv<'local>,
view: View<'local>,
context: Context<'local>,
init: fn(AndroidUiState, &mut State::Resources) -> State,
) -> android_view::jni::sys::jlong {
// `DisplayMetrics.density` -- physical pixels per dp on this device.
// Read once here, at the one point in this file already handed a
@@ -810,11 +836,11 @@ pub fn new_peer<'local, State: AndroidAppState>(
let vm = env.get_java_vm().unwrap();
let global_view = env.new_global_ref(&view.0).unwrap();
let redraw: Arc<dyn RequestRedraw> = Arc::new(AndroidRedrawHandle::new(vm, global_view));
let (mut rsc, task_recv) = StdRsc::new(redraw);
rsc.ui.set_density(content_scale);
let (mut rsc, task_recv) = State::Resources::new(redraw);
rsc.ui_mut().set_density(content_scale);
let shared = Rc::new(RefCell::new(Shared::default()));
let ui_state = AndroidUiState::new(shared.clone(), content_scale);
let mut state = State::new(ui_state, &mut rsc);
let mut state = init(ui_state, &mut rsc);
let platform_vm = env.get_java_vm().unwrap();
let platform_view = env.new_global_ref(&view.0).unwrap();
state.platform_ready(&mut rsc, platform_vm, platform_view);
+1
View File
@@ -29,6 +29,7 @@ mod layout_tests;
pub use iris_core as core;
pub use iris_macro as macros;
pub use iris_macro::app_init;
pub mod prelude {
use super::*;