diff --git a/README.md b/README.md
index 591c87b..8e72533 100644
--- a/README.md
+++ b/README.md
@@ -27,13 +27,20 @@ nix develop # or: direnv allow (after .envrc)
# or: ./scripts/enter
just host # desktop window (cargo run)
just lib # build android package as rlib (desktop target)
-just waydroid # APK → install → launch on Waydroid (x86_64)
+just waydroid # cargo-apk → install → launch on Waydroid (x86_64)
# Or via the flake (builds desktop host into ./result):
nix build # → ./result/bin/sleek
nix run # pure Nix store binary (hermetic)
nix run .#host # in-tree cargo run (iterative; needs sibling vidya/freeq)
+# Waydroid (x86_64 cargo-apk + install/launch + full UI window):
+nix run .#waydroid # debug: build + install + launch + show-full-ui
+nix run .#waydroid-release # release (optimized + local signing keystore)
+nix run .#waydroid -- build # cargo-apk only
+nix run .#waydroid -- launch # start activity + show-full-ui
+# Defaults: 1080×2400 @ density 420 (override with SLEEK_WAYDROID_WIDTH/HEIGHT/LCD_DENSITY)
+
# Phone APK (aarch64) + adb install:
# Fast iterate (in-tree cargo-apk + deploy — needs sibling vidya/freeq path deps):
nix run .#deploy-android # cargo apk + adb install -r
diff --git a/android/Cargo.toml b/android/Cargo.toml
index 8557dc2..0946942 100644
--- a/android/Cargo.toml
+++ b/android/Cargo.toml
@@ -172,6 +172,9 @@ label = "Sleek"
# defaults hasCode=false for pure-native apps.
has_code = true
orientations = ["Portrait", "Landscape"]
+# No action bar; SleekActivity still draws edge-to-edge under transparent
+# system bars (not Theme.*.Fullscreen, which would hide the status bar).
+theme = "@android:style/Theme.DeviceDefault.NoActionBar"
# Subclass of NativeActivity (classes.dex injected post cargo-apk) so
# onNewIntent can capture freeq:// OAuth callbacks while the app is warm.
diff --git a/android/java/uk/nandi/sleek/SleekActivity.java b/android/java/uk/nandi/sleek/SleekActivity.java
index d43dfd2..734c456 100644
--- a/android/java/uk/nandi/sleek/SleekActivity.java
+++ b/android/java/uk/nandi/sleek/SleekActivity.java
@@ -1,9 +1,19 @@
package uk.nandi.sleek;
+import android.app.Activity;
import android.app.NativeActivity;
import android.content.Intent;
+import android.content.res.Resources;
+import android.graphics.Color;
+import android.graphics.Insets;
import android.net.Uri;
+import android.os.Build;
import android.os.Bundle;
+import android.provider.Settings;
+import android.view.View;
+import android.view.Window;
+import android.view.WindowInsets;
+import android.view.WindowManager;
/**
* NativeActivity subclass so OAuth deep links ({@code freeq://auth?…}) are
@@ -15,6 +25,14 @@ import android.os.Bundle;
* We stash the latest {@code freeq://} URI in a static that Rust polls via JNI
* (same pattern as {@link PickFragment}).
*
+ *
Also forces edge-to-edge drawing: the native surface fills the
+ * physical display (under transparent status / nav bars and into the cutout).
+ * Rust reserves real safe-area insets from {@code content_rect} so UI chrome
+ * does not sit under the clock or gesture bar — without oversized fixed pads.
+ *
+ *
Exposes {@link #navigationMode()} so Rust can tell 3-button vs gesture
+ * nav (for chrome fallbacks — measured insets remain the primary source).
+ *
*
Manifest: {@code launchMode=singleTask} + VIEW intent-filter on scheme
* {@code freeq}. Broker login uses {@code mobile=1} so the redirect is
* {@code freeq://auth?…} instead of loopback.
@@ -23,12 +41,31 @@ public class SleekActivity extends NativeActivity {
/** Latest unconsumed {@code freeq://…} deep link (or null). */
public static volatile String pendingDeepLink = null;
+ /** Three-button navigation (Back / Home / Recents). */
+ public static final int NAV_MODE_THREE_BUTTON = 0;
+ /** Two-button navigation (Home pill + Back). */
+ public static final int NAV_MODE_TWO_BUTTON = 1;
+ /** Full gesture navigation (edge back + home swipe). */
+ public static final int NAV_MODE_GESTURES = 2;
+ /** Could not determine mode. */
+ public static final int NAV_MODE_UNKNOWN = -1;
+
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
+ applyEdgeToEdge();
captureIntent(getIntent());
}
+ @Override
+ public void onWindowFocusChanged(boolean hasFocus) {
+ super.onWindowFocusChanged(hasFocus);
+ if (hasFocus) {
+ // Re-apply after system UI changes (keyboard, multi-window, etc.).
+ applyEdgeToEdge();
+ }
+ }
+
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
@@ -37,6 +74,36 @@ public class SleekActivity extends NativeActivity {
captureIntent(intent);
}
+ /**
+ * Draw the NativeActivity surface under system bars / display cutout so
+ * the app can use the whole screen. Status and nav remain visible
+ * (transparent) — we do not hide them with immersive sticky.
+ */
+ private void applyEdgeToEdge() {
+ Window window = getWindow();
+ if (window == null) {
+ return;
+ }
+ window.setStatusBarColor(Color.TRANSPARENT);
+ window.setNavigationBarColor(Color.TRANSPARENT);
+ if (Build.VERSION.SDK_INT >= 28) {
+ WindowManager.LayoutParams lp = window.getAttributes();
+ lp.layoutInDisplayCutoutMode =
+ WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
+ window.setAttributes(lp);
+ }
+ View decor = window.getDecorView();
+ if (decor == null) {
+ return;
+ }
+ // LAYOUT_* flags: content extends under status/nav without hiding them.
+ int flags = decor.getSystemUiVisibility();
+ flags |= View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION;
+ decor.setSystemUiVisibility(flags);
+ }
+
private static void captureIntent(Intent intent) {
if (intent == null) {
return;
@@ -63,4 +130,235 @@ public class SleekActivity extends NativeActivity {
pendingDeepLink = null;
return s;
}
+
+ /**
+ * System navigation interaction mode for this activity.
+ *
+ *
Values match Android's internal {@code config_navBarInteractionMode}:
+ * {@link #NAV_MODE_THREE_BUTTON}, {@link #NAV_MODE_TWO_BUTTON},
+ * {@link #NAV_MODE_GESTURES}, or {@link #NAV_MODE_UNKNOWN}.
+ *
+ *
Called from Rust over JNI each frame (cheap after resource lookup) so
+ * chrome can avoid reserving 3-button space under gesture nav.
+ */
+ public int navigationMode() {
+ return detectNavigationMode(this);
+ }
+
+ /**
+ * Static entry for JNI when only a class + Activity jobject is handy.
+ *
+ * @param activity host activity (may be null → {@link #NAV_MODE_UNKNOWN})
+ */
+ public static int navigationModeOf(Activity activity) {
+ return detectNavigationMode(activity);
+ }
+
+ /**
+ * Status bar (+ display cutout) top inset in physical pixels .
+ *
+ *
Prefer this over NativeActivity {@code content_rect}, which often
+ * reports top=0 under edge-to-edge and lets UI draw under the clock.
+ *
+ * @param activity host activity (may be null → 0)
+ */
+ public static int statusBarInsetPx(Activity activity) {
+ if (activity == null) {
+ return 0;
+ }
+ int top = 0;
+ try {
+ Window window = activity.getWindow();
+ View decor = window != null ? window.getDecorView() : null;
+ WindowInsets root = decor != null ? decor.getRootWindowInsets() : null;
+ if (root != null) {
+ if (Build.VERSION.SDK_INT >= 30) {
+ Insets status = root.getInsets(WindowInsets.Type.statusBars());
+ Insets cutout = root.getInsets(WindowInsets.Type.displayCutout());
+ top = Math.max(status.top, cutout.top);
+ } else {
+ top = root.getSystemWindowInsetTop();
+ if (Build.VERSION.SDK_INT >= 28 && root.getDisplayCutout() != null) {
+ top = Math.max(top, root.getDisplayCutout().getSafeInsetTop());
+ }
+ }
+ }
+ } catch (Throwable ignored) {
+ // fall through
+ }
+ if (top <= 0) {
+ top = dimenPx(activity, "status_bar_height");
+ }
+ return Math.max(top, 0);
+ }
+
+ /**
+ * Navigation bar bottom inset in physical pixels .
+ *
+ * @param activity host activity (may be null → 0)
+ */
+ public static int navigationBarInsetPx(Activity activity) {
+ if (activity == null) {
+ return 0;
+ }
+ int bottom = 0;
+ try {
+ Window window = activity.getWindow();
+ View decor = window != null ? window.getDecorView() : null;
+ WindowInsets root = decor != null ? decor.getRootWindowInsets() : null;
+ if (root != null) {
+ if (Build.VERSION.SDK_INT >= 30) {
+ Insets nav = root.getInsets(WindowInsets.Type.navigationBars());
+ bottom = nav.bottom;
+ } else {
+ bottom = root.getSystemWindowInsetBottom();
+ }
+ }
+ } catch (Throwable ignored) {
+ // fall through
+ }
+ if (bottom <= 0) {
+ int navRes = dimenPx(activity, "navigation_bar_height");
+ if (navRes > 0) {
+ float density = activity.getResources().getDisplayMetrics().density;
+ float dp = navRes / Math.max(density, 0.01f);
+ int mode = detectNavigationMode(activity);
+ if (dp >= 40f || mode != NAV_MODE_GESTURES) {
+ bottom = navRes;
+ } else if (dp > 0f && dp < 32f) {
+ bottom = navRes;
+ }
+ }
+ }
+ return Math.max(bottom, 0);
+ }
+
+ private static int dimenPx(Activity activity, String name) {
+ try {
+ Resources res = activity.getResources();
+ int id = res.getIdentifier(name, "dimen", "android");
+ if (id > 0) {
+ return res.getDimensionPixelSize(id);
+ }
+ } catch (Throwable ignored) {
+ // ignore
+ }
+ return 0;
+ }
+
+ /**
+ * Detect 3-button / 2-button / gesture navigation.
+ *
+ *
Order matters. Measured nav-bar height is preferred
+ * over system-gesture side insets — several OEMs report non-zero left/right
+ * gesture zones even with 3-button nav, which previously false-positived
+ * as gestures and let the app draw over Back/Home/Recents.
+ *
+ *
+ * {@code Settings.Secure.navigation_mode} (user choice, most reliable)
+ * Internal resource {@code config_navBarInteractionMode} (AOSP Q+)
+ * WindowInsets nav-bar bottom height (≥40dp buttons, <32dp gesture)
+ * System-gesture side insets — only when bottom is not button-sized
+ * Resource {@code navigation_bar_height} dimen as last resort
+ *
+ */
+ static int detectNavigationMode(Activity activity) {
+ if (activity == null) {
+ return NAV_MODE_UNKNOWN;
+ }
+
+ // 1) Secure setting — reflects the user's actual nav mode on most builds.
+ // Prefer this over the internal config integer, which some OEMs leave
+ // stale when the user switches gestures ↔ buttons.
+ try {
+ int mode = Settings.Secure.getInt(
+ activity.getContentResolver(), "navigation_mode", -1);
+ if (mode == NAV_MODE_THREE_BUTTON
+ || mode == NAV_MODE_TWO_BUTTON
+ || mode == NAV_MODE_GESTURES) {
+ return mode;
+ }
+ } catch (Throwable ignored) {
+ // fall through
+ }
+
+ // 2) AOSP internal integer (not public API; present on Pixel / stock).
+ try {
+ Resources res = activity.getResources();
+ int id = res.getIdentifier(
+ "config_navBarInteractionMode", "integer", "android");
+ if (id > 0) {
+ int mode = res.getInteger(id);
+ if (mode == NAV_MODE_THREE_BUTTON
+ || mode == NAV_MODE_TWO_BUTTON
+ || mode == NAV_MODE_GESTURES) {
+ return mode;
+ }
+ }
+ } catch (Throwable ignored) {
+ // fall through
+ }
+
+ // 3) WindowInsets: bottom height first, then side gesture zones.
+ try {
+ Window window = activity.getWindow();
+ View decor = window != null ? window.getDecorView() : null;
+ WindowInsets root = decor != null ? decor.getRootWindowInsets() : null;
+ if (root != null && Build.VERSION.SDK_INT >= 29) {
+ float density = activity.getResources().getDisplayMetrics().density;
+ float bottomDp = 0f;
+ if (Build.VERSION.SDK_INT >= 30) {
+ Insets nav = root.getInsets(WindowInsets.Type.navigationBars());
+ bottomDp = nav.bottom / Math.max(density, 0.01f);
+ } else {
+ bottomDp = root.getSystemWindowInsetBottom()
+ / Math.max(density, 0.01f);
+ }
+ // 3-button bars are typically ≥40dp; gesture handle ~16–24dp.
+ if (bottomDp >= 40f) {
+ return NAV_MODE_THREE_BUTTON;
+ }
+ if (bottomDp > 0f && bottomDp < 32f) {
+ return NAV_MODE_GESTURES;
+ }
+
+ // Side system-gesture zones: only trust when the bottom bar is
+ // not already button-sized (tall bottoms win above).
+ if (Build.VERSION.SDK_INT >= 30) {
+ Insets g = root.getInsets(WindowInsets.Type.systemGestures());
+ if ((g.left > 0 || g.right > 0) && bottomDp < 40f) {
+ return NAV_MODE_GESTURES;
+ }
+ } else {
+ android.graphics.Insets g = root.getSystemGestureInsets();
+ if ((g.left > 0 || g.right > 0) && bottomDp < 40f) {
+ return NAV_MODE_GESTURES;
+ }
+ }
+ }
+ } catch (Throwable ignored) {
+ // fall through
+ }
+
+ // 4) Resource nav_bar height when present.
+ try {
+ Resources res = activity.getResources();
+ int id = res.getIdentifier("navigation_bar_height", "dimen", "android");
+ if (id > 0) {
+ int px = res.getDimensionPixelSize(id);
+ float density = res.getDisplayMetrics().density;
+ float dp = px / Math.max(density, 0.01f);
+ if (dp >= 40f) {
+ return NAV_MODE_THREE_BUTTON;
+ }
+ if (dp > 0f && dp < 32f) {
+ return NAV_MODE_GESTURES;
+ }
+ }
+ } catch (Throwable ignored) {
+ // fall through
+ }
+
+ return NAV_MODE_UNKNOWN;
+ }
}
diff --git a/android/src/android_media.rs b/android/src/android_media.rs
index 8cd1b55..747cc57 100644
--- a/android/src/android_media.rs
+++ b/android/src/android_media.rs
@@ -10,7 +10,8 @@ use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
-use winit::platform::android::activity::AndroidApp;
+use eframe::egui::Context;
+use winit::platform::android::activity::{AndroidApp, WindowManagerFlags};
use crate::clipboard::PickImageResult;
@@ -24,7 +25,16 @@ const PICK_IMAGE_REQ: i32 = 0x51_EE_61;
const PICK_FRAGMENT_REQ: i32 = 0x1_EE_6;
/// Stash the `AndroidApp` from `android_main` so path/permission helpers can use it.
+///
+/// Also enables edge-to-edge layout flags so the GL surface fills the physical
+/// display and `content_rect` reports real status/nav insets.
pub fn set_android_app(app: AndroidApp) {
+ // LAYOUT_IN_SCREEN: draw under system bars.
+ // LAYOUT_INSET_DECOR: content_rect reports the safe area under those bars.
+ app.set_window_flags(
+ WindowManagerFlags::LAYOUT_IN_SCREEN | WindowManagerFlags::LAYOUT_INSET_DECOR,
+ WindowManagerFlags::empty(),
+ );
let _ = ANDROID_APP.set(app);
}
@@ -32,6 +42,537 @@ fn android_app() -> Option<&'static AndroidApp> {
ANDROID_APP.get()
}
+/// System navigation interaction mode (3-button vs gestures).
+///
+/// Matches Android's internal `config_navBarInteractionMode` / Secure
+/// `navigation_mode` encoding. Used to refine chrome fallbacks when
+/// `content_rect` under- or over-reports the bottom inset.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum NavMode {
+ /// Back / Home / Recents bar.
+ ThreeButton,
+ /// Home pill + Back (older Android).
+ TwoButton,
+ /// Edge-swipe gestures + thin home indicator.
+ Gestures,
+ /// Detection failed — treat measured insets as authoritative.
+ Unknown,
+}
+
+impl NavMode {
+ pub fn is_button_nav(self) -> bool {
+ matches!(self, Self::ThreeButton | Self::TwoButton)
+ }
+
+ pub fn is_gesture_nav(self) -> bool {
+ matches!(self, Self::Gestures)
+ }
+
+ fn from_android(code: i32) -> Self {
+ match code {
+ 0 => Self::ThreeButton,
+ 1 => Self::TwoButton,
+ 2 => Self::Gestures,
+ _ => Self::Unknown,
+ }
+ }
+
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::ThreeButton => "three_button",
+ Self::TwoButton => "two_button",
+ Self::Gestures => "gestures",
+ Self::Unknown => "unknown",
+ }
+ }
+}
+
+/// Cache for JNI nav-mode probes (avoid attach every frame).
+struct NavModeCache {
+ mode: NavMode,
+ /// `Instant` of last successful JNI probe.
+ at: Instant,
+}
+
+static NAV_MODE_CACHE: Mutex> = Mutex::new(None);
+
+/// Re-probe interval — mode only changes in Settings.
+const NAV_MODE_TTL: Duration = Duration::from_secs(2);
+
+/// Detect 3-button vs gesture navigation via `SleekActivity.navigationMode()`.
+///
+/// Results are cached for [`NAV_MODE_TTL`]. Returns [`NavMode::Unknown`] when
+/// JNI is unavailable (cold start, missing dex).
+pub fn navigation_mode() -> NavMode {
+ if let Ok(guard) = NAV_MODE_CACHE.lock() {
+ if let Some(c) = guard.as_ref() {
+ if c.at.elapsed() < NAV_MODE_TTL {
+ return c.mode;
+ }
+ }
+ }
+
+ let mode = query_navigation_mode_jni().unwrap_or(NavMode::Unknown);
+
+ if let Ok(mut guard) = NAV_MODE_CACHE.lock() {
+ let prev = guard.as_ref().map(|c| c.mode);
+ *guard = Some(NavModeCache {
+ mode,
+ at: Instant::now(),
+ });
+ if prev != Some(mode) {
+ log::info!("android nav mode: {}", mode.as_str());
+ }
+ }
+
+ mode
+}
+
+fn query_navigation_mode_jni() -> Option {
+ let app = android_app()?;
+ let vm_ptr = app.vm_as_ptr();
+ if vm_ptr.is_null() {
+ return None;
+ }
+ let activity_ptr = app.activity_as_ptr() as jni::sys::jobject;
+ if activity_ptr.is_null() {
+ return None;
+ }
+
+ use jni::objects::{JObject, JValue};
+ use jni::refs::Global;
+ use jni::{jni_sig, jni_str, JavaVM};
+
+ // SAFETY: vm from live AndroidApp.
+ let vm = unsafe { JavaVM::from_raw(vm_ptr.cast()) };
+
+ let result = vm.attach_current_thread(|env| -> jni::errors::Result {
+ // SAFETY: activity global ref owned by the runtime.
+ let activity = unsafe { env.as_cast_raw::>(&activity_ptr)? };
+
+ // 1) SleekActivity helper (full heuristics: Secure + config + insets).
+ if let Ok(cls) = env.find_class(jni_str!("uk/nandi/sleek/SleekActivity")) {
+ if let Ok(v) = env.call_static_method(
+ &cls,
+ jni_str!("navigationModeOf"),
+ jni_sig!((android.app.Activity) -> jint),
+ &[JValue::Object(&activity)],
+ ) {
+ if let Ok(code) = v.i() {
+ if code >= 0 {
+ return Ok(code);
+ }
+ }
+ }
+ // Instance method fallback (subclass dex injected).
+ if let Ok(v) = env.call_method(
+ &activity,
+ jni_str!("navigationMode"),
+ jni_sig!(() -> jint),
+ &[],
+ ) {
+ if let Ok(code) = v.i() {
+ if code >= 0 {
+ return Ok(code);
+ }
+ }
+ }
+ }
+
+ // 2) Direct Settings.Secure.navigation_mode — works even when the
+ // injected SleekActivity dex is stale/missing the helper methods
+ // (that was returning Unknown on Waydroid while `adb shell settings
+ // get secure navigation_mode` already showed 0 = three-button).
+ if let Ok(resolver) = env
+ .call_method(
+ &activity,
+ jni_str!("getContentResolver"),
+ jni_sig!(() -> android.content.ContentResolver),
+ &[],
+ )
+ .and_then(|v| v.l())
+ {
+ if !resolver.is_null() {
+ if let Ok(settings_secure) =
+ env.find_class(jni_str!("android/provider/Settings$Secure"))
+ {
+ if let Ok(name) = env.new_string("navigation_mode") {
+ if let Ok(v) = env.call_static_method(
+ &settings_secure,
+ jni_str!("getInt"),
+ jni_sig!(
+ (android.content.ContentResolver, java.lang.String, jint) -> jint
+ ),
+ &[
+ JValue::Object(&resolver),
+ JValue::Object(name.as_ref()),
+ JValue::Int(-1),
+ ],
+ ) {
+ if let Ok(code) = v.i() {
+ if code >= 0 {
+ return Ok(code);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Ok(-1)
+ });
+
+ match result {
+ Ok(code) => Some(NavMode::from_android(code)),
+ Err(e) => {
+ log::debug!("android nav mode JNI: {e}");
+ None
+ }
+ }
+}
+
+/// Measured status/nav insets in **egui points**.
+///
+/// **Primary source:** JNI WindowInsets (`statusBars` + cutout +
+/// `navigationBars`). NativeActivity `content_rect` is secondary — it often
+/// reports `top=0` under edge-to-edge.
+///
+/// **Rules (hard-won):**
+/// 1. **Measured bottom is never shrunk** by a nav-mode guess. Clamping a real
+/// 48 dp 3-button bar down to a “gesture” pad was the regression that drew
+/// app chrome over Back/Home/Recents when the detector lied.
+/// 2. **Bottom height classifies the bar** when mode is wrong/unknown:
+/// ≥40 pt ⇒ button nav, 12–32 pt ⇒ gesture handle.
+/// 3. Nav mode only **floors** missing measurements (never caps good ones).
+/// 4. **Top is never zero** while edge-to-edge — floor so the clock clears.
+pub fn measured_system_chrome(ctx: &Context) -> Option {
+ let ppp = ctx.pixels_per_point().max(0.01);
+ let screen = ctx.screen_rect();
+ if screen.height() <= 0.0 {
+ return None;
+ }
+ let max_inset = screen.height() * 0.25;
+
+ // 1) WindowInsets via JNI (authoritative under edge-to-edge).
+ let (jni_top_px, jni_bottom_px) = system_bar_insets_px().unwrap_or((0, 0));
+
+ // 2) content_rect as secondary (sometimes has nav when WindowInsets lag).
+ let (cr_top_px, cr_bottom_px) = content_rect_insets_px(ctx);
+
+ // Top: take the larger clearance so the clock always wins.
+ let top_px = jni_top_px.max(cr_top_px).max(0);
+ // Bottom: prefer WindowInsets when present. content_rect can over-report
+ // (IME/layout glitches) and used to force a 3-button-sized pad under
+ // gesture nav when we took max(jni, cr).
+ let bottom_px = if jni_bottom_px > 0 {
+ jni_bottom_px
+ } else {
+ cr_bottom_px.max(0)
+ };
+
+ let mut top = (top_px as f32 / ppp).clamp(0.0, max_inset);
+ let mut bottom = (bottom_px as f32 / ppp).clamp(0.0, max_inset);
+
+ // Edge-to-edge surface: never allow a zero top inset. content_rect and even
+ // early-frame WindowInsets can report 0 while the GL surface still draws
+ // under the status bar — floor so the title / header clear the clock.
+ const STATUS_TOP_MIN: f32 = 32.0;
+ if top < STATUS_TOP_MIN {
+ top = STATUS_TOP_MIN.min(max_inset);
+ }
+
+ // Bottom: **trust measured WindowInsets when present** (in points).
+ // Never shrink a real inset because a nav-mode guess said "gestures" —
+ // that was the regression that painted over Back/Home/Recents.
+ // Floors apply only when measurement is missing (0 px).
+ const GESTURE_BOTTOM_MIN: f32 = 16.0;
+ const BUTTON_BOTTOM_MIN: f32 = 48.0;
+ let nav = navigation_mode();
+ if bottom_px <= 0 {
+ if nav.is_button_nav() {
+ bottom = BUTTON_BOTTOM_MIN.min(max_inset);
+ } else if nav.is_gesture_nav() {
+ bottom = GESTURE_BOTTOM_MIN.min(max_inset);
+ } else {
+ // Unknown + no measurement: prefer button pad (covering system
+ // buttons is worse than a few empty points under gestures).
+ bottom = BUTTON_BOTTOM_MIN.min(max_inset);
+ }
+ }
+ // else: keep measured `bottom` from WindowInsets / content_rect as-is.
+
+ // One-shot-ish log when values/mode change (cache key via static).
+ {
+ static LAST: Mutex> = Mutex::new(None);
+ let key = (
+ (top * 10.0) as u32,
+ (bottom * 10.0) as u32,
+ nav,
+ );
+ if let Ok(mut g) = LAST.lock() {
+ if g.as_ref() != Some(&key) {
+ log::info!(
+ "android chrome: top={top:.1} bottom={bottom:.1} nav={} \
+ jni_px=({jni_top_px},{jni_bottom_px}) cr_px=({cr_top_px},{cr_bottom_px})",
+ nav.as_str()
+ );
+ *g = Some(key);
+ }
+ }
+ }
+
+ Some(vidya::SystemChrome { top, bottom })
+}
+
+/// Physical-pixel insets from `AndroidApp::content_rect` (may be zero/wrong).
+fn content_rect_insets_px(ctx: &Context) -> (i32, i32) {
+ let Some(app) = android_app() else {
+ return (0, 0);
+ };
+ let cr = app.content_rect();
+ if cr.right <= cr.left || cr.bottom <= cr.top {
+ return (0, 0);
+ }
+ let ppp = ctx.pixels_per_point().max(0.01);
+ let surface_h = (ctx.screen_rect().height() * ppp).round() as i32;
+ if surface_h <= 0 {
+ return (0, 0);
+ }
+ let content_h = cr.bottom - cr.top;
+ if content_h < surface_h / 3 {
+ return (0, 0);
+ }
+ let top = cr.top.max(0);
+ let bottom = (surface_h - cr.bottom).max(0);
+ (top, bottom)
+}
+
+/// JNI: status + nav bar insets in physical pixels.
+///
+/// Tries `SleekActivity` helpers first, then reads `WindowInsets` directly from
+/// the decor view (does not depend on injected-dex methods — those were
+/// returning 0/`Unknown` on device while `VRI` already had real insets).
+fn system_bar_insets_px() -> Option<(i32, i32)> {
+ let app = android_app()?;
+ let vm_ptr = app.vm_as_ptr();
+ if vm_ptr.is_null() {
+ return None;
+ }
+ let activity_ptr = app.activity_as_ptr() as jni::sys::jobject;
+ if activity_ptr.is_null() {
+ return None;
+ }
+
+ use jni::objects::{JObject, JValue};
+ use jni::refs::Global;
+ use jni::{jni_sig, jni_str, JavaVM};
+
+ // SAFETY: vm from live AndroidApp.
+ let vm = unsafe { JavaVM::from_raw(vm_ptr.cast()) };
+
+ let result = vm.attach_current_thread(|env| -> jni::errors::Result<(i32, i32)> {
+ // SAFETY: activity global ref owned by the runtime.
+ let activity = unsafe { env.as_cast_raw::>(&activity_ptr)? };
+
+ // 1) SleekActivity helpers (dimen fallbacks + cutout max).
+ if let Ok(cls) = env.find_class(jni_str!("uk/nandi/sleek/SleekActivity")) {
+ let top = env
+ .call_static_method(
+ &cls,
+ jni_str!("statusBarInsetPx"),
+ jni_sig!((android.app.Activity) -> jint),
+ &[JValue::Object(&activity)],
+ )
+ .ok()
+ .and_then(|v| v.i().ok())
+ .unwrap_or(0)
+ .max(0);
+ let bottom = env
+ .call_static_method(
+ &cls,
+ jni_str!("navigationBarInsetPx"),
+ jni_sig!((android.app.Activity) -> jint),
+ &[JValue::Object(&activity)],
+ )
+ .ok()
+ .and_then(|v| v.i().ok())
+ .unwrap_or(0)
+ .max(0);
+ if top > 0 || bottom > 0 {
+ return Ok((top, bottom));
+ }
+ }
+
+ // 2) Direct WindowInsets from decor (framework API — no custom dex).
+ let window = env
+ .call_method(
+ &activity,
+ jni_str!("getWindow"),
+ jni_sig!(() -> android.view.Window),
+ &[],
+ )?
+ .l()?;
+ if !window.is_null() {
+ let decor = env
+ .call_method(
+ &window,
+ jni_str!("getDecorView"),
+ jni_sig!(() -> android.view.View),
+ &[],
+ )?
+ .l()?;
+ if !decor.is_null() {
+ let root = env
+ .call_method(
+ &decor,
+ jni_str!("getRootWindowInsets"),
+ jni_sig!(() -> android.view.WindowInsets),
+ &[],
+ )?
+ .l()?;
+ if !root.is_null() {
+ let mut top = 0i32;
+ let mut bottom = 0i32;
+
+ // Typed insets (API 30+): WindowInsets.Type.statusBars() etc.
+ if let Ok(type_cls) =
+ env.find_class(jni_str!("android/view/WindowInsets$Type"))
+ {
+ let status_t = env
+ .call_static_method(
+ &type_cls,
+ jni_str!("statusBars"),
+ jni_sig!(() -> jint),
+ &[],
+ )
+ .ok()
+ .and_then(|v| v.i().ok());
+ let nav_t = env
+ .call_static_method(
+ &type_cls,
+ jni_str!("navigationBars"),
+ jni_sig!(() -> jint),
+ &[],
+ )
+ .ok()
+ .and_then(|v| v.i().ok());
+ let cutout_t = env
+ .call_static_method(
+ &type_cls,
+ jni_str!("displayCutout"),
+ jni_sig!(() -> jint),
+ &[],
+ )
+ .ok()
+ .and_then(|v| v.i().ok());
+ if let (Some(st), Some(nt), Some(ct)) = (status_t, nav_t, cutout_t) {
+ let status_top = window_insets_top(env, &root, st);
+ let cutout_top = window_insets_top(env, &root, ct);
+ let nav_bottom = window_insets_bottom(env, &root, nt);
+ top = status_top.max(cutout_top);
+ bottom = nav_bottom;
+ }
+ }
+
+ if top <= 0 {
+ top = env
+ .call_method(
+ &root,
+ jni_str!("getSystemWindowInsetTop"),
+ jni_sig!(() -> jint),
+ &[],
+ )
+ .ok()
+ .and_then(|v| v.i().ok())
+ .unwrap_or(0)
+ .max(0);
+ }
+ if bottom <= 0 {
+ bottom = env
+ .call_method(
+ &root,
+ jni_str!("getSystemWindowInsetBottom"),
+ jni_sig!(() -> jint),
+ &[],
+ )
+ .ok()
+ .and_then(|v| v.i().ok())
+ .unwrap_or(0)
+ .max(0);
+ }
+
+ if top > 0 || bottom > 0 {
+ return Ok((top, bottom));
+ }
+ }
+ }
+ }
+
+ Ok((0, 0))
+ });
+
+ match result {
+ Ok(pair) => Some(pair),
+ Err(e) => {
+ log::debug!("android system bar insets JNI: {e}");
+ None
+ }
+ }
+}
+
+fn window_insets_top(env: &mut jni::Env<'_>, root: &jni::objects::JObject<'_>, type_mask: i32) -> i32 {
+ use jni::objects::JValue;
+ use jni::{jni_sig, jni_str};
+ let Ok(insets) = env
+ .call_method(
+ root,
+ jni_str!("getInsets"),
+ jni_sig!((jint) -> android.graphics.Insets),
+ &[JValue::Int(type_mask)],
+ )
+ .and_then(|v| v.l())
+ else {
+ return 0;
+ };
+ if insets.is_null() {
+ return 0;
+ }
+ env.get_field(&insets, jni_str!("top"), jni_sig!(jint))
+ .ok()
+ .and_then(|v| v.i().ok())
+ .unwrap_or(0)
+ .max(0)
+}
+
+fn window_insets_bottom(
+ env: &mut jni::Env<'_>,
+ root: &jni::objects::JObject<'_>,
+ type_mask: i32,
+) -> i32 {
+ use jni::objects::JValue;
+ use jni::{jni_sig, jni_str};
+ let Ok(insets) = env
+ .call_method(
+ root,
+ jni_str!("getInsets"),
+ jni_sig!((jint) -> android.graphics.Insets),
+ &[JValue::Int(type_mask)],
+ )
+ .and_then(|v| v.l())
+ else {
+ return 0;
+ };
+ if insets.is_null() {
+ return 0;
+ }
+ env.get_field(&insets, jni_str!("bottom"), jni_sig!(jint))
+ .ok()
+ .and_then(|v| v.i().ok())
+ .unwrap_or(0)
+ .max(0)
+}
+
/// Shared media directories (used only as fallback path hints / logging).
#[allow(dead_code)]
pub fn media_root_candidates() -> Vec {
diff --git a/android/src/app.rs b/android/src/app.rs
index cccd3fc..6007812 100644
--- a/android/src/app.rs
+++ b/android/src/app.rs
@@ -3,6 +3,8 @@
use eframe::egui::{self, Align, Layout, RichText, ScrollArea, Stroke, Vec2};
use freeq_sdk::event::Event;
use vidya::{apply, body, button, dim_label, reserve_system_chrome, title, Mode, Theme};
+#[cfg(target_os = "android")]
+use vidya::set_system_chrome;
use crate::auth::{self, AuthTokens};
use crate::av::{self, LocalCall, MediaStatus};
@@ -1327,7 +1329,7 @@ impl SleekApp {
self.state.status_line = "Connecting…".into();
// Client-side room memory (prefs) — freeq does not reliably restore
- // channel membership across reconnects. Default lobby is #general.
+ // channel membership across reconnects. Defaults: #general, #test.
let auto_join = self.state.auto_join_channels();
self.net.send(NetCmd::Connect {
@@ -1722,9 +1724,15 @@ impl eframe::App for SleekApp {
|| screen.height() >= screen.width()
|| screen.width() < 640.0;
- // vidya::reserve_system_chrome already grows the bottom inset while a
- // text field has focus (IME clearance). Do not add a second IME band
- // here — that double-padded ~80% of the screen gray on join/search.
+ // System status / nav safe areas (edge-to-edge). Measured via
+ // WindowInsets JNI + content_rect; top is floored so the status bar
+ // is always respected (content_rect alone often reports top=0).
+ // Bottom refined for 3-button vs gesture (android_media::navigation_mode).
+ // IME clearance still lives in vidya::system_chrome when focused.
+ #[cfg(target_os = "android")]
+ if let Some(chrome) = crate::android_media::measured_system_chrome(ctx) {
+ set_system_chrome(ctx, chrome);
+ }
reserve_system_chrome(ctx, &th);
let connected = self.state.connection == ConnectionState::Registered
@@ -1915,12 +1923,26 @@ impl eframe::App for SleekApp {
// ── Main content ───────────────────────────────────────────
// Theater mode: zero margin so video claims the whole window.
- // Landscape short: smaller page padding so chat keeps height.
+ // Android / fill: tight side padding so content uses the full width;
+ // vertical pad is minimal (system chrome + header/tabs already own
+ // the safe edges — large page padding looked letterboxed).
let page = if av_fullscreen {
egui::Frame::new().fill(p.window_bg).inner_margin(egui::Margin::ZERO)
} else if fill {
- let h_pad = if short { 8_i8 } else { 10_i8 };
- let v_pad = if short { (sp.sm) as i8 } else { sp.page as i8 };
+ let h_pad = if cfg!(target_os = "android") {
+ if short { 6_i8 } else { 8_i8 }
+ } else if short {
+ 8_i8
+ } else {
+ 10_i8
+ };
+ let v_pad = if cfg!(target_os = "android") {
+ if short { sp.xs as i8 } else { sp.sm as i8 }
+ } else if short {
+ sp.sm as i8
+ } else {
+ sp.page as i8
+ };
egui::Frame::new()
.fill(p.window_bg)
.inner_margin(egui::Margin::symmetric(h_pad, v_pad))
diff --git a/android/src/assets/sleek_activity.dex b/android/src/assets/sleek_activity.dex
index 5b983452d3ed4b6861b2e38cfdbc7616bfff1c53..cee89ee1f01d1e7617fbb07b43ee288a57a761cf 100644
GIT binary patch
literal 5556
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
FuK)(P&EWt5
literal 1568
zcmYdEt>7{+wq#iIrgb6DmXb5^muCqbiBU0ex43=z*2mt93TzAv1q=)f6=5bq3{cR(
z%D^DY01@Y8U|@K{z`(!(!i)?Ij0_A68H@}J0w8%P{{x86!oVQF#K7Rj%)k)9%)k)A
z%)k)M%)n5|%)l_2nSo&$GXukJW(J1y%nS^VnHd=SQr?bSr{06SQr?>Sr{0SSQr?xSr`~fSr{0aSQr>4voJ8sV_{&}%)-F1
zmxY0WnSp_Ug@J*Am4ShQje&uI9pqL91_qGZxfvK3co`TNm>3{#2YC_124NT*qz}Xf
zsRii;v3a287%($1z}Obd5VJsG0aC*Uwa0-OWG^d&1W1s985~9+*NB1nEDYjcyBQfk
z0nEg}#2^B84-TU)GE@nu03Nd6d
zFfi~jFoGci10Php02KKQd|;n~%oAc}U{GUVV6bFjX5eGU6JTawV&Gwd@T3?78S(^J
zKq8EJ3TzAl3|tJB3=nm!AW$Dsd$;Q`$S^YS*;FtnF*5Mk+Sq`@0F=f-7!(sAnuUSGCOIcFFSEpsfz1X&
zGx{*d_$20~6y;~8=qDBw==&v>WR|5mCYNNEWtLQ0Gf2Q?lk@XRQu9jmJ;9VUgCJZc
zFSSHJv?$Y>K^QKSU##y`nwOH3YRw?zla*MOsGpOVm#*)hl$DxXf+`kVQk0pOZp|R)
zQ<|-xmzbB5sb8FvnwqU23?dOO5kleXfed9}3}avl^I>3&Vql8$Vc<PHSRa#om63&xQgKG2uA!2ZQd(kiYD$`kVNz0xkwucJ
znQ@9?s%5HyWwL3SxlxLFvT1Unsij4VsgjOTS!z*nW`3TMm6C;?k)DxmN@|%>EdwJ1
z3loDUM@Iw88WzUJ29_f%j9hnESUD%K@UZM)Wn_843Mnr@SrC+tU^J|31*LCL83ig|
zL1_(Cu5d6y@*YS%sGJ35TTpf@U<8#3kh~2lZy6OJd{CLo3}u7zG@}5dJOq_nObU>4
U98@MVC_war%K-rh8&u8%0QZFPQ2+n{
diff --git a/android/src/auth.rs b/android/src/auth.rs
index 376ad98..c4f1b9e 100644
--- a/android/src/auth.rs
+++ b/android/src/auth.rs
@@ -46,7 +46,8 @@ fn default_true() -> bool {
}
fn default_recent_channels() -> Vec {
- vec!["#general".into()]
+ // Lobby + test room so guest / first-run channel lists are useful out of the box.
+ vec!["#general".into(), "#test".into()]
}
/// Disk-persisted app preferences (independent of auth session).
@@ -75,7 +76,7 @@ pub struct SavedPrefs {
#[serde(default)]
pub av_pref_speaker_id: Option,
/// Channels the user has visited / joined — auto-rejoined on connect.
- /// MRU order (most recent first). Default lobby is `#general`.
+ /// MRU order (most recent first). Defaults: `#general`, `#test`.
#[serde(default = "default_recent_channels")]
pub recent_channels: Vec,
}
@@ -672,18 +673,27 @@ mod tests {
assert!(!d.av_pref_muted);
assert!(!d.av_pref_speaker_muted);
assert!(d.av_pref_camera);
- assert_eq!(d.recent_channels, vec!["#general".to_string()]);
+ assert_eq!(
+ d.recent_channels,
+ vec!["#general".to_string(), "#test".to_string()]
+ );
// Missing camera field should default to true (join with camera ready).
let partial: SavedPrefs = serde_json::from_str(r#"{"av_pref_muted":true}"#).unwrap();
assert!(partial.av_pref_muted);
assert!(!partial.av_pref_speaker_muted);
assert!(partial.av_pref_camera);
- assert_eq!(partial.recent_channels, vec!["#general".to_string()]);
+ assert_eq!(
+ partial.recent_channels,
+ vec!["#general".to_string(), "#test".to_string()]
+ );
let empty: SavedPrefs = serde_json::from_str("{}").unwrap();
assert!(!empty.av_pref_muted);
assert!(!empty.av_pref_speaker_muted);
assert!(empty.av_pref_camera);
- assert_eq!(empty.recent_channels, vec!["#general".to_string()]);
+ assert_eq!(
+ empty.recent_channels,
+ vec!["#general".to_string(), "#test".to_string()]
+ );
// Explicit list round-trips (escape JSON so `#` is not inside a raw string).
let with_ch: SavedPrefs = serde_json::from_str(
"{\"av_pref_muted\":false,\"av_pref_camera\":true,\"recent_channels\":[\"#test\",\"#general\"]}",
diff --git a/android/src/state.rs b/android/src/state.rs
index 6153fa5..af78f41 100644
--- a/android/src/state.rs
+++ b/android/src/state.rs
@@ -549,6 +549,7 @@ pub fn display_emoji(emoji: &str) -> String {
/// Popular channels shown on Discover (mirrors freeq-android).
pub const POPULAR_CHANNELS: &[(&str, &str)] = &[
("#general", "General discussion"),
+ ("#test", "Test channel"),
("#freeq", "freeq development & support"),
("#dev", "Programming & technology"),
("#music", "Music recommendations"),
@@ -1197,12 +1198,19 @@ impl AppState {
}
/// Channels to auto-join after registration (client-side room memory).
+ /// Guests always get `#test` in the list so first-run / guest login has a
+ /// useful room even when prefs only remembered `#general`.
pub fn auto_join_channels(&self) -> Vec {
- if self.recent_channels.is_empty() {
- vec!["#general".into()]
+ let mut chans = if self.recent_channels.is_empty() {
+ vec!["#general".into(), "#test".into()]
} else {
self.recent_channels.clone()
+ };
+ let is_guest = self.did.is_none() && self.broker_token.is_none();
+ if is_guest && !chans.iter().any(|c| c.eq_ignore_ascii_case("#test")) {
+ chans.push("#test".into());
}
+ chans
}
/// Record a successfully visited channel and persist.
@@ -1239,7 +1247,7 @@ impl AppState {
self.recent_channels
.retain(|c| !c.eq_ignore_ascii_case(channel));
if self.recent_channels.len() != before {
- // Empty list is fine — `auto_join_channels` falls back to #general.
+ // Empty list is fine — `auto_join_channels` falls back to defaults.
self.persist_prefs();
}
}
@@ -1590,7 +1598,7 @@ impl AppState {
}
}
-/// Dedupe + normalize a loaded recent-channels list; ensure `#general` exists.
+/// Dedupe + normalize a loaded recent-channels list; ensure default lobby exists.
fn normalize_recent_channels(raw: Vec) -> Vec {
let mut out: Vec = Vec::new();
for ch in raw {
@@ -1605,6 +1613,7 @@ fn normalize_recent_channels(raw: Vec) -> Vec {
}
if out.is_empty() {
out.push("#general".into());
+ out.push("#test".into());
}
out
}
diff --git a/flake.nix b/flake.nix
index 1e2a2fb..95828e3 100644
--- a/flake.nix
+++ b/flake.nix
@@ -140,13 +140,17 @@
"clippy"
];
};
+ # aarch64 for phone APK; x86_64 for Waydroid (host container).
rustAndroid = pkgs.rust-bin.stable.latest.default.override {
extensions = [
"rust-src"
"rustfmt"
"clippy"
];
- targets = [ androidTarget ];
+ targets = [
+ androidTarget
+ "x86_64-linux-android"
+ ];
};
rustPlatform = pkgs.makeRustPlatform {
cargo = rust;
@@ -493,6 +497,104 @@ EOF
'';
};
+ # Iterative Waydroid deploy: flake toolchain + in-tree cargo-apk (x86_64)
+ # + adb install/launch. Opens full Android UI by default (portrait phone).
+ # Run from the sleek repo root (needs sibling ../../vidya + ../../freeq).
+ # Host `waydroid` CLI must be on PATH (system/NixOS package).
+ #
+ # Display defaults match a tall phone window; override via env when
+ # calling `nix run .#waydroid -- …`.
+ # `.#waydroid` = debug; `.#waydroid-release` = cargo apk --release.
+ waydroidDisplay = {
+ width = "1080";
+ height = "2400";
+ lcdDensity = "420";
+ };
+ mkWaydroidApp =
+ {
+ name,
+ release ? false,
+ }:
+ pkgs.writeShellApplication {
+ inherit name;
+ runtimeInputs = [
+ rustAndroid
+ pkgs.cargo-apk
+ pkgs.android-tools
+ pkgs.jdk17_headless
+ pkgs.python3
+ pkgs.findutils
+ pkgs.gawk
+ pkgs.gnugrep
+ pkgs.gnused
+ pkgs.coreutils
+ pkgs.bash
+ pkgs.procps
+ ];
+ text = ''
+ set -euo pipefail
+
+ # Hermetic SDK/NDK from the flake (override with env if you prefer host tools).
+ export ANDROID_HOME="''${ANDROID_HOME:-${androidSdkRoot}}"
+ export ANDROID_SDK_ROOT="''${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
+ export ANDROID_NDK_HOME="''${ANDROID_NDK_HOME:-${androidSdkRoot}/ndk-bundle}"
+ export ANDROID_NDK_ROOT="''${ANDROID_NDK_ROOT:-$ANDROID_NDK_HOME}"
+ if [[ ! -d "$ANDROID_NDK_HOME" ]]; then
+ ndk="$(echo "$ANDROID_HOME"/ndk/* | awk '{print $1}')"
+ if [[ -n "''${ndk:-}" && -d "$ndk" ]]; then
+ export ANDROID_NDK_HOME="$ndk"
+ export ANDROID_NDK_ROOT="$ndk"
+ fi
+ fi
+
+ # Waydroid window / density (portrait phone). Env overrides win.
+ export SLEEK_WAYDROID_WIDTH="''${SLEEK_WAYDROID_WIDTH:-${waydroidDisplay.width}}"
+ export SLEEK_WAYDROID_HEIGHT="''${SLEEK_WAYDROID_HEIGHT:-${waydroidDisplay.height}}"
+ export SLEEK_WAYDROID_LCD_DENSITY="''${SLEEK_WAYDROID_LCD_DENSITY:-${waydroidDisplay.lcdDensity}}"
+ # 1 = open `waydroid show-full-ui` before launch (needed to see the app).
+ export SLEEK_WAYDROID_SHOW_UI="''${SLEEK_WAYDROID_SHOW_UI:-1}"
+ # 1 = try `waydroid session start` if session is stopped.
+ export SLEEK_WAYDROID_START_SESSION="''${SLEEK_WAYDROID_START_SESSION:-1}"
+ # Release profile (optimized + signed). Env can still force either way.
+ export SLEEK_WAYDROID_RELEASE="''${SLEEK_WAYDROID_RELEASE:-${if release then "1" else "0"}}"
+
+ # Prefer live working-tree script (edits without re-eval); else store copy.
+ script=""
+ if [[ -f ./scripts/waydroid.sh ]]; then
+ script=./scripts/waydroid.sh
+ elif [[ -f ./android/Cargo.toml && -f ./scripts/waydroid.sh ]]; then
+ script=./scripts/waydroid.sh
+ else
+ d="$PWD"
+ for _ in 1 2 3 4 5; do
+ if [[ -f "$d/android/Cargo.toml" && -f "$d/scripts/waydroid.sh" ]]; then
+ script="$d/scripts/waydroid.sh"
+ break
+ fi
+ d="$(dirname "$d")"
+ done
+ fi
+ if [[ -z "''${script:-}" ]]; then
+ script="${./scripts/waydroid.sh}"
+ if [[ ! -f ./android/Cargo.toml ]]; then
+ echo "error: run from the sleek repo root (need android/Cargo.toml + path deps)" >&2
+ echo " cd /path/to/sleek && nix run .#${name}" >&2
+ exit 1
+ fi
+ fi
+
+ exec bash "$script" "$@"
+ '';
+ };
+ run-waydroid = mkWaydroidApp {
+ name = "waydroid";
+ release = false;
+ };
+ run-waydroid-release = mkWaydroidApp {
+ name = "waydroid-release";
+ release = true;
+ };
+
# Iterative desktop host: flake toolchain + in-tree `cargo run`.
# Unlike .#default / `nix run` (pure Nix store binary), this builds and
# runs against the working tree so incremental cargo caches apply.
@@ -574,6 +676,10 @@ EOF
inherit sleek-android;
inherit install-android;
inherit deploy-android;
+ waydroid = run-waydroid;
+ inherit run-waydroid;
+ waydroid-release = run-waydroid-release;
+ inherit run-waydroid-release;
inherit run-host;
}
);
@@ -596,6 +702,16 @@ EOF
type = "app";
program = "${self.packages.${system}.deploy-android}/bin/deploy-android";
};
+ # In-tree cargo-apk (x86_64) → Waydroid adb install + launch (debug).
+ waydroid = {
+ type = "app";
+ program = "${self.packages.${system}.waydroid}/bin/waydroid";
+ };
+ # Same as .#waydroid but cargo apk --release (signed local keystore).
+ waydroid-release = {
+ type = "app";
+ program = "${self.packages.${system}.waydroid-release}/bin/waydroid-release";
+ };
});
devShells = forAllSystems (
@@ -679,7 +795,7 @@ EOF
export BINDGEN_EXTRA_CLANG_ARGS="${(v4l2BindgenEnv pkgs).BINDGEN_EXTRA_CLANG_ARGS}"
export V4L2R_VIDEODEV2_H_PATH="${(v4l2BindgenEnv pkgs).V4L2R_VIDEODEV2_H_PATH}"
if [[ -z "''${SLEEK_QUIET_SHELL:-}" ]]; then
- echo "sleek — nix run .#host | just host | just waydroid | nix run .#deploy-android | nix build .#android"
+ echo "sleek — nix run .#host | nix run .#waydroid | nix run .#waydroid-release | nix run .#deploy-android | nix build .#android"
fi
'';
}
diff --git a/justfile b/justfile
index 9df6ff7..631a18a 100644
--- a/justfile
+++ b/justfile
@@ -56,17 +56,25 @@ host *args:
lib:
cargo build --manifest-path android/Cargo.toml --lib
-# APK in android/ → Waydroid (x86_64)
-waydroid:
- ./scripts/waydroid.sh run
+# APK in android/ → Waydroid (x86_64) via flake (Rust + NDK + adb)
+# just waydroid # debug
+# just waydroid build
+# just waydroid launch
+# just waydroid-release # release (optimized + signed)
+# just waydroid-release build
+waydroid *args:
+ nix run .#waydroid -- {{args}}
+
+waydroid-release *args:
+ nix run .#waydroid-release -- {{args}}
run: waydroid
install:
- ./scripts/waydroid.sh install
+ nix run .#waydroid -- install
launch:
- ./scripts/waydroid.sh launch
+ nix run .#waydroid -- launch
# Phone APK (aarch64) via flake — auto-pushes to Cachix when auth is present
# Opt out: SLEEK_CACHIX_PUSH=0 just android
diff --git a/scripts/deploy-android.sh b/scripts/deploy-android.sh
index 4ab561b..a141f27 100755
--- a/scripts/deploy-android.sh
+++ b/scripts/deploy-android.sh
@@ -303,8 +303,16 @@ build_apk() {
if [[ "$CLEAN" -eq 1 ]]; then
cargo clean -p sleek --target "$TARGET" >&2 2>/dev/null || true
fi
- cargo apk "${apk_args[@]}" >&2
- )
+ # Explicit status check: some shells/contexts with command-substitution
+ # callers have been observed to continue after a failed cargo-apk.
+ cargo apk "${apk_args[@]}" >&2 || {
+ echo "error: cargo apk failed" >&2
+ exit 1
+ }
+ ) || {
+ echo "error: APK build failed — not installing a stale package" >&2
+ exit 1
+ }
local apk
apk="$(find_apk "$profile")" || {
diff --git a/scripts/waydroid.sh b/scripts/waydroid.sh
index 4cc878c..9e86900 100755
--- a/scripts/waydroid.sh
+++ b/scripts/waydroid.sh
@@ -1,6 +1,26 @@
#!/usr/bin/env bash
-# In-tree APK build + Waydroid install/launch (mirrors vidya/scripts/waydroid-demo.sh).
+# In-tree cargo-apk (x86_64) + Waydroid adb install/launch.
+#
+# Prefer the flake app (brings Rust + NDK + adb + display config):
+# nix run .#waydroid # debug (default)
+# nix run .#waydroid-release # release (optimized + signed)
+# nix run .#waydroid -- build
+# nix run .#waydroid -- install
+# nix run .#waydroid -- launch
+# nix run .#waydroid -- --release run
+#
+# Display (set by flake app; override with env):
+# SLEEK_WAYDROID_WIDTH=1080
+# SLEEK_WAYDROID_HEIGHT=2400
+# SLEEK_WAYDROID_LCD_DENSITY=420
+# SLEEK_WAYDROID_SHOW_UI=1 # open show-full-ui window
+# SLEEK_WAYDROID_START_SESSION=1 # start session if stopped
+# SLEEK_WAYDROID_RELEASE=1 # cargo apk --release (or use --release)
+#
+# Or from an existing flake shell:
+# ./scripts/waydroid.sh
# just waydroid
+# just waydroid-release
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
@@ -8,15 +28,27 @@ APP="$ROOT/android"
PKG="uk.nandi.sleek"
# SleekActivity subclasses NativeActivity (classes.dex injected post cargo-apk).
ACTIVITY="$PKG/uk.nandi.sleek.SleekActivity"
+TARGET="${SLEEK_ANDROID_TARGET:-x86_64-linux-android}"
+API_LEVEL="${SLEEK_ANDROID_API:-28}"
-export ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-$HOME/.local/share/android-ndk-r29}"
-export ANDROID_NDK_ROOT="$ANDROID_NDK_HOME"
-export ANDROID_HOME="${ANDROID_HOME:-$HOME/.local/share/android-sdk}"
-unset ANDROID_SDK_ROOT 2>/dev/null || true
+# Portrait phone defaults (flake app re-exports these; keep in sync with flake.nix).
+SLEEK_WAYDROID_WIDTH="${SLEEK_WAYDROID_WIDTH:-1080}"
+SLEEK_WAYDROID_HEIGHT="${SLEEK_WAYDROID_HEIGHT:-2400}"
+SLEEK_WAYDROID_LCD_DENSITY="${SLEEK_WAYDROID_LCD_DENSITY:-420}"
+SLEEK_WAYDROID_SHOW_UI="${SLEEK_WAYDROID_SHOW_UI:-1}"
+SLEEK_WAYDROID_START_SESSION="${SLEEK_WAYDROID_START_SESSION:-1}"
+# 1 / true / yes → cargo apk --release (also: --release flag, or nix run .#waydroid-release).
+RELEASE=0
+case "${SLEEK_WAYDROID_RELEASE:-0}" in
+ 1 | true | yes | TRUE | YES) RELEASE=1 ;;
+esac
-need() { command -v "$1" >/dev/null || { echo "missing: $1" >&2; exit 1; }; }
+export ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-$HOME/.local/share/android-ndk-r29}}"
+export ANDROID_NDK_ROOT="$ANDROID_NDK_HOME"
+export ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/.local/share/android-sdk}}"
+export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
-export PATH="${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin:${ANDROID_HOME}/platform-tools:${HOME}/.cargo/bin:${PATH}"
+need() { command -v "$1" >/dev/null || { echo "missing: $1 (use: nix run .#waydroid or .#waydroid-release)" >&2; exit 1; }; }
need waydroid
need cargo
@@ -24,25 +56,118 @@ need adb
need cargo-apk
need rustc
-export CC_x86_64_linux_android="${CC_x86_64_linux_android:-x86_64-linux-android28-clang}"
-export CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER="${CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER:-$CC_x86_64_linux_android}"
-export AR_x86_64_linux_android="${AR_x86_64_linux_android:-llvm-ar}"
+# Prefer NDK llvm + platform-tools when present (host layout or flake app PATH).
+if [[ -d "$ANDROID_NDK_HOME/toolchains/llvm/prebuilt" ]]; then
+ prebuilt=""
+ for host in linux-x86_64 linux-aarch64; do
+ if [[ -d "$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/$host/bin" ]]; then
+ prebuilt="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/$host/bin"
+ break
+ fi
+ done
+ if [[ -n "$prebuilt" ]]; then
+ export PATH="$prebuilt:$PATH"
+ fi
+fi
+if [[ -d "$ANDROID_HOME/platform-tools" ]]; then
+ export PATH="$ANDROID_HOME/platform-tools:$PATH"
+fi
+
+triple_upper="$(echo "$TARGET" | tr '[:lower:]-' '[:upper:]_')"
+clang_bin="${TARGET}${API_LEVEL}-clang"
+export "CC_${TARGET//-/_}=$clang_bin"
+export "CARGO_TARGET_${triple_upper}_LINKER=$clang_bin"
+export "AR_${TARGET//-/_}=llvm-ar"
+export "CARGO_TARGET_${triple_upper}_AR=llvm-ar"
-if ! rustc --print sysroot --target x86_64-linux-android >/dev/null 2>&1; then
- echo "error: rustc missing x86_64-linux-android" >&2
- echo " rustup target add x86_64-linux-android" >&2
+if ! rustc --print sysroot --target "$TARGET" >/dev/null 2>&1; then
+ echo "error: rustc missing target $TARGET" >&2
+ echo " rustup target add $TARGET # or use: nix run .#waydroid" >&2
exit 1
fi
+[[ -f "$APP/Cargo.toml" ]] || {
+ echo "error: android crate not found at $APP" >&2
+ echo " run from the sleek repo (or via nix run .#waydroid from the repo root)" >&2
+ exit 1
+}
+
waydroid_ip() {
waydroid status 2>/dev/null | awk -F': *' '/IP address/{gsub(/[[:space:]]/,"",$2); print $2; exit}'
}
-ensure_adb() {
- waydroid status | grep -q 'Session:.*RUNNING' || {
+session_running() {
+ waydroid status 2>/dev/null | grep -q 'Session:.*RUNNING'
+}
+
+# Apply portrait phone window size / density via waydroid props (idempotent).
+apply_waydroid_config() {
+ local w h d cur
+ w="$SLEEK_WAYDROID_WIDTH"
+ h="$SLEEK_WAYDROID_HEIGHT"
+ d="$SLEEK_WAYDROID_LCD_DENSITY"
+
+ echo "waydroid display: ${w}x${h} density=${d}" >&2
+
+ # prop set only works with a running container/session.
+ if ! session_running && [[ "${SLEEK_WAYDROID_START_SESSION}" != "1" ]]; then
+ echo "note: session not RUNNING — skip prop set (start session first)" >&2
+ return 0
+ fi
+
+ set_prop_if_needed() {
+ local key="$1" want="$2"
+ cur="$(waydroid prop get "$key" 2>/dev/null | tr -d '[:space:]' || true)"
+ if [[ "$cur" == "$want" ]]; then
+ return 0
+ fi
+ echo " prop set $key=$want (was: ${cur:-unset})" >&2
+ waydroid prop set "$key" "$want" >/dev/null 2>&1 || {
+ echo " warning: failed to set $key (needs running container)" >&2
+ }
+ }
+
+ set_prop_if_needed persist.waydroid.width "$w"
+ set_prop_if_needed persist.waydroid.height "$h"
+ set_prop_if_needed persist.waydroid.lcd_density "$d"
+ set_prop_if_needed ro.sf.lcd_density "$d"
+}
+
+ensure_session() {
+ if session_running; then
+ return 0
+ fi
+ if [[ "${SLEEK_WAYDROID_START_SESSION}" != "1" ]]; then
echo "Waydroid session not RUNNING" >&2
+ echo " start with: waydroid session start" >&2
+ echo " or: SLEEK_WAYDROID_START_SESSION=1 nix run .#waydroid" >&2
exit 1
- }
+ fi
+ echo "starting Waydroid session…" >&2
+ # Needs a Wayland/X display for the user session.
+ if [[ -z "${WAYLAND_DISPLAY:-}" && -z "${DISPLAY:-}" ]]; then
+ if [[ -n "${XDG_RUNTIME_DIR:-}" && -S "${XDG_RUNTIME_DIR}/wayland-0" ]]; then
+ export WAYLAND_DISPLAY=wayland-0
+ elif [[ -S /tmp/.X11-unix/X0 ]]; then
+ export DISPLAY=:0
+ fi
+ fi
+ waydroid session start >/dev/null 2>&1 &
+ local i
+ for i in $(seq 1 60); do
+ if session_running; then
+ echo "Waydroid session RUNNING" >&2
+ return 0
+ fi
+ sleep 0.5
+ done
+ echo "error: Waydroid session failed to start" >&2
+ waydroid status >&2 || true
+ exit 1
+}
+
+ensure_adb() {
+ ensure_session
mkdir -p "$HOME/.android" "$HOME/.local/share/waydroid/data/misc/adb"
[[ -f "$HOME/.android/adbkey" ]] || adb keygen "$HOME/.android/adbkey"
cp "$HOME/.android/adbkey.pub" "$HOME/.local/share/waydroid/data/misc/adb/adb_keys" 2>/dev/null || true
@@ -50,6 +175,7 @@ ensure_adb() {
ip="$(waydroid_ip)"
ip="${ip:-192.168.240.112}"
waydroid adb connect >/dev/null 2>&1 || adb connect "${ip}:5555" >/dev/null 2>&1 || true
+ local _
for _ in $(seq 1 40); do
if adb devices 2>/dev/null | tr -d '\r' | grep -qE "${ip}:5555[[:space:]]+device"; then
export ANDROID_SERIAL="${ip}:5555"
@@ -60,41 +186,175 @@ ensure_adb() {
sleep 0.5
done
echo "ADB not ready for ${ip}:5555" >&2
+ adb devices -l >&2 || true
exit 1
}
+# Freeform `waydroid app launch` often flashes then closes on wlroots/Niri.
+# Keep the full Android UI window open, then start the activity inside it.
+ensure_full_ui() {
+ if [[ "${SLEEK_WAYDROID_SHOW_UI}" != "1" ]]; then
+ return 0
+ fi
+ if pgrep -a waydroid 2>/dev/null | grep -qE 'show-full-ui|first-launch'; then
+ return 0
+ fi
+ echo "Opening Waydroid full UI (${SLEEK_WAYDROID_WIDTH}x${SLEEK_WAYDROID_HEIGHT})…" >&2
+ # Detach so nix run / just does not block on the window.
+ nohup waydroid show-full-ui >/dev/null 2>&1 &
+ local _
+ for _ in $(seq 1 30); do
+ if pgrep -a waydroid 2>/dev/null | grep -qE 'show-full-ui|first-launch'; then
+ sleep 0.5
+ return 0
+ fi
+ # Fallback: window may already be composing without matching the pgrep string.
+ if adb shell dumpsys window 2>/dev/null | grep -q 'mCurrentFocus'; then
+ sleep 0.5
+ return 0
+ fi
+ sleep 0.3
+ done
+ echo "warning: show-full-ui may not have appeared — check compositor / WAYLAND_DISPLAY" >&2
+}
+
+# cargo-apk stages NDK shared libs as mode 0555; a later rebuild then fails with
+# "Permission denied (os error 13)" when overwriting libc++_shared.so etc.
+writable_apk_stage() {
+ local profile="$1"
+ local stage
+ for stage in \
+ "$APP/target/${profile}/apk" \
+ "$APP/target/apk" \
+ "$APP/target"; do
+ if [[ -d "$stage" ]]; then
+ chmod -R u+w "$stage" 2>/dev/null || true
+ fi
+ done
+ rm -f "$APP/target/${profile}/apk/"*-unaligned.apk 2>/dev/null || true
+}
+
+find_apk() {
+ local profile="$1"
+ local apk=""
+ local dir="$APP/target/${profile}/apk"
+ for cand in \
+ "$dir/sleek.apk" \
+ "$APP/target/sleek.apk" \
+ "$dir/sleek-${profile}.apk"; do
+ if [[ -f "$cand" ]]; then
+ echo "$cand"
+ return 0
+ fi
+ done
+ apk="$(find "$APP/target" -type f -path "*/${profile}/apk/*.apk" ! -name '*-unaligned.apk' 2>/dev/null | head -1 || true)"
+ if [[ -z "${apk:-}" ]]; then
+ apk="$(find "$APP/target" -type f \( -name 'sleek.apk' -o -name "*-${profile}.apk" \) ! -name '*-unaligned.apk' 2>/dev/null | head -1 || true)"
+ fi
+ if [[ -z "${apk:-}" ]]; then
+ apk="$(find "$APP/target" -type f -name '*.apk' ! -name '*-unaligned.apk' 2>/dev/null | head -1 || true)"
+ fi
+ [[ -n "${apk:-}" && -f "$apk" ]] || return 1
+ echo "$apk"
+}
+
+ensure_release_signing() {
+ mkdir -p "$HOME/.android"
+ local keystore="$HOME/.android/sleek-release.keystore"
+ if [[ ! -f "$keystore" ]]; then
+ need keytool
+ echo "generating release keystore at $keystore" >&2
+ keytool -genkeypair -v \
+ -keystore "$keystore" \
+ -storepass android \
+ -alias androiddebugkey \
+ -keypass android \
+ -keyalg RSA -keysize 2048 -validity 10000 \
+ -dname "CN=Sleek Local,O=Sleek,C=US" >&2
+ fi
+ export SLEEK_KEYSTORE="$keystore"
+ export SLEEK_KEYSTORE_PASSWORD=android
+ export SLEEK_KEY_ALIAS=androiddebugkey
+ export SLEEK_KEY_PASSWORD=android
+
+ # cargo-apk reads [package.metadata.android.signing.release] from Cargo.toml.
+ # Inject ephemerally if missing (do not commit).
+ if ! grep -q 'signing.release' "$APP/Cargo.toml"; then
+ cat >>"$APP/Cargo.toml" <&2
+ fi
+}
+
build_apk() {
[[ -d "$ANDROID_NDK_HOME" ]] || {
- echo "Set ANDROID_NDK_HOME=$ANDROID_NDK_HOME" >&2
+ echo "Set ANDROID_NDK_HOME (got: $ANDROID_NDK_HOME)" >&2
+ echo " flake app sets this from androidenv; or install an NDK under ~/.local/share" >&2
+ exit 1
+ }
+ [[ -d "$ANDROID_HOME" ]] || {
+ echo "Set ANDROID_HOME (got: $ANDROID_HOME)" >&2
exit 1
}
- echo "cargo apk (in-tree) → $APP" >&2
- # cargo-apk stages NDK libs as mode 0555; rebuilds fail with Permission denied otherwise.
- if [[ -d "$APP/target/debug/apk" ]]; then
- chmod -R u+w "$APP/target/debug/apk" 2>/dev/null || true
+ local profile=debug
+ local -a apk_args=(build --target "$TARGET" -p sleek --lib)
+ if [[ "$RELEASE" -eq 1 ]]; then
+ profile=release
+ apk_args+=(--release)
+ # cargo-apk --release needs signing metadata.
+ ensure_release_signing
fi
- rm -f "$APP/target/debug/apk/"*-unaligned.apk 2>/dev/null || true
+
+ echo "cargo apk ${apk_args[*]} (in-tree → $APP)" >&2
+ echo " ANDROID_HOME=$ANDROID_HOME" >&2
+ echo " ANDROID_NDK_HOME=$ANDROID_NDK_HOME" >&2
+ echo " target=$TARGET profile=$profile" >&2
+
+ writable_apk_stage "$profile"
+
(
cd "$APP"
- cargo clean -p sleek --target x86_64-linux-android >&2 2>/dev/null || true
- cargo apk build --target x86_64-linux-android >&2
- )
+ # Optional full clean: SLEEK_WAYDROID_CLEAN=1 nix run .#waydroid
+ if [[ "${SLEEK_WAYDROID_CLEAN:-0}" == "1" ]]; then
+ cargo clean -p sleek --target "$TARGET" >&2 2>/dev/null || true
+ fi
+ cargo apk "${apk_args[@]}" >&2 || {
+ echo "error: cargo apk failed" >&2
+ exit 1
+ }
+ ) || {
+ echo "error: APK build failed — not installing a stale package" >&2
+ exit 1
+ }
local apk
- # Prefer final sleek.apk; never install *-unaligned stubs.
- if [[ -f "$APP/target/debug/apk/sleek.apk" ]]; then
- apk="$APP/target/debug/apk/sleek.apk"
- else
- apk="$(find "$APP/target" -type f \( -name 'sleek.apk' -o -name '*-debug.apk' \) ! -name '*-unaligned.apk' 2>/dev/null | head -1)"
- fi
- if [[ -z "${apk:-}" ]]; then
- apk="$(find "$APP/target" -type f -name '*.apk' ! -name '*-unaligned.apk' 2>/dev/null | head -1)"
- fi
- [[ -n "${apk:-}" && -f "$apk" ]] || {
+ apk="$(find_apk "$profile")" || {
echo "APK not found under $APP/target" >&2
exit 1
}
+
+ # Guard against installing a stub without native libs.
+ if command -v python3 >/dev/null 2>&1; then
+ if ! python3 - "$apk" <<'PY'
+import sys, zipfile
+with zipfile.ZipFile(sys.argv[1]) as z:
+ ok = any(n.startswith("lib/") and n.endswith(".so") for n in z.namelist())
+sys.exit(0 if ok else 1)
+PY
+ then
+ echo "error: $apk has no native libs — cargo-apk packaging failed?" >&2
+ echo " try: SLEEK_WAYDROID_CLEAN=1 nix run .#waydroid" >&2
+ exit 1
+ fi
+ fi
+
# Inject uk.nandi.sleek.SleekActivity as classes.dex (freeq:// OAuth handler).
bash "$APP/scripts/inject-activity-dex.sh" "$apk" "$APP/src/assets/sleek_activity.dex"
echo "$apk"
@@ -103,24 +363,100 @@ build_apk() {
install_apk() {
local apk="$1"
ensure_adb
+ apply_waydroid_config
echo "install $apk" >&2
+ # Prefer waydroid app install (avoids adb incremental install failures).
+ if waydroid app install "$apk" >&2; then
+ echo "ok: installed $PKG (waydroid app install)" >&2
+ return 0
+ fi
+ echo "waydroid app install failed — falling back to adb install -r" >&2
adb install -r "$apk" >&2
+ echo "ok: installed $PKG" >&2
}
launch_app() {
ensure_adb
+ apply_waydroid_config
+ ensure_full_ui
echo "launch $ACTIVITY" >&2
+ adb shell am force-stop "$PKG" >/dev/null 2>&1 || true
adb shell am start -n "$ACTIVITY" >&2
+ sleep 0.8
+ local pid
+ pid="$(adb shell pidof "$PKG" 2>/dev/null | tr -d '\r' || true)"
+ if [[ -z "$pid" ]]; then
+ echo "warning: process not running — check: adb logcat | grep -i sleek" >&2
+ else
+ echo "Launched $PKG (pid $pid) on ${WAYDROID_IP:-waydroid}" >&2
+ fi
+ if [[ "${SLEEK_WAYDROID_SHOW_UI}" == "1" ]]; then
+ echo "Focus the Waydroid window if you do not see Sleek." >&2
+ fi
}
-cmd="${1:-run}"
+usage() {
+ cat <&2
+usage: $0 [options] [build|install|launch|run]
+
+ run build APK + install + launch + show-full-ui (default)
+ build cargo-apk only (print APK path)
+ install build + install
+ launch start activity + show-full-ui (no rebuild)
+
+options:
+ --release cargo apk build --release (default: debug)
+ -h, --help this help
+
+env (flake app sets defaults):
+ ANDROID_HOME / ANDROID_NDK_HOME SDK + NDK
+ SLEEK_ANDROID_TARGET default x86_64-linux-android
+ SLEEK_WAYDROID_WIDTH default 1080
+ SLEEK_WAYDROID_HEIGHT default 2400
+ SLEEK_WAYDROID_LCD_DENSITY default 420
+ SLEEK_WAYDROID_SHOW_UI=0|1 open show-full-ui (default 1)
+ SLEEK_WAYDROID_START_SESSION=0|1 start session if stopped (default 1)
+ SLEEK_WAYDROID_RELEASE=0|1 same as --release (waydroid-release sets 1)
+ SLEEK_WAYDROID_CLEAN=1 cargo clean before build
+
+flake apps:
+ nix run .#waydroid # debug iterate
+ nix run .#waydroid-release # release iterate
+EOF
+}
+
+cmd=run
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -h | --help)
+ usage
+ exit 0
+ ;;
+ --release)
+ RELEASE=1
+ shift
+ ;;
+ build | install | launch | run)
+ cmd="$1"
+ shift
+ ;;
+ *)
+ echo "unknown argument: $1" >&2
+ usage
+ exit 1
+ ;;
+ esac
+done
+
case "$cmd" in
build)
- build_apk
+ apk="$(build_apk)"
+ echo "$apk"
;;
install)
apk="$(build_apk)"
install_apk "$apk"
+ echo "$apk"
;;
launch)
launch_app
@@ -129,9 +465,10 @@ case "$cmd" in
apk="$(build_apk)"
install_apk "$apk"
launch_app
+ echo "$apk"
;;
*)
- echo "usage: $0 {build|install|launch|run}" >&2
+ usage
exit 1
;;
esac