An Android backend, a platform layer that keeps Wayland out of the runtime, the built-in keyboard for an app's own text input, and APKs packaged with Debian's own tools
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s

ltk only ran on a Wayland compositor: the runtime, the input handlers and the draw paths named smithay-client-toolkit's types throughout, so no other windowing system could be added without touching all of them. Prompt, the Eydos terminal, has to run on an Android phone, with its sessions and its sharing, and needs a toolkit that runs there.
The platform layer. Input reaches the runtime as platform-neutral events: `input/event.rs` defines `KeyInput`, `PointerInput` and `TouchInput`, and the pointer, touch and keyboard handlers take them through a few entry points (`pointer_input`, `touch_input`, `key_press`, `key_release`, `key_repeated`, `modifiers_changed`, `keyboard_enter`, `keyboard_leave`, `set_repeat_info`). The sctk handler implementations moved to `platform/wayland/input.rs`, which records the serials later requests need, resolves surfaces and translates. What the runtime asks of the windowing system (moving, resizing, minimising and fullscreening the window, a bare frame request, the cursor's shape, the input method, publishing the clipboard and the primary selection, preparing a canvas, presenting it and its damage) forms the `platform::Platform` trait, with its surface type as `Platform::Surface`. The protocol state moved from `AppData` into `platform::wayland::WaylandPlatform`, held as `AppData::platform`, and each surface's Wayland half (its kind, SHM pool, EGL window surface and what the compositor was last told) from `SurfaceState` into `WaylandSurfaceState`, held as `SurfaceState::platform`. The compositor, shell, seat and output handlers, the data device, the primary selection, the run loop, overlay and subsurface reconciliation, session management, output probing and window fitting moved from `event_loop/` to `platform/wayland/`. Drawing is split from presenting: `draw/paint.rs` paints a surface in full or under its dirty rects, the same for the software and GPU canvases, in place of the four near-identical paths of `draw/software.rs` and `draw/gles.rs`, and the backend presents it (`platform/wayland/present.rs`, which also took the opaque and input regions and `pick_shm_format`). The runtime is generic over the platform, `AppData<A, P: Platform = NativePlatform<A>>` and `SurfaceState<Msg, S = NativeSurface>`, resolved at compile time. The backend-neutral half of a loop iteration is `event_loop/step.rs` (`process_messages`, `draw_due`, `next_wakeup`, `before_draw`, `after_draw`), and `event_loop/setup.rs` builds the state and the event sources every backend has; the accessibility tree and its actions moved to `event_loop/a11y.rs`. `platform/mock.rs` is a platform that logs what is asked of it, and `event_loop/tests.rs` drives the runtime on it through input, update, painting and presentation. The behaviour on Wayland is unchanged, except that a frame whose presentation fails leaves the next one to repaint in full. `ltk::Keysym` now comes from `xkeysym`, the crate sctk re-exported it from, so it is the same type.
Features. `wayland`, on by default, brings the Wayland backend and its dependencies, now optional, AT-SPI2 accessibility among them. `android` brings android-activity 0.6, ndk 0.9 and jni 0.21. A new `build.rs` refuses both at once and sets `cfg( backend )` when one is on, which builds the runtime behind `run` and `try_run`. Without a backend only the runtime-free core builds: `UiSurface`, widgets, layout, theming and rendering.
The Android backend. `ltk::run` runs inside a NativeActivity once `android_main` has handed its `AndroidApp`, re-exported as `ltk::AndroidApp`, to the new `ltk::set_android_app`; without it `run` fails with `RunError::NoAndroidApp`. The first call in the process sends standard output and error, panics included, to logcat, sets `HOME` to the app's internal storage and, unless set, `SSL_CERT_DIR` to Android's CA certificates and `LANG` to the device's language, and unpacks the bundled resources; every call keeps the latest activity, since Android starts the next one in the same process. The backend draws through EGL on the activity's window, or in software into a locked window buffer, and sizes the main surface at the display's density in whole multiples of 160 dpi. Fingers and styluses become touches, a mouse the pointer, and key events keysyms and text through the device's character map. calloop's descriptor rides on the activity's `ALooper`, so the app's channel and timers keep working. Text is typed on ltk's own keyboard, since a NativeActivity gets only key events from the system's. Through JNI on the activity (`platform/android/java.rs`): copies go to `ClipboardManager` and its text is read back whenever the app gains the focus; back with nothing left to go back to minimises the app with `moveTaskToBack` or closes it, as `App::back_at_root` says, which the new `Platform::back_leaves` decides per backend; and the window's insets come from `getRootWindowInsets()` on Android 11 and later, the status bar and any cutout on top and only bars that take taps on the other edges, so there is no gap above the view nor below it in gesture navigation, and a bar of buttons still keeps its room; earlier releases fall back to the content rect. When the app's last surface closes, ltk calls `finishAndRemoveTask` and waits for the activity to be destroyed, since a second activity started in a process whose `android_main` has returned never came up; an app should end the process after `run` returns. `ltk::keep_running( Some( text ) )` starts a foreground service with `text` on an ongoing notification, so Android does not freeze the app once it leaves the screen, and `None` stops it.
Surface insets. `SurfaceState::insets` holds the logical pixels the system covers along each edge; the view is laid out inside them while the background fills the whole surface, and positions stay absolute, so hit testing is unchanged. The built-in keyboard is placed inside them too, above a bar of navigation buttons. On Wayland they stay zero.
Resources in a package. `ltk::set_resource_dir( dir )` names an app's own copy of ltk's resources and `ltk::resource_dir()` returns it, so an app also finds its own bundled files there. Its `themes/` is searched after the user's themes and before `/usr/share/ltk/themes`, its `keyboards/` before `/usr/share`, and a font a theme names by an absolute path that does not exist is read from its `fonts/` by file name, so a theme written against Debian's paths works unchanged. On Android `set_android_app` unpacks `assets/ltk/` into the internal storage and uses it as the resource directory; `assets/ltk/MANIFEST` lists the tree, since the asset manager cannot list subdirectories, and its first line, a hash of the contents, decides whether to unpack again. On Android the per-script fallback fonts come from `/system/fonts` and the primary face is the embedded Sora.
APKs. `scripts/android-assets.sh` builds that tree from installed themes, without their cursors, with the fonts each `theme.json` names and ltk's keyboard layouts. `scripts/android-apk.sh OUT.apk LIB.so PACKAGE LABEL [ASSETS_DIR]` packages an app built as a `cdylib` with Debian's aapt, zipalign and apksigner, with no Gradle and no SDK download: it writes the manifest, with the library loaded as `libmain.so`, optional permissions and a theme without an action bar, which the content rect would otherwise leave room for; links it against `android-framework-res`; adds the assets and the library; aligns and signs, creating a debug keystore when there is none. `ICON` takes an SVG, rasterised with `rsvg-convert` for every density, or a PNG. `KEEP_ALIVE=1` compiles `java/net/liberux/ltk/KeepAlive.java` with javac and `dx` from art-standalone against the platform 34 `android.jar`, against which aapt then links too, since Debian's framework-res predates the `specialUse` service type, and declares the service with `KEEP_ALIVE_USE` as its reason and the permissions it needs. `examples/android_hello.rs` is the smoke test: `make android-check` typechecks the backend with Debian's rustc, `rust-src` and NDK through `-Zbuild-std`, `make android-apk` and `make android-install` package and install the example, `make deps` and `make android-deps` install what the targets need, and `make install` now copies `examples`, `scripts`, `java` and `build.rs` into the registry.
The built-in keyboard for an app's own text input. While `App::external_text_input` asks for text and no ltk text field has the focus, the built-in keyboard shows with `App::external_text_purpose`, so a terminal gets its row of Escape, Tab, Ctrl, Alt and arrows, comes back when the serial changes and hides when the request ends. Its keys reach the app as physical ones, pressed and released through `App::on_raw_key_event` with the panel's Ctrl and Alt held for them. Before, it only served ltk's text fields, so such an app had no keyboard where the compositor offers no input method.
Clippy. `make clippy` is clean again: derived `Default`s replace the hand-written ones, `div_ceil`, `then_some`, struct update syntax for strokes, the map entry API in the text renderer, function paths in place of closures in the examples and tests, and fewer casts and explicit lifetimes.
`README.md`, `docs/architecture.md` and `CHANGELOG.md` describe the features, the backend, the events and their units, and what stays with a backend.
Not done. On Android there are no overlays, secondary surfaces or subsurfaces, no dead keys from a physical keyboard and no saved state for the activity. Why a second activity in the same process never comes up was not found. The keep-alive service does not ask for the notification permission, so on Android 13 and later its notification may not show until it is granted in the app's settings.
This commit is contained in:
2026-10-16 23:46:50 +02:00
parent 85aa85a357
commit cacf871ddd
131 changed files with 6155 additions and 3492 deletions
+68
View File
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! The APK's copy of ltk's resources: `assets/ltk/`, unpacked into the app's internal storage and used through [`crate::set_resource_dir`], so themes and fonts are read from files as on any other system.
//!
//! The asset manager lists the files of a directory but not its subdirectories, so `assets/ltk/MANIFEST` lists the tree: one path per line, relative to `assets/ltk/`; empty lines and lines starting with `#` are skipped. The tree is unpacked again only when the manifest differs from the one unpacked last, so a packager puts a hash of the contents on a `#` line.
use std::ffi::CString;
use std::io::{ self, Read };
use std::path::{ Component, Path };
use android_activity::AndroidApp;
use ndk::asset::AssetManager;
const ROOT: &str = "ltk";
const MANIFEST: &str = "MANIFEST";
/// Unpack the APK's resources when it carries them and they changed since the last start, and use them.
pub( super ) fn install( activity: &AndroidApp )
{
let Some( data ) = activity.internal_data_path() else { return };
let assets = activity.asset_manager();
let Some( manifest ) = read_asset( &assets, MANIFEST ) else { return };
let dir = data.join( ROOT );
let stamp = dir.join( MANIFEST );
if std::fs::read( &stamp ).ok().as_deref() != Some( manifest.as_slice() )
{
// The manifest goes last, so a start that dies halfway unpacks again.
if let Err( e ) = unpack( &assets, &manifest, &dir ).and_then( |()| std::fs::write( &stamp, &manifest ) )
{
eprintln!( "[ltk] unpacking the APK's resources: {e}" );
return;
}
}
crate::resources::set_resource_dir( dir );
}
fn read_asset( assets: &AssetManager, path: &str ) -> Option<Vec<u8>>
{
let name = CString::new( format!( "{ROOT}/{path}" ) ).ok()?;
let mut asset = assets.open( &name )?;
let mut bytes = Vec::with_capacity( asset.length() );
asset.read_to_end( &mut bytes ).ok()?;
Some( bytes )
}
/// Replace `dir` with the files `manifest` lists, which must stay inside it.
fn unpack( assets: &AssetManager, manifest: &[u8], dir: &Path ) -> io::Result<()>
{
let text = std::str::from_utf8( manifest ).map_err( |e| io::Error::new( io::ErrorKind::InvalidData, e ) )?;
// A file the previous version shipped may be gone from this one.
let _ = std::fs::remove_dir_all( dir );
std::fs::create_dir_all( dir )?;
for line in text.lines().map( str::trim ).filter( |l| !l.is_empty() && !l.starts_with( '#' ) )
{
let rel = Path::new( line );
if !rel.components().all( |c| matches!( c, Component::Normal( _ ) ) )
{
return Err( io::Error::new( io::ErrorKind::InvalidData, format!( "{line}: not a path inside {ROOT}/" ) ) );
}
let bytes = read_asset( assets, line )
.ok_or_else( || io::Error::new( io::ErrorKind::NotFound, format!( "asset {ROOT}/{line}" ) ) )?;
let target = dir.join( rel );
if let Some( parent ) = target.parent() { std::fs::create_dir_all( parent )?; }
std::fs::write( target, bytes )?;
}
Ok( () )
}
+169
View File
@@ -0,0 +1,169 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Android's motion and key events turned into [`crate::input::event`].
//!
//! Fingers and styluses are touches; a mouse is a pointer. Positions come in window pixels and are divided by `AndroidPlatform::px_per_logical`. Keys repeat on Android's own clock, so the runtime's repeat stays off. Dead keys are not composed yet.
use std::collections::hash_map::Entry;
use android_activity::InputStatus;
use android_activity::input::
{
Axis as MotionAxis, Button, InputEvent, KeyAction, KeyEvent, KeyMapChar, Keycode,
MetaState, MotionAction, MotionEvent, Pointer, ToolType,
};
use crate::app::App;
use crate::event_loop::{ AppData, SurfaceFocus };
use crate::input::event::{ Axis, AxisSource, KeyInput, PointerButton, PointerInput, PointerKind, TouchInput, TouchPhase };
use crate::Keysym;
use super::keys;
/// `AMETA_ALT_*`, `AMETA_CTRL_*` and `AMETA_META_*`: the modifiers that change what a key means, not the character it types.
const META_ALT: u32 = 0x0000_0032;
const META_CTRL: u32 = 0x0000_7000;
const META_META: u32 = 0x0007_0000;
/// Logical pixels per wheel detent, as libinput reports one.
const WHEEL_STEP: f64 = 15.0;
impl<A: App> AppData<A>
{
/// Hand one input event to the runtime. Keys it has no use for go back to the system, which acts on the volume keys and the like.
pub( super ) fn android_input( &mut self, event: &InputEvent<'_> ) -> InputStatus
{
match event
{
InputEvent::MotionEvent( motion ) =>
{
self.android_motion( motion );
InputStatus::Handled
}
InputEvent::KeyEvent( key ) => self.android_key( key ),
_ => InputStatus::Unhandled,
}
}
fn window_to_logical( &self, pointer: &Pointer<'_> ) -> ( f64, f64 )
{
let ( sx, sy ) = self.platform.px_per_logical;
( pointer.x() as f64 / sx, pointer.y() as f64 / sy )
}
fn android_motion( &mut self, motion: &MotionEvent<'_> )
{
let pointer = motion.pointer_at_index( motion.pointer_index() );
if pointer.tool_type() == ToolType::Mouse
{
self.android_mouse( motion, &pointer );
return;
}
let time = ( motion.event_time() / 1_000_000 ) as u32;
match motion.action()
{
MotionAction::Down | MotionAction::PointerDown =>
{
let position = self.window_to_logical( &pointer );
self.touch_input( &TouchInput { id: pointer.pointer_id(), time, phase: TouchPhase::Down { focus: SurfaceFocus::Main, position } } );
}
MotionAction::Move =>
{
for pointer in motion.pointers()
{
let position = self.window_to_logical( &pointer );
self.touch_input( &TouchInput { id: pointer.pointer_id(), time, phase: TouchPhase::Motion { position } } );
}
}
MotionAction::Up | MotionAction::PointerUp =>
{
self.touch_input( &TouchInput { id: pointer.pointer_id(), time, phase: TouchPhase::Up } );
}
MotionAction::Cancel =>
{
self.reset_touch_state();
}
_ => {}
}
}
/// A press reaches the runtime as `ButtonPress`, which names the button; the `Down` that comes with it does not.
fn android_mouse( &mut self, motion: &MotionEvent<'_>, pointer: &Pointer<'_> )
{
let kind = match motion.action()
{
MotionAction::HoverEnter => PointerKind::Enter,
MotionAction::HoverExit => PointerKind::Leave,
MotionAction::HoverMove | MotionAction::Move => PointerKind::Motion,
MotionAction::ButtonPress => PointerKind::Press( button( motion.action_button() ) ),
MotionAction::ButtonRelease => PointerKind::Release( button( motion.action_button() ) ),
// Android counts the vertical wheel upwards.
MotionAction::Scroll => PointerKind::Axis
{
horizontal: wheel( pointer.axis_value( MotionAxis::Hscroll ) ),
vertical: wheel( -pointer.axis_value( MotionAxis::Vscroll ) ),
source: AxisSource::Wheel,
},
_ => return,
};
let position = self.window_to_logical( pointer );
self.pointer_input( &PointerInput { focus: SurfaceFocus::Main, position, kind } );
}
fn android_key( &mut self, event: &KeyEvent<'_> ) -> InputStatus
{
let meta = event.meta_state();
let ( ctrl, shift, alt ) = ( meta.ctrl_on(), meta.shift_on(), meta.alt_on() );
if ( ctrl, shift, alt ) != ( self.ctrl_pressed, self.shift_pressed, self.alt_pressed )
{
self.modifiers_changed( ctrl, shift, alt );
}
let code = event.key_code();
let typed = self.typed_char( event.device_id(), code, meta );
let Some( keysym ) = keys::keysym( code ).or( typed.map( Keysym::from_char ) ) else { return InputStatus::Unhandled };
let text = typed.filter( |c| !ctrl && !c.is_control() ).map( String::from );
let key = KeyInput { keysym, keycode: u32::from( code ), text };
match event.action()
{
KeyAction::Down if event.repeat_count() > 0 => self.key_repeated( key ),
KeyAction::Down => self.key_press( key ),
KeyAction::Up => self.key_release( &key ),
_ => return InputStatus::Unhandled,
}
InputStatus::Handled
}
/// The character `code` types on `device` with only the modifiers that pick a character, Shift and Caps Lock.
fn typed_char( &mut self, device: i32, code: Keycode, meta: MetaState ) -> Option<char>
{
let map = match self.platform.key_maps.entry( device )
{
Entry::Occupied( e ) => e.into_mut(),
Entry::Vacant( e ) => e.insert( self.platform.activity.device_key_character_map( device ).ok()? ),
};
match map.get( code, MetaState( meta.0 & !( META_ALT | META_CTRL | META_META ) ) )
{
Ok( KeyMapChar::Unicode( c ) ) => Some( c ),
_ => None,
}
}
}
fn button( button: Button ) -> PointerButton
{
match button
{
Button::Primary => PointerButton::Left,
Button::Secondary => PointerButton::Right,
Button::Tertiary => PointerButton::Middle,
Button::Back => PointerButton::Back,
_ => PointerButton::Other,
}
}
fn wheel( notches: f32 ) -> Axis
{
Axis { distance: notches as f64 * WHEEL_STEP, notches }
}
+156
View File
@@ -0,0 +1,156 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! What only the Java side of Android offers, through JNI on the activity: the system clipboard, the window's insets, a service that keeps the app running, sending the app to the background and closing it.
use android_activity::AndroidApp;
use jni::objects::{ JObject, JString, JValue };
use jni::{ JNIEnv, JavaVM };
/// Room for the local references one call makes.
const LOCAL_FRAME: i32 = 16;
/// Run `f` with the activity, its local references freed afterwards and a Java exception cleared. `None` when JNI or `f` failed.
fn with_activity<R>( app: &AndroidApp, f: impl FnOnce( &mut JNIEnv, &JObject ) -> jni::errors::Result<R> ) -> Option<R>
{
// SAFETY: android-activity hands out the process's JavaVM and a global reference to the activity, both alive while the app runs.
let vm = unsafe { JavaVM::from_raw( app.vm_as_ptr().cast() ) }.ok()?;
let activity = unsafe { JObject::from_raw( app.activity_as_ptr().cast() ) };
let mut env = vm.attach_current_thread().ok()?;
env.with_local_frame( LOCAL_FRAME, |env|
{
let result = f( env, &activity );
if result.is_err() { let _ = env.exception_clear(); }
result
} ).ok()
}
fn clipboard_manager<'local>( env: &mut JNIEnv<'local>, activity: &JObject ) -> jni::errors::Result<JObject<'local>>
{
let name = env.new_string( "clipboard" )?;
env.call_method( activity, "getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;", &[ JValue::Object( &name ) ] )?.l()
}
/// Offer `text` to other apps as the clipboard.
pub( super ) fn set_clipboard( app: &AndroidApp, text: &str )
{
with_activity( app, |env, activity|
{
let manager = clipboard_manager( env, activity )?;
let label = env.new_string( "ltk" )?;
let text = env.new_string( text )?;
let clip = env.call_static_method
(
"android/content/ClipData", "newPlainText",
"(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Landroid/content/ClipData;",
&[ JValue::Object( &label ), JValue::Object( &text ) ],
)?.l()?;
env.call_method( &manager, "setPrimaryClip", "(Landroid/content/ClipData;)V", &[ JValue::Object( &clip ) ] )?;
Ok( () )
} );
}
/// The clipboard's text. Android lets only the app with the focus read it.
pub( super ) fn clipboard_text( app: &AndroidApp ) -> Option<String>
{
with_activity( app, |env, activity|
{
let manager = clipboard_manager( env, activity )?;
let clip = env.call_method( &manager, "getPrimaryClip", "()Landroid/content/ClipData;", &[] )?.l()?;
if clip.is_null() { return Ok( None ); }
if env.call_method( &clip, "getItemCount", "()I", &[] )?.i()? == 0 { return Ok( None ); }
let item = env.call_method( &clip, "getItemAt", "(I)Landroid/content/ClipData$Item;", &[ JValue::Int( 0 ) ] )?.l()?;
let text = env.call_method( &item, "coerceToText", "(Landroid/content/Context;)Ljava/lang/CharSequence;", &[ JValue::Object( activity ) ] )?.l()?;
if text.is_null() { return Ok( None ); }
let text = JString::from( env.call_method( &text, "toString", "()Ljava/lang/String;", &[] )?.l()? );
let string = String::from( env.get_string( &text )? );
Ok( Some( string ) )
} ).flatten()
}
/// The window's edges the view keeps clear of, in window pixels, as `[ left, top, right, bottom ]`: on top the status bar and any cutout, elsewhere only bars that take taps, so the view runs under a gesture bar. `None` before Android 11, which cannot tell them apart, or while the view has no insets.
pub( super ) fn window_insets( app: &AndroidApp ) -> Option<[ i32; 4 ]>
{
if app.config().sdk_version() < 30 { return None; }
with_activity( app, |env, activity|
{
let window = env.call_method( activity, "getWindow", "()Landroid/view/Window;", &[] )?.l()?;
let decor = env.call_method( &window, "getDecorView", "()Landroid/view/View;", &[] )?.l()?;
let insets = env.call_method( &decor, "getRootWindowInsets", "()Landroid/view/WindowInsets;", &[] )?.l()?;
if insets.is_null() { return Ok( None ); }
let cutout = inset_type( env, "displayCutout" )?;
let bars = inset_type( env, "statusBars" )?;
let taps = inset_type( env, "tappableElement" )?;
let top = edges( env, &insets, bars | cutout )?;
let rest = edges( env, &insets, taps | cutout )?;
Ok( Some( [ rest[ 0 ], top[ 1 ], rest[ 2 ], rest[ 3 ] ] ) )
} ).flatten()
}
fn inset_type( env: &mut JNIEnv, name: &str ) -> jni::errors::Result<i32>
{
env.call_static_method( "android/view/WindowInsets$Type", name, "()I", &[] )?.i()
}
/// `insets.getInsets( types )` as `[ left, top, right, bottom ]`.
fn edges( env: &mut JNIEnv, insets: &JObject, types: i32 ) -> jni::errors::Result<[ i32; 4 ]>
{
let edges = env.call_method( insets, "getInsets", "(I)Landroid/graphics/Insets;", &[ JValue::Int( types ) ] )?.l()?;
Ok(
[
env.get_field( &edges, "left", "I" )?.i()?,
env.get_field( &edges, "top", "I" )?.i()?,
env.get_field( &edges, "right", "I" )?.i()?,
env.get_field( &edges, "bottom", "I" )?.i()?,
] )
}
/// The service `scripts/android-apk.sh` adds with `KEEP_ALIVE=1`.
const KEEP_ALIVE: &str = "net.liberux.ltk.KeepAlive";
/// Start the keep-alive service with `text` on its notification, or stop it with `None`. Without the service in the APK, nothing happens.
pub( super ) fn keep_alive( app: &AndroidApp, text: Option<&str> )
{
with_activity( app, |env, activity|
{
let package = env.call_method( activity, "getPackageName", "()Ljava/lang/String;", &[] )?.l()?;
let class = env.new_string( KEEP_ALIVE )?;
let intent = env.new_object( "android/content/Intent", "()V", &[] )?;
env.call_method( &intent, "setClassName", "(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;", &[ JValue::Object( &package ), JValue::Object( &class ) ] )?;
match text
{
Some( text ) =>
{
let key = env.new_string( "text" )?;
let value = env.new_string( text )?;
env.call_method( &intent, "putExtra", "(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;", &[ JValue::Object( &key ), JValue::Object( &value ) ] )?;
env.call_method( activity, "startForegroundService", "(Landroid/content/Intent;)Landroid/content/ComponentName;", &[ JValue::Object( &intent ) ] )?;
}
None =>
{
env.call_method( activity, "stopService", "(Landroid/content/Intent;)Z", &[ JValue::Object( &intent ) ] )?;
}
}
Ok( () )
} );
}
/// Close the activity and take its task out of the recent apps, as closing it there does.
pub( super ) fn finish_and_remove_task( app: &AndroidApp )
{
with_activity( app, |env, activity|
{
env.call_method( activity, "finishAndRemoveTask", "()V", &[] )?;
Ok( () )
} );
}
/// Send the app's task to the background, as the home button does; the app keeps running.
pub( super ) fn move_task_to_back( app: &AndroidApp )
{
with_activity( app, |env, activity|
{
env.call_method( activity, "moveTaskToBack", "(Z)Z", &[ JValue::Bool( 1 ) ] )?;
Ok( () )
} );
}
+60
View File
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use android_activity::input::Keycode;
use crate::Keysym;
/// The keysym of a key that types no character: navigation, editing, function and modifier keys, and back. `None` for the others, whose keysym is the character they type.
pub( super ) fn keysym( code: Keycode ) -> Option<Keysym>
{
Some( match code
{
Keycode::DpadLeft => Keysym::Left,
Keycode::DpadRight => Keysym::Right,
Keycode::DpadUp => Keysym::Up,
Keycode::DpadDown => Keysym::Down,
Keycode::Enter | Keycode::DpadCenter => Keysym::Return,
Keycode::NumpadEnter => Keysym::KP_Enter,
Keycode::Del => Keysym::BackSpace,
Keycode::ForwardDel => Keysym::Delete,
Keycode::Tab => Keysym::Tab,
Keycode::Escape => Keysym::Escape,
Keycode::MoveHome => Keysym::Home,
Keycode::MoveEnd => Keysym::End,
Keycode::PageUp => Keysym::Page_Up,
Keycode::PageDown => Keysym::Page_Down,
Keycode::Insert => Keysym::Insert,
Keycode::F1 => Keysym::F1,
Keycode::F2 => Keysym::F2,
Keycode::F3 => Keysym::F3,
Keycode::F4 => Keysym::F4,
Keycode::F5 => Keysym::F5,
Keycode::F6 => Keysym::F6,
Keycode::F7 => Keysym::F7,
Keycode::F8 => Keysym::F8,
Keycode::F9 => Keysym::F9,
Keycode::F10 => Keysym::F10,
Keycode::F11 => Keysym::F11,
Keycode::F12 => Keysym::F12,
Keycode::Back => Keysym::XF86_Back,
Keycode::Forward => Keysym::XF86_Forward,
Keycode::Menu => Keysym::Menu,
Keycode::Cut => Keysym::XF86_Cut,
Keycode::Copy => Keysym::XF86_Copy,
Keycode::Paste => Keysym::XF86_Paste,
Keycode::ShiftLeft => Keysym::Shift_L,
Keycode::ShiftRight => Keysym::Shift_R,
Keycode::CtrlLeft => Keysym::Control_L,
Keycode::CtrlRight => Keysym::Control_R,
Keycode::AltLeft => Keysym::Alt_L,
Keycode::AltRight => Keysym::Alt_R,
Keycode::MetaLeft => Keysym::Super_L,
Keycode::MetaRight => Keysym::Super_R,
Keycode::CapsLock => Keysym::Caps_Lock,
Keycode::NumLock => Keysym::Num_Lock,
Keycode::ScrollLock => Keysym::Scroll_Lock,
Keycode::Sysrq => Keysym::Print,
_ => return None,
} )
}
+51
View File
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Standard output and error into logcat. An Android process's go nowhere, and ltk reports through `eprintln!`, as the default panic hook does.
use std::ffi::{ c_char, c_int, CString };
use std::io::{ BufRead, BufReader };
use std::os::fd::FromRawFd;
#[ link( name = "log" ) ]
extern "C"
{
fn __android_log_write( priority: c_int, tag: *const c_char, text: *const c_char ) -> c_int;
}
/// `ANDROID_LOG_INFO`.
const INFO: c_int = 4;
/// Send each line the process writes to its standard output or error to logcat, tagged with the process's name, which on Android is its package's.
pub( super ) fn redirect()
{
let name = std::fs::read( "/proc/self/cmdline" ).ok()
.and_then( |cmdline| cmdline.split( |&b| b == 0 ).next().map( <[u8]>::to_vec ) )
.filter( |name| !name.is_empty() )
.unwrap_or_else( || b"ltk".to_vec() );
let Ok( tag ) = CString::new( name ) else { return };
let mut fds = [ 0 as c_int; 2 ];
// SAFETY: `fds` has room for the two descriptors `pipe` writes.
if unsafe { libc::pipe( fds.as_mut_ptr() ) } != 0 { return; }
// SAFETY: duplicates a descriptor this function just opened onto the standard ones, then drops its own copy.
unsafe
{
libc::dup2( fds[ 1 ], libc::STDOUT_FILENO );
libc::dup2( fds[ 1 ], libc::STDERR_FILENO );
libc::close( fds[ 1 ] );
}
// SAFETY: the read end was just opened and nothing else owns it.
let reader = unsafe { std::fs::File::from_raw_fd( fds[ 0 ] ) };
let _ = std::thread::Builder::new()
.name( "ltk-logcat".into() )
.spawn( move ||
{
for line in BufReader::new( reader ).lines().map_while( Result::ok )
{
let Ok( text ) = CString::new( line ) else { continue };
// SAFETY: both strings are NUL-terminated and outlive the call.
unsafe { __android_log_write( INFO, tag.as_ptr(), text.as_ptr() ); }
}
} );
}
+133
View File
@@ -0,0 +1,133 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! The Android backend: `ltk::run` inside a NativeActivity, through `android-activity`.
//!
//! An app's `android_main` hands its `AndroidApp` to [`set_android_app`], which also unpacks the resources the APK carries, and then calls `ltk::run` as on any other platform.
//!
//! | File | What it does for the runtime |
//! | --- | --- |
//! | `assets.rs` | The themes and fonts under the APK's `assets/ltk/`, unpacked into internal storage as the app's resource directory |
//! | `log.rs` | Standard output and error, panics included, into logcat |
//! | `java.rs` | The system clipboard, the window's insets, the keep-alive service, sending the app to the background and closing it, through JNI |
//! | `state.rs` | [`AndroidPlatform`]: the activity, the EGL context, the window's scale and what the runtime asked of the system |
//! | `surface.rs` | [`AndroidSurfaceState`]: the `ANativeWindow` and its EGL surface; sizing the main surface to the window as it comes, changes and goes |
//! | `present.rs` | Putting a painted canvas on screen through `eglSwapBuffers`, or a locked window buffer in software |
//! | `input.rs`, `keys.rs` | Motion and key events turned into [`crate::input::event`], Android's keycodes into keysyms |
//! | `run.rs` | `ltk::run`: the looper, the activity's lifecycle and the loop around the runtime's steps |
//!
//! The activity has one surface, the main one, filling its window. Overlays, secondary surfaces and subsurfaces are not created. The clipboard is the system's, read again whenever the app gains the focus; the primary selection stays within the process, and the activity's saved state is not used yet.
mod assets;
mod input;
mod java;
mod keys;
mod log;
mod present;
pub( crate ) mod run;
mod state;
mod surface;
pub use android_activity::AndroidApp;
pub use run::{ keep_running, set_android_app };
pub( crate ) use state::AndroidPlatform;
pub( crate ) use surface::AndroidSurfaceState;
use android_activity::WindowManagerFlags;
use crate::app::App;
use crate::event_loop::SurfaceFocus;
use crate::event_loop::cursor_shape::ResizeEdge;
use crate::osk::Purpose;
use crate::render::Canvas;
use crate::types::CursorShape;
use super::{ Platform, Presentation };
impl<A: App> Platform for AndroidPlatform<A>
{
type Surface = AndroidSurfaceState;
type Output = ();
fn is_toplevel( &self, _main: &AndroidSurfaceState ) -> bool
{
false
}
fn back_leaves( &self, _main: &AndroidSurfaceState ) -> bool
{
true
}
fn begin_window_move( &self, _main: &AndroidSurfaceState ) {}
fn begin_window_resize( &self, _main: &AndroidSurfaceState, _edge: ResizeEdge ) {}
fn minimize_window( &self, _main: &AndroidSurfaceState )
{
java::move_task_to_back( &self.activity );
}
fn set_fullscreen( &self, _main: &AndroidSurfaceState, fullscreen: bool ) -> bool
{
let ( add, remove ) = if fullscreen
{
( WindowManagerFlags::FULLSCREEN, WindowManagerFlags::empty() )
} else {
( WindowManagerFlags::empty(), WindowManagerFlags::FULLSCREEN )
};
self.activity.set_window_flags( add, remove );
true
}
fn request_frame( &self, surface: &AndroidSurfaceState, _focus: SurfaceFocus ) -> bool
{
if surface.window.is_none() { return false; }
self.frame_requested.set( true );
true
}
fn precise_damage( &self, surface: &AndroidSurfaceState ) -> bool
{
self.egl_context.is_none() || surface.egl_surface.is_none()
}
fn prepare_canvas( &self, surface: &mut AndroidSurfaceState, canvas: &mut Option<Canvas>, pw: u32, ph: u32, scale: u32 ) -> bool
{
present::prepare_canvas( self, surface, canvas, pw, ph, scale )
}
fn present( &self, surface: &mut AndroidSurfaceState, canvas: &mut Canvas, frame: &Presentation<'_> ) -> bool
{
present::present( self, surface, canvas, frame )
}
fn publish_clipboard( &mut self, text: &str )
{
java::set_clipboard( &self.activity, text );
}
fn publish_primary_selection( &mut self, _text: &str ) {}
fn set_cursor_shape( &mut self, _shape: CursorShape ) -> bool
{
false
}
fn text_input_active( &self ) -> bool
{
self.text_input
}
fn activate_text_input( &mut self, _secure: bool, _purpose: Purpose )
{
self.text_input = true;
}
fn deactivate_text_input( &mut self )
{
self.text_input = false;
}
fn reenable_text_input( &self, _secure: bool, _purpose: Purpose ) {}
}
+85
View File
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Putting a painted canvas on screen: through `eglSwapBuffers` for the GPU renderer, through a locked `ANativeWindow` buffer for the software one.
use std::sync::Arc;
use crate::app::App;
use crate::draw::fit_canvas;
use crate::egl_context::egl_damage;
use crate::event_loop::perf::RasterTrace;
use crate::platform::Presentation;
use crate::render::Canvas;
use super::{ AndroidPlatform, AndroidSurfaceState };
/// The GPU path when both the process and the window have EGL, software otherwise.
pub( super ) fn prepare_canvas<A: App>
(
platform: &AndroidPlatform<A>,
surface: &mut AndroidSurfaceState,
canvas: &mut Option<Canvas>,
pw: u32,
ph: u32,
scale: u32,
) -> bool
{
if let ( Some( ctx ), Some( es ) ) = ( platform.egl_context.as_ref(), surface.egl_surface.as_ref() )
{
if ctx.make_current( es ).is_err() { return false; }
surface.raster_trace = RasterTrace::begin();
fit_canvas( canvas, pw, ph, scale, || Canvas::new_gles( Arc::clone( ctx.gl() ), ctx.version, pw, ph ) );
return true;
}
if surface.window.is_none() { return false; }
fit_canvas( canvas, pw, ph, scale, || Canvas::new( pw, ph ) );
true
}
pub( super ) fn present<A: App>
(
platform: &AndroidPlatform<A>,
surface: &mut AndroidSurfaceState,
canvas: &mut Canvas,
frame: &Presentation<'_>,
) -> bool
{
let ( pw, ph ) = canvas.size();
let shown = match platform.egl_context.as_ref().zip( surface.egl_surface.as_ref() )
{
Some( ( ctx, es ) ) =>
{
canvas.present();
if let Some( t ) = surface.raster_trace.take()
{
t.end( ctx.gl(), if frame.damage.is_empty() { "full" } else { "partial" }, pw, ph );
}
ctx.swap_buffers_with_damage( es, &egl_damage( frame.damage, pw, ph ) ).is_ok()
}
None => present_software( surface, canvas ),
};
if shown && frame.frame.is_some() { platform.presented.set( true ); }
shown
}
/// Copy the canvas into the window's next buffer, whose lines may be longer than the canvas is wide, and post it.
fn present_software( surface: &mut AndroidSurfaceState, canvas: &Canvas ) -> bool
{
let ( pw, ph ) = canvas.size();
let line = pw as usize * 4;
surface.pixels.resize( line * ph as usize, 0 );
// The window's buffers are R8G8B8A8, the canvas's own byte order.
canvas.write_to_wayland_buf( &mut surface.pixels, false );
let Some( ref window ) = surface.window else { return false };
let Ok( mut buffer ) = window.lock( None ) else { return false };
let Some( lines ) = buffer.lines() else { return false };
for ( dst, src ) in lines.zip( surface.pixels.chunks_exact( line ) )
{
let n = dst.len().min( src.len() );
// SAFETY: `dst` is a line of the locked buffer with at least `n` writable bytes, `src` has at least `n`.
unsafe { std::ptr::copy_nonoverlapping( src.as_ptr(), dst.as_mut_ptr().cast::<u8>(), n ); }
}
true
}
+203
View File
@@ -0,0 +1,203 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! `ltk::run` on Android.
//!
//! The activity's thread sleeps in `AndroidApp::poll_events`, on its `ALooper`. calloop's sources (the app's message channel, the text-scale watcher, the timers) still live on a calloop loop, whose epoll descriptor is added to the looper with a callback that wakes it, after which the loop dispatches calloop without waiting. calloop's timers raise no descriptor, so the looper's timeout is bounded by every timer the runtime may have armed.
use std::os::fd::AsFd;
use std::sync::{ Arc, Mutex, Once };
use std::time::Duration;
use android_activity::{ AndroidApp, MainEvent, PollEvent };
use calloop::EventLoop;
use ndk::looper::{ FdEvent, ForeignLooper };
use crate::app::App;
use crate::egl_context::{ log_software_fallback, EglContext };
use crate::event_loop::{ AppData, RunError, SurfaceFocus, SurfaceState };
use super::{ AndroidPlatform, AndroidSurfaceState };
/// How long a frame the runtime asked for without drawing waits, one refresh at 60 Hz.
const FRAME: Duration = Duration::from_millis( 16 );
/// How often the looper wakes while a held button repeats on a calloop timer.
const REPEAT_TICK: Duration = Duration::from_millis( 10 );
/// The latest activity: when one finishes the process lives on, and Android starts the next one in it with a new `android_main`.
static ACTIVITY: Mutex<Option<AndroidApp>> = Mutex::new( None );
static PROCESS: Once = Once::new();
/// Hand ltk the `AndroidApp` that `android_main` receives. The first call in the process also sets `HOME`, `SSL_CERT_DIR` and `LANG` as desktop code expects them, sends standard output and error to logcat, and unpacks the themes and fonts the APK carries under `assets/ltk/`. Call it first, before the app reads its theme or preferences and before `ltk::run`, which otherwise fails with [`RunError::NoAndroidApp`].
pub fn set_android_app( app: AndroidApp )
{
PROCESS.call_once( ||
{
set_environment( &app );
super::log::redirect();
super::assets::install( &app );
} );
*ACTIVITY.lock().unwrap_or_else( |e| e.into_inner() ) = Some( app );
}
/// Keep the app running in the background with `Some( text )` on an ongoing notification, as Android otherwise freezes it soon after it leaves the screen, or let it be frozen again with `None`. Needs the APK built with `KEEP_ALIVE=1` (`scripts/android-apk.sh`); callable from any thread, and while the app is on screen, since Android refuses to start it from the background.
pub fn keep_running( text: Option<&str> )
{
let activity = ACTIVITY.lock().unwrap_or_else( |e| e.into_inner() ).clone();
if let Some( activity ) = activity { super::java::keep_alive( &activity, text ); }
}
/// Android's CA certificates, updated through the Conscrypt APEX since Android 14, and the system's.
const CERT_DIRS: &str = "/apex/com.android.conscrypt/cacerts:/system/etc/security/cacerts";
/// `HOME` is the app's internal storage, where an Android app keeps its files; unless set, `SSL_CERT_DIR` names Android's CA certificates, which OpenSSL-style lookups such as rustls-native-certs do not know, and `LANG` the device's language. Before any thread of ltk's starts, since another thread reading the environment meanwhile is unsound.
fn set_environment( app: &AndroidApp )
{
if let Some( dir ) = app.internal_data_path() { std::env::set_var( "HOME", dir ); }
if std::env::var_os( "SSL_CERT_DIR" ).is_none() { std::env::set_var( "SSL_CERT_DIR", CERT_DIRS ); }
if std::env::var_os( "LANG" ).is_some() { return; }
let config = app.config();
let Some( language ) = config.language() else { return };
let locale = match config.country()
{
Some( country ) => format!( "{language}_{country}.UTF-8" ),
None => format!( "{language}.UTF-8" ),
};
std::env::set_var( "LANG", locale );
}
pub( crate ) fn run<A: App>( app: A )
{
if let Err( e ) = try_run( app )
{
panic!( "ltk::run failed during init: {e}" );
}
}
pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
{
let activity = ACTIVITY.lock().unwrap_or_else( |e| e.into_inner() ).clone().ok_or( RunError::NoAndroidApp )?;
let mut event_loop: EventLoop<AppData<A>> = EventLoop::try_new()
.map_err( |e| RunError::EventLoop( format!( "EventLoop::try_new: {e}" ) ) )?;
wake_on_sources( &event_loop, &activity )?;
let egl_context = match EglContext::new_android()
{
Ok( ctx ) => Some( Arc::new( ctx ) ),
Err( reason ) =>
{
log_software_fallback( &reason );
None
}
};
crate::render::set_software_render( egl_context.is_none() );
let platform = AndroidPlatform::new( activity.clone(), egl_context );
let main = SurfaceState::new( AndroidSurfaceState::default(), 0.0, String::new() );
// ltk's own keyboard: a NativeActivity gets only key events from the system's, so composed text and suggestions would be lost.
let mut data = AppData::new( app, platform, main, event_loop.handle(), true, None );
data.install_sources()?;
while !data.exit_requested
{
// The window must be let go of inside the callback, before the system destroys it; input is read after.
let mut input = false;
activity.poll_events( timeout( &data ), |event|
{
if let PollEvent::Main( event ) = event { input |= data.android_lifecycle( event ); }
} );
if let Err( e ) = event_loop.dispatch( Some( Duration::ZERO ), &mut data )
{
panic!( "dispatch: {e:?}" );
}
if input { data.read_android_input(); }
if data.platform.frame_requested.take() { data.frame_presented( SurfaceFocus::Main, false ); }
data.before_draw();
let drawn = data.draw_due();
if data.platform.presented.take() { data.frame_presented( SurfaceFocus::Main, false ); }
data.after_draw( drawn );
}
// The app asked to close: `ltk::run` returns once the system has destroyed the activity, which leaves no window behind.
if !data.platform.destroyed { super::java::finish_and_remove_task( &activity ); }
while !data.platform.destroyed
{
activity.poll_events( None, |event|
{
if let PollEvent::Main( event ) = event { data.android_lifecycle( event ); }
} );
}
Ok( () )
}
/// Wake the looper when a calloop source has an event, so `poll_events` returns and the loop dispatches it.
fn wake_on_sources<A: App>( event_loop: &EventLoop<'static, AppData<A>>, activity: &AndroidApp ) -> Result<(), RunError>
{
let looper = ForeignLooper::for_thread()
.ok_or_else( || RunError::EventLoop( "no ALooper on the activity's thread".to_string() ) )?;
let waker = activity.create_waker();
looper.add_fd_with_callback( event_loop.as_fd(), FdEvent::INPUT, move |_, _|
{
waker.wake();
true
} )
.map_err( |e| RunError::EventLoop( format!( "ALooper_addFd: {e:?}" ) ) )
}
/// The longest the looper may sleep: not at all while the main surface waits to be drawn, a refresh while a frame is owed, and otherwise until the runtime's next deadline or calloop timer.
fn timeout<A: App>( data: &AppData<A> ) -> Option<Duration>
{
let main = &data.main;
if main.configured && main.needs_redraw && !main.frame_pending { return Some( Duration::ZERO ); }
[
data.next_wakeup(),
data.platform.frame_requested.get().then_some( FRAME ),
data.button_repeat.is_some().then_some( REPEAT_TICK ),
data.app.poll_interval(),
]
.into_iter()
.flatten()
.min()
}
impl<A: App> AppData<A>
{
/// Follow the activity's lifecycle. Returns whether input is waiting to be read.
fn android_lifecycle( &mut self, event: MainEvent<'_> ) -> bool
{
match event
{
MainEvent::InputAvailable => return true,
MainEvent::InitWindow { .. } => self.attach_window(),
MainEvent::TerminateWindow { .. } => self.detach_window(),
MainEvent::WindowResized { .. }
| MainEvent::ContentRectChanged { .. }
| MainEvent::ConfigChanged { .. } => self.fit_window(),
MainEvent::RedrawNeeded { .. } => self.main.request_redraw(),
MainEvent::GainedFocus =>
{
// Another app may have copied meanwhile, and Android lets only the focused app read it.
if let Some( text ) = super::java::clipboard_text( &self.platform.activity ) { self.clipboard = text; }
self.keyboard_enter( SurfaceFocus::Main );
}
MainEvent::LostFocus => self.keyboard_leave(),
MainEvent::Destroy =>
{
self.platform.destroyed = true;
self.exit_requested = true;
}
_ => {}
}
false
}
/// Read every waiting input event; reading also lets the looper report the next ones.
fn read_android_input( &mut self )
{
let activity = self.platform.activity.clone();
let Ok( mut events ) = activity.input_events_iter() else { return };
while events.next( |event| self.android_input( event ) ) {}
}
}
+52
View File
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::cell::Cell;
use std::collections::HashMap;
use std::marker::PhantomData;
use std::sync::Arc;
use android_activity::AndroidApp;
use android_activity::input::KeyCharacterMap;
use crate::app::App;
use crate::egl_context::EglContext;
/// What the runtime's requests go through on Android: the activity, the EGL context and what the loop must report back to the runtime.
pub( crate ) struct AndroidPlatform<A: App>
{
pub activity: AndroidApp,
/// `None` when EGL failed to initialise or `LTK_FORCE_SOFTWARE=1`; the window is then drawn through a locked buffer.
pub egl_context: Option<Arc<EglContext>>,
/// Window pixels per logical pixel on each axis, which input positions are divided by. The buffers hold the logical size times the scale, which the system stretches by less than a pixel to fill the window.
pub px_per_logical: ( f64, f64 ),
/// Text input was asked for and not ended; ltk's own keyboard serves it, never the system's.
pub text_input: bool,
/// A frame reached the screen; `eglSwapBuffers` has already waited for the display, so the loop reports it at once.
pub presented: Cell<bool>,
/// The runtime asked for a frame without drawing one; the loop reports it a refresh later.
pub frame_requested: Cell<bool>,
/// Each input device's character map, read once through JNI.
pub key_maps: HashMap<i32, KeyCharacterMap>,
pub destroyed: bool,
_app: PhantomData<fn() -> A>,
}
impl<A: App> AndroidPlatform<A>
{
pub( crate ) fn new( activity: AndroidApp, egl_context: Option<Arc<EglContext>> ) -> Self
{
Self
{
activity,
egl_context,
px_per_logical: ( 1.0, 1.0 ),
text_input: false,
presented: Cell::new( false ),
frame_requested: Cell::new( false ),
key_maps: HashMap::new(),
destroyed: false,
_app: PhantomData,
}
}
}
+100
View File
@@ -0,0 +1,100 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use android_activity::Rect;
use ndk::hardware_buffer_format::HardwareBufferFormat;
use ndk::native_window::NativeWindow;
use crate::app::App;
use crate::egl_context::EglSurface;
use crate::event_loop::AppData;
use crate::event_loop::surface::Insets;
use crate::event_loop::perf::RasterTrace;
/// The main surface's Android half: the activity's window while it has one.
#[ derive( Default ) ]
pub( crate ) struct AndroidSurfaceState
{
/// Declared before `window` so it is destroyed first: EGL draws into the window.
pub egl_surface: Option<EglSurface>,
pub window: Option<NativeWindow>,
pub raster_trace: Option<RasterTrace>,
/// The software renderer's frame, copied line by line into the window's buffer, whose lines may be longer.
pub pixels: Vec<u8>,
}
/// The edges of a `window`-pixel window outside the activity's content rect, as `[ left, top, right, bottom ]`. None before Android has reported the rect, which starts empty.
fn content_edges( content: Rect, window: ( u32, u32 ) ) -> [ i32; 4 ]
{
if content.right <= content.left || content.bottom <= content.top { return [ 0; 4 ]; }
[ content.left, content.top, window.0 as i32 - content.right, window.1 as i32 - content.bottom ]
}
/// Window-pixel edges in logical pixels.
fn logical_insets( [ left, top, right, bottom ]: [ i32; 4 ], px_per_logical: ( f64, f64 ) ) -> Insets
{
let ( sx, sy ) = ( px_per_logical.0 as f32, px_per_logical.1 as f32 );
Insets
{
top: top.max( 0 ) as f32 / sy,
right: right.max( 0 ) as f32 / sx,
bottom: bottom.max( 0 ) as f32 / sy,
left: left.max( 0 ) as f32 / sx,
}
}
/// The buffer scale for a display of `dpi`: its density in multiples of Android's 160 dpi baseline, rounded, at least 1.
fn density_scale( dpi: Option<u32> ) -> u32
{
dpi.map_or( 1, |dpi| ( ( dpi as f32 / 160.0 ).round() as u32 ).max( 1 ) )
}
impl<A: App> AppData<A>
{
/// The activity has a window: draw into it through EGL when there is a context, in software otherwise.
pub( super ) fn attach_window( &mut self )
{
let Some( window ) = self.platform.activity.native_window() else { return };
self.main.platform.window = Some( window );
self.fit_window();
let ( Some( ctx ), Some( window ) ) = ( self.platform.egl_context.as_ref(), self.main.platform.window.as_ref() ) else { return };
// SAFETY: the window is held in `main.platform.window`, which outlives the EGL surface declared before it.
match unsafe { ctx.create_native_surface( window.ptr().as_ptr().cast() ) }
{
Ok( es ) => self.main.platform.egl_surface = Some( es ),
Err( e ) => eprintln!( "[ltk] {e} — drawing the window in software" ),
}
}
/// Size the main surface to the window at the display's density, its buffers to match, and keep the view out of what the system bars and the keyboard cover.
pub( super ) fn fit_window( &mut self )
{
let Some( ref window ) = self.main.platform.window else { return };
let ( ww, wh ) = ( window.width().max( 1 ) as u32, window.height().max( 1 ) as u32 );
let scale = density_scale( self.platform.activity.config().density() );
let ( w, h ) = ( ( ww / scale ).max( 1 ), ( wh / scale ).max( 1 ) );
let _ = window.set_buffers_geometry( ( w * scale ) as i32, ( h * scale ) as i32, Some( HardwareBufferFormat::R8G8B8A8_UNORM ) );
self.platform.px_per_logical = ( ww as f64 / w as f64, wh as f64 / h as f64 );
let edges = super::java::window_insets( &self.platform.activity )
.unwrap_or_else( || content_edges( self.platform.activity.content_rect(), ( ww, wh ) ) );
self.main.insets = logical_insets( edges, self.platform.px_per_logical );
if scale as i32 != self.main.scale_factor
{
self.main.scale_factor = scale as i32;
if let Some( ref mut canvas ) = self.main.canvas { canvas.set_dpi_scale( scale as f32 ); }
}
self.main.set_size( w, h );
self.on_main_resized( w, h );
}
/// The window is going away: let go of it before the system destroys it, and draw nothing until the next one.
pub( super ) fn detach_window( &mut self )
{
if let Some( ref ctx ) = self.platform.egl_context { ctx.release_current(); }
self.main.platform.egl_surface = None;
self.main.platform.window = None;
self.main.configured = false;
self.main.frame_pending = false;
}
}