//! [`AndroidShell`] — the Android-side equivalent of host-winit's `DesktopShell`.
//!
//! Owns a [`FluorApp`] instance + the rendering pipeline + the Android surface. Photon's `jni_android.rs` constructs one of these in `nativeInit` and ferries the pointer back to Kotlin as a `jlong`. Each subsequent JNI entry point downcasts the pointer back to `&mut AndroidShell<A>` and invokes the matching method.
//!
//! Pipeline mirrors `DesktopShell::render_frame` but stripped to the bare essentials Android needs:
//! - No drop shadow (the surface IS the full screen, no band to cast onto).
//! - No persistent_screen distinction (window_rect = full surface = same buffer).
//! - No clip_mask carving (no rounded window corners on Android).
//! - Choreographer-driven scheduling, not winit's event loop.
//! - Skip render entirely when the AndroidWindow dirty flag is false (saves the ANativeWindow lock/copy cycle on idle frames, tho Choreographer still advances).
use alloc::string::String;
use alloc::vec::Vec;
use core::time::Duration;
use std::time::Instant;
use ndk::native_window::NativeWindow;
use super::events;
use super::surface::Surface;
use super::window::AndroidWindow;
use crate::canvas::{Damage, PixelRect};
use crate::coord::Coord;
use crate::event::{Event as FEvent, ModifiersState as FModifiersState};
use crate::geom::Viewport;
use crate::host::app::{Context, FluorApp};
use crate::host::wake::NoopWakeSender;
use crate::host::EventResponse;
use crate::text::TextRenderer;
use alloc::sync::Arc;
/// The Android equivalent of `DesktopShell`. Wraps a [`FluorApp`] with the surface + pipeline + input translation needed to drive it from Android's Choreographer + JNI.
pub struct AndroidShell<A: FluorApp> {
app: A,
surface: Surface,
text: Option<TextRenderer>,
window: AndroidWindow,
viewport: Viewport,
cursor_x: Coord,
cursor_y: Coord,
modifiers: FModifiersState,
/// Scratch buffer in α + darkness format that the app paints into via `app.render`.
scratch: Vec<u32>,
/// Window-shape clip mask. Android has no rounded corners so this stays all-255 (fully visible). Resized to match scratch.
clip_mask: Vec<u8>,
/// Damage accumulator for the current frame.
pending_damage: Damage,
/// Last-known good last_tick used by `tick`-style apps.
#[allow(dead_code)]
last_tick: Option<Instant>,
}
impl<A: FluorApp> AndroidShell<A> {
/// Construct the shell. Caller provides the surface dimensions Android opened the SurfaceView at (typically full-screen). The app's `set_event_proxy` is invoked with a [`NoopWakeSender`] (Android background tasks talk to the UI thread thru JNI callbacks, not the proxy) and then `init` runs once the shell has its viewport + text renderer ready — same host contract as `DesktopShell`.
pub fn new(mut app: A, width: u32, height: u32) -> Self {
// Android writes finalize output directly into an ANativeWindow_lock'd buffer that the SurfaceFlinger compositor consumes after unlockAndPost. Worker-thread writes to that buffer need to be visible to the compositor by the time unlockAndPost runs; rayon's join is a CPU memory barrier but not a guaranteed device-coherent flush across all worker cores. Forcing par_rows / par_chunks sequential keeps writes on the calling thread so unlockAndPost's cache flush covers everything in one shot — eliminates the "horizontal white band at a random row" tear we hit with parallel finalize.
crate::par::FORCE_SEQUENTIAL.store(true, core::sync::atomic::Ordering::Relaxed);
let viewport = Viewport::new(width, height);
// Host contract: set_event_proxy fires BEFORE init. On desktop run_app wraps winit's EventLoopProxy; here we hand the app a no-op sender. Apps that override `on_user_event` to react to background-task pings won't see any (background tasks should use JNI callbacks to wake the Activity on Android instead).
let wake: Arc<dyn crate::host::wake::WakeSender<A::UserEvent>> = Arc::new(NoopWakeSender);
app.set_event_proxy(wake);
let mut shell = Self {
app,
surface: Surface::new(width, height),
text: Some(TextRenderer::new()),
window: AndroidWindow::new(),
viewport,
cursor_x: 0.0,
cursor_y: 0.0,
modifiers: FModifiersState::empty(),
scratch: alloc::vec![0u32; (width as usize) * (height as usize)],
clip_mask: alloc::vec![255u8; (width as usize) * (height as usize)],
pending_damage: Damage::new(),
last_tick: None,
};
shell.with_context(|app, ctx| app.init(ctx));
shell
}
/// Resize the surface + viewport + scratch + clip_mask. Called from `nativeResize` on surfaceChanged (IME show/hide, screen orientation change). The Activity drives orientation itself via `setRequestedOrientation`, so a tilt arrives here as a regular resize with swapped (w, h) — photon's existing resize codepath reflows the layout automatically.
pub fn resize(&mut self, width: u32, height: u32) {
if width == 0 || height == 0 {
return;
}
self.surface.resize(width, height);
self.viewport = Viewport::new(width, height).with_ru(self.viewport.ru);
let px = (width as usize) * (height as usize);
self.scratch.resize(px, 0);
self.clip_mask.resize(px, 255);
self.window.mark_dirty();
self.with_context(|app, ctx| app.on_resize(width, height, ctx));
}
/// Render one frame. Returns `true` if pixels were actually written to the ANativeWindow surface (the magic-pixel cache can short-circuit on cached buffers).
pub fn draw(&mut self, window: &NativeWindow) -> bool {
let now = Instant::now();
self.last_tick = Some(now);
let tick_dirty = self.with_context(|app, ctx| app.tick(ctx));
if tick_dirty {
self.window.mark_dirty();
}
let win_w = self.viewport.width_px as usize;
let win_h = self.viewport.height_px as usize;
let viewport_rect = PixelRect::new(0, 0, win_w, win_h);
let was_dirty = self.window.take_dirty();
if was_dirty {
let damage_clip = self
.app
.damage_rect(self.viewport)
.unwrap_or(viewport_rect);
if !damage_clip.is_empty() {
clear_scratch_rect(&mut self.scratch, win_w, damage_clip);
self.pending_damage.clear();
self.with_context_render(damage_clip, |app, scratch, ctx| {
app.render(scratch, ctx);
});
}
}
// Single-pass present: finalize scratch (α + darkness) directly into the locked ANativeWindow bits at the buffer's stride, skipping the intermediate Vec<u32>. The full viewport is passed as the finalize clip — on Android we treat every paint as a full repaint, so on cache-miss frames the locked buffer gets refreshed from scratch's current state regardless of which damage rect drove this frame.
self.surface.present(
window,
&self.scratch,
&self.clip_mask,
win_w,
win_h,
viewport_rect,
was_dirty,
)
}
/// Touch dispatch from `nativeOnTouch`. Translates Android action codes into one or two fluor events, dispatches each thru `app.on_event`. Tracks cursor position on CursorMoved so Context.cursor_x/y stays accurate.
///
/// Return value is the Android `nativeOnTouch` ABI: `1` = host should show the soft keyboard (focus moved into a text input), `-1` = hide, `0` = no change. Wraps `FluorApp::wants_keyboard`; the JNI shim passes it straight back to Java.
pub fn on_touch(&mut self, action: i32, x: f32, y: f32) -> i32 {
let (count, evs) = events::translate_touch(action, x as Coord, y as Coord);
for ev in &evs[..count] {
if let FEvent::CursorMoved { x: cx, y: cy } = ev {
self.cursor_x = *cx;
self.cursor_y = *cy;
}
let _ = self.dispatch(ev);
}
self.window.mark_dirty();
self.poll_keyboard()
}
/// Poll `FluorApp::wants_keyboard` and map its one-shot Option to the Android IME-action ABI (`1` = show, `-1` = hide, `0` = no change). Called from both `on_touch` (focus changes driven by user taps) and the JNI shim's per-frame `nativePollKeyboard` hook so app-driven focus changes (e.g. `change_focus(None)` from `submit_handle` while the user is just watching the "Attesting…" spinner) propagate to the Activity without waiting for the next touch.
pub fn poll_keyboard(&mut self) -> i32 {
match self.app.wants_keyboard() {
Some(true) => 1,
Some(false) => -1,
None => 0,
}
}
/// Poll `FluorApp::wants_input_reset` → `1` (the Activity should `InputMethodManager.restartInput` to clear a stale IME composing buffer after a programmatic text clear) or `0`. One-shot, drained here.
pub fn poll_input_reset(&mut self) -> i32 {
self.app.wants_input_reset() as i32
}
/// Key event from `nativeOnKeyEvent`. Returns true if the host handled it (app's response was `Handled`). Untranslated keys (Key::Unidentified) return false so Android's default behavior runs.
pub fn on_key_event(&mut self, key_code: i32) -> bool {
let Some(ev) = events::key_press_from_keycode(key_code) else {
return false;
};
let handled = matches!(self.dispatch(&ev), EventResponse::Handled);
self.window.mark_dirty();
handled
}
/// Text input from `nativeOnTextInput` (Java `String` committed by the soft IME). Builds an `Event::Ime(Commit(text))` and dispatches.
pub fn on_text_input(&mut self, text: String) {
let ev = events::ime_commit(text);
let _ = self.dispatch(&ev);
self.window.mark_dirty();
}
/// Android back button. Today we route it as `Escape` (cancels focus / closes overlays). Apps that want explicit back-handling can intercept `Event::KeyboardInput` with `NamedKey::Escape` and respond accordingly.
pub fn on_back_pressed(&mut self) -> bool {
let Some(ev) = events::key_press_from_keycode(111 /* KEYCODE_ESCAPE */) else {
return false;
};
let handled = matches!(self.dispatch(&ev), EventResponse::Handled);
self.window.mark_dirty();
handled
}
/// Pinch-to-zoom. Multiplies the viewport's `ru` by the scale factor and triggers `on_resize` so layout code re-runs against the new effective span. Matches the desktop Ctrl++/Ctrl-+ semantic.
pub fn on_scale(&mut self, scale_factor: f32) {
if scale_factor <= 0.0 || !scale_factor.is_finite() {
return;
}
let new_ru = self.viewport.ru * scale_factor;
self.viewport = self.viewport.with_ru(new_ru);
self.window.mark_dirty();
let (w, h) = (self.viewport.width_px, self.viewport.height_px);
self.with_context(|app, ctx| app.on_resize(w, h, ctx));
}
/// Borrow the underlying app. Lets photon's JNI shim wire app-specific functionality that doesn't fit fluor's compositor surface (avatar picker, FCM peer updates).
pub fn app(&mut self) -> &mut A {
&mut self.app
}
// ------------------------------------------------------------------------
// Internal Context builders
fn with_context<R>(&mut self, f: impl FnOnce(&mut A, &mut Context) -> R) -> R {
let text = self
.text
.as_mut()
.expect("TextRenderer must be initialized in AndroidShell::new");
let mut ctx = Context {
viewport: self.viewport,
text,
clip_mask: &mut self.clip_mask,
damage: &mut self.pending_damage,
window: &self.window,
modifiers: self.modifiers,
cursor_x: self.cursor_x,
cursor_y: self.cursor_y,
is_maximized: false,
window_origin: (0, 0),
damage_clip: PixelRect::new(
0,
0,
self.viewport.width_px as usize,
self.viewport.height_px as usize,
),
};
f(&mut self.app, &mut ctx)
}
fn with_context_render(
&mut self,
damage_clip: PixelRect,
f: impl FnOnce(&mut A, &mut [u32], &mut Context),
) {
let text = self
.text
.as_mut()
.expect("TextRenderer must be initialized in AndroidShell::new");
let mut ctx = Context {
viewport: self.viewport,
text,
clip_mask: &mut self.clip_mask,
damage: &mut self.pending_damage,
window: &self.window,
modifiers: self.modifiers,
cursor_x: self.cursor_x,
cursor_y: self.cursor_y,
is_maximized: false,
window_origin: (0, 0),
damage_clip,
};
f(&mut self.app, &mut self.scratch, &mut ctx);
}
fn dispatch(&mut self, ev: &FEvent) -> EventResponse {
self.with_context(|app, ctx| app.on_event(ev, ctx))
}
}
/// Clear `scratch[y0..y1][x0..x1]` to α=0 + zero-darkness. Same shape as `DesktopShell::clear_scratch_rect`.
fn clear_scratch_rect(scratch: &mut [u32], width: usize, rect: PixelRect) {
if rect.is_empty() || width == 0 {
return;
}
let x0 = rect.x0;
let x1 = rect.x1.min(width);
if x0 >= x1 {
return;
}
let row_len = x1 - x0;
for y in rect.y0..rect.y1 {
let base = y * width + x0;
if base + row_len > scratch.len() {
break;
}
for cell in &mut scratch[base..base + row_len] {
*cell = 0;
}
}
}