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. + * + *

    + *
  1. {@code Settings.Secure.navigation_mode} (user choice, most reliable)
  2. + *
  3. Internal resource {@code config_navBarInteractionMode} (AOSP Q+)
  4. + *
  5. WindowInsets nav-bar bottom height (≥40dp buttons, <32dp gesture)
  6. + *
  7. System-gesture side insets — only when bottom is not button-sized
  8. + *
  9. Resource {@code navigation_bar_height} dimen as last resort
  10. + *
+ */ + 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 zcmYdEt>7{+wq!VP(zvd%d*a&g*>Of8r|#~%S1Y#q<~kqwEustz1q=)f6=5bq3{b!! z%D|Ar01=mDU|>*SVqlPEU|?urVqoB5U|@K_%)nsAz`)?Z!oa}Dz`*c+l4IcwTI3EK;79Rsc8y^G1Y(55t{d^1z_xKnX z{_rs{2=X&9Xz(*Ic=0nZr1LW{OyY;QVFy10!x?@ChBy2S4FCBV7{mk^7_6wHyAT7zaUlkV>p~0+FF~;+%)lTh z%)lTm%)p=_%)nqL%)sC;%)k&P%)pQ;%)n3}%)rnt%)l^9n1NxVFayJ3VFrft!VCN$25ANc1{nrO`jBH_V2}r;7X}6fMFs{24F(1V z9R>ylW@x$sSpu>Plx9FetPGI!!@y4L)j9%3=AAlwgN8$11FTN!OOtF z1!bG?GB9vM{Qxo(#0HrQV}s0vu|ej7*q}5FVuS1jsR!8s5(lwCaxgY1OkiwK*udDJ zFoLl`;RIuY!VAU*g%^ko3IkA>f!LbR@B`Va1!X(%GBCi{9!Tr}UIqqjsG0~~NH~JR z9b}F!G!7ELVa3Q`zyJy}Mus3L9S5be7(ijh$e_jm$+yPfIAvrA1ji*KgA$ZhhSDlf zS`|!-GQ=`~(gz2FHJIjNa0AnV41VA^6$R&gW(Em{C@^1~AsS3eF~l%1Ft9T4GT1PH z;+2WP4ovegIDlz(21hW>#NfgJieC{1Pbi(q0E$aC24`@3WMuFFr%53OcQDP!;0>n5 z7?Q#1RG7gEObamBf@w*HcrY!-kO-xdz-gJ0!5m75Lg`c}oeoat{0#PBnuoy&Omj1M zfoVntA1Lk1z`!8Pz`zg;rkTOCD1!`x1vp1{MYu1|=Q_hEEI(3{nhw3_>3*d6*ga89^nI zkaC^?D7EmgNU=sT6dLd`gfMckSg>#~7^$!^7@IINXfSCo^Rw`>a503iak1L6@-pNpa5IE3fb_937^$#;_3^W4F!3^pGBG#_u?Vq>uyI{iwh?7l zaLN;40-3`h#A(67&X6Y{!ob16!=b^%KEr~8jUi7!0L16C;LK%6;9y}8=8|PFS&_~F zwVfkRfDbgvD$L5Gw5+}Fdt6}2hfcl0U}9il zU}T8rft2+imoYK$FiA0(GDtD%fn8=Kz{PCK%no*$5d#+!KNBcsg_!t3l?k+rVPp_t zU}s=tU}9io$l+mNC<2RHvN3~e7*L$EG4L=7FJ7`PZUm>3x~n0Xl)8EqIX8KjsZ z7z!1%7}OZJm~5F0!RdvKfrl9sip&f=%t6Mi3{5H`te{i~3d>*yF6NUag#wxkYz$m% zqRa|Ty38!h*33c-Ldr&F%nTa5LTtKhENr%H+zg^@3{HA%ENs?ntPC1V3=E8lg$hax zj0{|C{A{39sKvm@z{p?(_B*Jq>*WEJhYSoL(-|4Kz##xq0g{`9BnPV1Ky?c%INX>R zX7Dh8%Q_(jOBO`9u@Dh%R!HF{%}~eys;Rl);Z_K)!MT`0;b_Fb!xSXQ%n+o+%mDI* zA}A!K^8^$aKw+9EAj81Kz{708EW`k1Gvx{JgXEats#$oMG*}T~&%q$YmudP25#l#34qFV9!4RdNFg?cJOxpZoB*i31JjWw0IK1+7_SL3u;(cVF$gg5 zF))MUhk@ZH4+FSvwPXUBohQH!3M(cEPl~~kAy0q@B*K`dpv)k^zy)TL7enjX{9Hf{_(!CLco=6C(p(l!{!dPo1Q^ zvUi8BceawW@}+5+?Mvk{+uP-$V!Y+smAu;z%gcEmR+d)wZrAnB%mj%^D>A%eWMW|8 z^KRFTl~!Ol$;imSXCp7~JuOo=vt3t?sX9_!T3o zt8H(W^Ul1=RHDPgCo8S5o5j$`$iSy-!=S~$$DqN$#}LfG=dCO6Z6oJxYwNA6;H_`N zkONX7@2x9;Sl+u`kHH*dwM_+sJ0k<1t&I(X9f+@I3vSQYdi2I+;hIfbBYPf&XdBrd|hVUwJbnU`5&$G~O-p&8v6nBCmm z7#KYnm^?ihm_0o~3?Bw&Pahu!7Ed1^PcZ4j!05xkaNm89mC=sQE`pw#00oU+s+YX%7nC7xiF)(r9( zGDWGy`XHU9Maikf)(nzx1?feJ1sR#i#rmFk#i=F5)(nDhsl3z@{m`OJYX&K}P=2w# zQ)y;Sib|MkP_U=JpEZLps$5=5PO3G75?rvLD8DQ-CACODIJKlCGcUbZB{(&?v?$e@ zK^Cs2v?Mb}-zBrSASbcXH?^cFGr1T!bjmVQ%VE;ar6u{LCCIjdWy3O4%dHti;UtS&(YYAdOpvHG?KLN#Df0#Prl66`#b){L+$u#G=I9VrvEg zpRB~PME#t^ymWov#F7ka1|cL7|D>$c{w}WZ?ykWhp+T;}45DzM5RV{N*LbJUkPv@A24RG7xIau#04^Bn z=k4bo?#I9$?BX5o=@-HvgkqB($O8@sUnv$Lxk`E#o*pl)~O7e3Vc#{(gN=l1TA<2+ICOJPZEi*knFR{!iu?Qqsl$czS znV;vIpOVVJo|2kZoLN%IAd-?=lA2uNmspmWo(Pv_P07qn&0}B(QvnqWyeW_b0!aZ3 zY^jB%i8;j#tZ7B5sf7%@>8T}-B_&0fNu?#J#SB8}sU>LHg@HXiwZtW{B$0s!#7Irf zF9M|+22l_XWL0ryeqKOkMQToPW>qSK09Y8DB%wA6B1E8x6l|+!N@`w7W?E)y5d&v> zY6&D=F|dJ|B@A2;IyD_G4a&-3C%|(fgK&CkNl<=%2_(TnP2f*YEeTFcO9gX7@(aMG z2PbEw=B6@;f#fQSOHy;)Q;Q)14$8Y=zXXHDLo>rNi!+llb3g$s1y_z_gA*jm#Iec2 ztb-_LV9(4e0sDYGv)DDaprn#PG6$UQJ@cTs77|3hplD^uNlhzZV9rgfU=YcJM=3b| zi&IMiDj4`sM2nI6U;_jY@*uu{8iOc8Bt9vzC_W=KGd-h(fgedY7i2X@ex7qtYGO$$ z19yI&Uuroh#pRVSh~(!%JmQw0Tw3g$k(igBn!>1>cXO2FLrM__=92sZhB=INbuJdwN=b>y z*{OLcN>)lKsTE2(O3C@T1(`YE{G|&DEwDsVX}Xe*QbuC2ZgNIya&~cPZn2V8T4GLd zs*X}_W}a?hL8g+Gk%f*@aYmx9p^}wST4Hi)N}7pbQc{YMMUttRaf)H8WvYQ?vT2&R zQHpu8X>y{erA3OVl8#bYY7r<)DOo95=o#r5>87NXDb+HtGH|i5OEN;Br)oz7%K=fw z#s-!fqKr%*L>alRh%s_Z;9=z2Ai&J|M}UcQhA=DV7GXxtC8Bzae}tG=Hi$E_+!1GF zY>;4KVPaumVPyFa8@~b7*Rb{gY>bl$I-Ug@=LFULu(3|iSQThI2sGXaY6HN=JlUZ2 zKWN;OQ329U1C4z$L)oCdH6yqe4Iao~U{ZjLr-H^t85AIG3Q&KX70L$n%R$XiklR3G 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