AzNavRail

AzNavRail API Reference

This document serves as the technical reference for the AzNavRail library. The library is driven by a Kotlin DSL declared inside AzHostActivityLayout; the DSL block is re-applied on every recomposition, so items simply read your state and stay in sync. The @Az annotations in section 1 are sugar that generates exactly that DSL.


1. High-Inference API (@Az)

Declare the rail with annotations and let a KSP processor generate the graph. This is sugar over the DSL, not a replacement: the generated file is the same DSL you would have written by hand, so it is readable and debuggable, and anything the annotations cannot express goes in AzActivity.configureRail(), which the generated graph calls inside the same block.

Setup

plugins { id("com.google.devtools.ksp") }

dependencies {
    implementation("com.github.HereLiesAz.AzNavRail:aznavrail-annotations:<version>")
    ksp("com.github.HereLiesAz.AzNavRail:aznavrail-processor:<version>")
}

Usage

Annotate the Activity to configure the rail, and its functions and properties to declare items. The processor emits <ActivityName>AzGraph; point AzActivity.graph at it.

@Az(
    app = App(displayAppName = true, vibrate = true, startDestination = "home"),
    advanced = Advanced(enableRailDragging = true),
)
class MainActivity : AzActivity() {
    override val graph = MainActivityAzGraph

    var unread by mutableIntStateOf(2)
    var dark by mutableStateOf(true)

    @Az(rail = RailItem(id = "home", text = "Home", route = "home", badgeProperty = "unread"))
    fun goHome() { unread = 0 }

    @Az(toggle = Toggle(id = "theme", toggleOnText = "Dark", toggleOffText = "Light", isCheckedProperty = "dark"))
    fun toggleTheme() = Unit

    override fun azGraphDestinations(builder: NavGraphBuilder) {
        builder.composable("home") { HomeScreen() }
    }
}

A working example lives in SampleApp/src/main/java/com/hereliesaz/SampleApp/AzGraphDemoActivity.kt.

Members of @Az

Each is “declared” when its id is non-blank (or, for Divider, when enabled is true), so one @Az carries exactly the one kind of thing you filled in.

Member Declares
app App(displayAppName, packRailButtons, vibrate, dockingSide, startDestination) — class level.
advanced Advanced(isLoadingProperty, helpEnabled, enableRailDragging, autoGuidanceEdges) — class level.
rail / menu RailItem / MenuItem — an item on the rail, or in the drawer only.
host / sub RailHost and the SubItems that name its id as their hostId.
toggle Toggle(isCheckedProperty) — the generated onClick flips that property.
cycler Cycler(options or optionsProperty, selectedOptionProperty) — the generated onClick advances it.
divider Divider(enabled = true).

Reactive bindings

The *Property parameters name a property on the Activity. The DSL block re-runs on every recomposition, so a mutableStateOf property keeps the item in sync with no further wiring:

On a function, the function is the item’s onClick. On a property, the property supplies the item’s text when no text/textProperty is given.

2. The Configuration Duality (Scope API)

When extending AzActivity, you may optionally override AzNavRailScope.configureRail() to inject dynamic parameters.

azConfig

Controls behavioral and geometrical mechanics.

fun azConfig(
    dockingSide: AzDockingSide = AzDockingSide.LEFT,
    packButtons: Boolean = false,
    noMenu: Boolean = false,
    vibrate: Boolean = false,
    displayAppName: Boolean = false,
    activeClassifiers: Set<String> = emptySet(),
    secondaryClassifiers: Set<String> = emptySet(),
    tertiaryClassifiers: Set<String> = emptySet(),
    usePhysicalDocking: Boolean = false,
    expandedWidth: Dp = 160.dp,
    collapsedWidth: Dp = 100.dp,
    railItemWidth: Dp = Dp.Unspecified,
    showFooter: Boolean = true,
    appRepositoryUrl: String = "",
    // Menu-drawer look-and-feel:
    dimBehindMenu: Boolean = false,
    dimBehindMenuAlpha: Float = 0.4f,
    menuItemAlignment: AzMenuItemAlignment = AzMenuItemAlignment.SIDE,
    justifyMenuItems: Boolean = true,
)

A hamburger drop-down menu is a standalone composable, AzDropdownMenu — not a rail mode. Its azConfig also takes inAppAbout = true and appRepositoryUrl = ""; because the dropdown has no onscreen area, “About” opens a full-screen in-app reader (inAppAbout = false reverts to a browser link). Same namespace derivation as the rail. See the README’s “AzDropdownMenu” section and docs/DSL.md.

azTheme

Controls the visual style of the rail.

fun azTheme(
    activeColor: Color = Color.Unspecified,
    defaultShape: AzButtonShape = AzButtonShape.CIRCLE,
    headerIconShape: AzHeaderIconShape = AzHeaderIconShape.CIRCLE,
    translucentBackground: Color = Color.Unspecified,
    helpLineColors: List<Color> = emptyList(),
    headerIconSize: Dp = Dp.Unspecified,
    focusColor: Color = Color.Unspecified,
    secondaryColor: Color = Color.Unspecified
)

azKinetics

Configures the WP7-style kinetic typography (entrance/exit on the expanded menu items, press-tilt, and the big screen title’s sweep). Defaults animate; pass AzEntrance.None/AzExit.None to opt out. In FAB/floating mode the cascade becomes a vertical up/down slide.

fun azKinetics(
    itemEntrance: AzEntrance = AzEntrance.Turnstile,
    itemExit: AzExit = AzExit.Turnstile,
    itemTextStyle: TextStyle? = null,
    entranceStaggerMs: Int = AzMotion.ItemStaggerMs,   // 22ms — items overlap heavily
    entranceDurationMs: Int = AzMotion.ItemDurationMs, // 280ms per item
    entranceEasing: Easing = AzEasing.Wp7Decelerate,
    entranceStartAngle: Float = 90f, // pure edge-on → flat, no fade, no slide
    tiltOnPress: Boolean = false,
    maxTiltDegrees: Float = 10f,
    titleEntrance: AzEntrance = AzEntrance.Turnstile,
    titleTextStyle: TextStyle? = null
)

The motion scale (AzMotion)

Every transition in the library reads its timing from one object, AzMotion. Nothing hard-codes a duration any more.

Constant Value What it times
ItemDurationMs 280 ms One item’s own entrance or exit
ItemStaggerMs 22 ms The gap between one item starting and the next, at CascadeReferenceCount items
CascadeReferenceCount 8 Every cascade is rescaled to ItemStaggerMs × 8 total, so 3 items fold as fast as 12
PanelDurationMs 200 ms A container arriving or leaving — panel, scrim, popup
SettleDurationMs 240 ms A layout settling into a new size
IndicatorStepMs 420 ms One step of a continuous indicator (AzLoad’s morph)

These replaced a 60 ms stagger and a 720 ms item duration that had been copy-pasted as literals into six places. At those values an eight-item drawer took 1.2 seconds to finish arriving, and because the turnstile entrance starts each item edge-on — and therefore invisible — the panel sat there empty for the first stretch of it. Motion is a guide, not a toll: it should say where a thing came from and then get out of the way.

The scale is proportional, so a host wanting a different tempo scales the whole thing through azKinetics(entranceDurationMs = …, entranceStaggerMs = …) rather than hunting literals. A unit test (AzMotionTest) holds the budget: a cascade of any length must settle inside 650 ms, the stagger must stay well under the duration it offsets, and a panel must never outlast its own contents.

azAbout

Configures the built-in About reader and the “More from Az” carousel.

fun azAbout(
    inAppAbout: Boolean = true,
    moreFromAzEnabled: Boolean = true,
    moreFromAzJsonUrl: String = "https://raw.githubusercontent.com/HereLiesAz/AzNavRail/main/more-from-az.json",
    moreRailItem: Boolean = false,
    aboutRailItem: Boolean = true,
    dedupeAbout: Boolean = true
)

azAboutRailItem

Declares the rail’s About (?) item explicitly, in the position you call it from — replacing the automatic one. The button persists; it is not fixed.

fun azAboutRailItem(
    id: String = "about",
    text: String = "?",
    content: Any? = null,
    color: Color? = null,
    shape: AzButtonShape? = null,
    menuText: String? = null,
    textColor: Color? = null,
    fillColor: Color? = null,
    info: String? = null,
    badge: String? = null,
    persistentBadge: Boolean = false,
    isLoading: Boolean = false
)

Tapping it opens the About reader; tapping it again closes it. So does tapping any other rail item, any menu item, or the app icon — plus drag-down, the close target, and system back. The reader is inset by the rail’s gutter, so the app icon stays tappable behind it even when the rail is folded up. React: <AzAboutRailItem id="about" /> and the aboutRailItem setting.

Guides hidden over footer screens (all platforms): while a footer screen (About or More from Az) is open, visible Help cards and any guidance callouts are hidden, and they return exactly where they were when the footer screen closes.

The reader is dark, in every theme. It is a full-screen surface the user has stepped aside into — long-form prose, a document list, a carousel — and long-form reading on a bright white field is the wrong call regardless of what the surrounding app is doing. It used to take MaterialTheme.colorScheme.surface, which in a light-themed host meant a screenful of white. The host’s accent still comes through on headings, links, and the close affordance, and a host-supplied translucentBackground still wins outright.

Three ways out, because a full-screen reader that can only be left through one 24 dp icon is a room with a keyhole for a door:


3. UI Components

Standalone components designed to be used inside your @Composable screens.

AzTextBox

A high-ground text input field with support for autocomplete, password masking, multiline text, and pre-filled initial values.

AzForm

A form builder component for managing and grouping multiple AzTextBox fields together. Form entries now strictly enforce and securely support initial pre-filled values (initialValue) across platform variants.

AzToggle

A standalone toggle switch matching the rail’s aesthetic.

AzSlider

The rail’s slider, drawn on Material 3 Expressive lines. One instrument in four shapes — they differ only in where the active track begins and how many thumbs ride it, which is why they are one composable and not four.

@Composable
fun AzSlider(
    value: Float = 0f,
    onValueChange: (Float) -> Unit = {},
    modifier: Modifier = Modifier,
    config: AzSliderConfig = AzSliderConfig(),
    color: Color = Color.Unspecified,
    trackColor: Color = Color.Unspecified,
    enabled: Boolean = true,
    rangeValue: ClosedFloatingPointRange<Float> = 0f..1f,
    onRangeChange: (ClosedFloatingPointRange<Float>) -> Unit = {},
    length: Dp? = null,
    label: String? = null,
)

It is drawn rather than composed out of Material’s own Slider because the rail has to render identically on Android and in Compose Multiplatform, and has to stand in an 80 dp-wide rail slot running vertically — which Material’s slider will not do.

On React the same control ships as <AzSlider> plus <AzRailSlider>, with AzSliderSizeMetrics carrying the size ladder and AzMotion (in AzNavRailDefaults) carrying the timings.

In the rail: azRailSlider(...) puts the same control in a rail item that unfolds in its own slot. Folded it is an ordinary rail button; tapped, the slot grows along the rail and the button becomes the track, with the value underneath as the way back. Nothing opens over the rail and nothing moves the user elsewhere — the control arrives where their attention already was. The rail forces the orientation vertical, because the rail is; everything else the host asked for is honoured.

AzButton

A circular or shaped button that automatically resizes text to fit.

AzWindow

The library’s floating window — the surface every panel it puts in front of the user is drawn in (AzPopup, the hidden menu), and available for your own panels.

@Composable
fun AzWindow(
    modifier: Modifier = Modifier,
    title: String = "",
    state: AzWindowState = rememberAzWindowState(),
    accent: Color = azAccent(),
    surfaceColor: Color = MaterialTheme.colorScheme.surface,
    movable: Boolean = true,
    minimizable: Boolean = true,
    onDismiss: (() -> Unit)? = null,
    content: @Composable () -> Unit,
)

Every window moves (drag its bar; it is clamped so it can never be pushed entirely off-screen) and folds to that bar (the fold control), keeping its position. onDismiss adds a close control; null draws none.


4. Low-Level API (Manual)

AzActivity

The base class for your Activity.

AzGraph (Generated)

The KSP processor generates this object. It casts the activity to your specific instance type to access the bound properties, ensuring Recomposition occurs whenever your mutableStateOf properties change.

Pages (Z-ordering)

onscreen(alignment, page = 0f) { } and background(weight, page = 0f) { } accept a page: Float. Pages add Z-ordering over standard Compose positioning:

Concept Behavior
Same page One co-planar layer; position items with normal Compose alignment (or Row/Column inside the content) so they tile without overlapping.
Different pages Stacked in Z and may overlap. Higher page → further back; lowest page is on top.
Decimals page = 1.5f inserts a layer between 1f and 2f without renumbering.
Two books background() pages form a book entirely beneath the onscreen book (which is beneath the rail/nav bar). onscreen pages respect safe zones; backgrounds fill the screen. weight breaks ties within a background page.
AzHostActivityLayout(pagesEnabled = true) Default. Forced when on — unlabelled items share page 0f. Set false to render in declaration order (backgrounds by weight) and ignore page.

Edge-to-edge & window insets (Android 15 / SDK 35)

AzHostActivityLayout owns this; nothing to opt into. See the Complete Guide §1.1 for the reasoning.

Concern Behavior
Enabling edge-to-edge The host calls enableEdgeToEdge() on the enclosing ComponentActivity, falling back to WindowCompat.setDecorFitsSystemWindows(window, false). Calling it yourself (as AzActivity does) is idempotent.
Safe-zone insets Each edge resolves to max(systemBars, displayCutout). The cutout matters on its own: under Android 15’s enforced LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS it can occlude an edge with no system bar — the landscape short edge, where the rail docks. ime is deliberately excluded so the layout doesn’t jump when the keyboard opens.
AzSafeZones Now (top, bottom, start, end). start/end are 0.dp in portrait and non-zero only where the system reports something to clear. Applied to the onscreen area, the title row, and the built-in About / Help / More-from-Az overlays. Read via LocalAzSafeZones.current.
Horizontal combination In the onscreen area, safeStart/safeEnd combine with the rail gutter by max, not addition — the rail hugs the same physical edge, so a rail wider than the inset already clears it. Content therefore keeps its current position unless the system inset exceeds the rail width.
AzHostFragmentLayout Gained trailing safeStart: Dp = 0.dp, safeEnd: Dp = 0.dp. Existing calls are unaffected.
Library-added windows The bottom-sheet overlay, AzNavBarDecorWindow, and AzNavRailWindowService.windowParams all set LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS — the only value Android 15 left non-deprecated. If you override windowParams, keep that assignment; leaving the field unset inherits the deprecated DEFAULT.
drawBehindNavBar Writes navigationBarColor / isNavigationBarContrastEnforced below API 35 only. Both are deprecated in API 35 and ignored for apps targeting SDK 35, where the bar is already transparent.

5. Status-Driven Guidance API

The guidance framework drives the user toward developer-declared goals by reactively surfacing the instruction needed to reach the next status along a shortest path. You describe the userflow as a graph of statuses (nodes) and edges (transitions carrying an instruction); the engine routes with BFS, auto-advances the instant a target status becomes true, re-routes when the user wanders, and renders every active goal’s instruction simultaneously as a callout adjacent to its control. See TUTORIAL_FRAMEWORK_PROPOSAL.md for the conceptual reference.

This section replaces the old scripted multi-scene Tutorial Framework API. See the migration note at the end of the section.


DSL — Android (Kotlin)

Declared inside the AzHostActivityLayout { ... } content lambda, which now returns an AzGuidanceController.

fun azStatus(id: String, predicate: () -> Boolean)
fun azEdge(from: String, to: String? = null, text: String, title: String? = null, highlightItemId: String? = null)
fun azGoal(id: String, target: String, label: String? = null, autoStartWhen: String? = null)

You only hand-author edges into custom statuses; the engine auto-generates edges for the rail’s own affordances (see Built-in statuses & auto-edges below).

DSL — React (TypeScript)

Declared as JSX children of the rail (under AzHostActivityLayout / AzNavRail).

<AzStatus id="cart_open" predicate={() => cart.isOpen} />
<AzEdge from="cart_open" to="az.screen.checkout" text="Tap Checkout" highlightItemId="checkout" />
<AzGoal id="checkout" target="az.screen.confirmation" label="Check out" autoStartWhen={null} />

to on <AzEdge> is optional (omit for a passive edge). The React package also exports AzGuidanceProvider, AzInstructionOverlay, useActiveStatuses, computeBuiltinStatuses, nextHop, routeInstructions, computeAutoEdges, and types AzGuideHighlight, AzCalloutSide, AzInstruction, AzGoalDef, AzEdgeDef, AzStatusPredicate, AzStatusProps, AzEdgeProps, AzGoalProps.


AzGuidanceController

Package com.hereliesaz.aznavrail.tutorial (Android); exported from @HereLiesAz/aznavrail-react.

Member Android type React type Description
enabled Boolean boolean Whether guidance is on.
activeGoals List<String> string[] Currently active goal ids.
completedGoals List<String> string[] Goal ids reached at least once (persisted).
enable() / disable() — — Turn guidance on/off.
activate(goalId) / deactivate(goalId) — — Activate / deactivate a goal.
markReached(goalId) — — Mark a goal reached (persists it as completed).
isCompleted(goalId) Boolean boolean Whether a goal has been completed.

Goal activation is developer-driven. There is no built-in end-user goal picker; use autoStartWhen for onboarding-style self-activation.

Obtaining the controller — Android:

// AzHostActivityLayout returns it:
val guidance = AzHostActivityLayout(navController = nav, currentDestination = route) { /* … */ }

// Or from the CompositionLocal under the host:
val guidance = LocalAzGuidanceController.current        // CompositionLocal<AzGuidanceController?>
val guidance = rememberAzGuidanceController()           // @Composable

Obtaining the controller — React:

const guidance = useAzGuidanceController();

Persistence: completed goals persist under key az_navrail_completed_goals — Android SharedPreferences file az_tutorial_prefs; React localStorage (and AsyncStorage on React Native).


Model types

Package com.hereliesaz.aznavrail.tutorial; React equivalents exported from @HereLiesAz/aznavrail-react.

Type Shape
AzGuideHighlight Sealed: None, FullScreen, Item(id), Area(left, top, width, height).
AzInstruction (text, title?, highlight, side, media?).
AzCalloutSide Auto / Above / Below / Start / End.
AzEdge (from, to?, instruction).
AzGoal (id, target, label?, autoStartWhen?).

Overlay rendering note. Where Android punches a true spotlight hole per target, React draws an accent ring around each target over a light dim (multi-hole masking isn’t portable across React Native primitives).


Built-in statuses & auto-edges

These az.* statuses are published automatically from live rail / host / route / help / onscreen state; reference them as edge or goal targets.

Status id True when
az.app.ready Always true (root; navigation auto-edges always have a reachable from).
az.rail.expanded The rail menu is expanded.
az.rail.collapsed The rail is collapsed (mutually exclusive with az.rail.expanded).
az.rail.floating The rail is in FAB / floating mode.
az.host.<id>.expanded Host item <id> is expanded.
az.screen.<route> The current route is <route>.
az.item.<id>.active Item <id> is the active/selected item.
az.nestedRail.<id>.open Nested rail <id> popup is open.
az.help.open The help overlay is open.
az.onscreen.<id>.visible On-screen element <id> is visible.

Auto-edges (you don’t author these): “Open the menu” (az.rail.collapsed → az.rail.expanded), tap a host item (→ az.host.<id>.expanded), tap a nested-rail item (→ az.nestedRail.<id>.open), tap a routed item (→ az.screen.<route>). Rail items are tappable from az.app.ready; menu-only items require az.rail.expanded. Instruction text is localizable on Android via az_guide_open_menu / az_guide_tap_item (defaults “Open the menu” / “Tap

Observation latency. Predicates that read Compose snapshot state (Android) or React state are observed instantly; predicates backed by a non-snapshot / non-React source (StateFlow.value, a plain var, a mutable ref, an external store) are observed within a ~300 ms poll.


Migration from the scripted tutorial framework

Old (removed) New
AzTutorial / AzScene / AzCard, advance conditions, branching, checklist/media cards, AzTutorialOverlay azStatus / azEdge / azGoal + the reactive engine
AzTutorialController.startTutorial / markTutorialRead AzGuidanceController.activate / markReached
azAdvanced(tutorials = …) removed — declare statuses/edges/goals in the host content lambda
Help-overlay “Start Tutorial” launch removed — guidance is developer-activated, never launched from help
LocalAzTutorialController / useAzTutorialController LocalAzGuidanceController / useAzGuidanceController
Persistence key az_navrail_read_tutorials az_navrail_completed_goals

Bottom Sheet

The bottom-sheet shell is ported from LogKitty so AzNavRail consumers get the same four-detent, accumulated-delta-drag sheet, and LogKitty itself can replace its hand-rolled version with AzBottomSheetWindowHost with no visual change.

Composables

Composable Description
AzBottomSheet(controller, modifier, config, onSwipeLeft?, onSwipeRight?) { content } In-tree sheet. Anchored at the bottom of its parent and spans full width. The HIDDEN strip stays visible (dimmed handle) and accepts a tap that steps up to PEEK in addition to swipe-up. Apply Modifier.windowInsetsPadding(WindowInsets.navigationBars) yourself if your body content needs to clear the system nav bar.
AzBottomSheetInsetAware(controller, config, onSwipeLeft?, onSwipeRight?) { content } Same as AzBottomSheet but the modifier already applies fillMaxSize() + windowInsetsPadding(navigationBars).
DSL form via AzNavHostScope.azBottomSheet Registered above rail/menu/onscreen with zIndex(2f), full-width, edge-to-edge (no windowInsetsPadding applied by the DSL).

DSL

DSL Scope Description
azBottomSheet(controller, config, onSwipeLeft?, onSwipeRight?) { content } AzNavHostScope Registers a sheet rendered above rail/menu/onscreen in AzHostActivityLayout with zIndex(2f), spans full screen width, extends to the bottom edge (no inset padding) so the HIDDEN strip is reachable from the system-nav-bar area.

State

Class Description
enum AzSheetDetent { HIDDEN, PEEK, HALF, FULL } Discrete heights.
AzSheetController(initial) State holder with dual mutableState + StateFlow channels.
rememberAzSheetController(initial) Composable factory backed by rememberSaveable.

AzSheetController properties / methods:

Member Type Notes
detent AzSheetDetent (mutable) Setter also pushes to detentFlow.
isEnabled Boolean (mutable) false forces HIDDEN and blocks step calls.
detentFlow StateFlow<AzSheetDetent> Read-only.
enabledFlow StateFlow<Boolean> Read-only.
stepUp() / stepDown() – One detent per call; clamps at the ends. The swipe-down gesture calls stepDown() (descends one detent at a time, mirroring swipe-up).
snapTo(target) – Direct jump; blocked when disabled and target ≠ HIDDEN.

Configuration

AzSheetConfig(...) — all fields optional, sensible defaults match the LogKitty look.

Field Type Default Purpose
backgroundColor Color Color.Unspecified (→ MaterialTheme.colorScheme.surface) Sheet fill.
backgroundAlpha Float 0.92f Alpha applied to fill.
scrimColor Color Color.Black Dim layer above sheet in HALF/FULL. At PEEK, a transparent (no-dim) tap overlay catches taps instead.
scrimAlpha Float 0.32f Scrim alpha.
hiddenStripDp Dp 28.dp Swipe-target height in HIDDEN. Bumped from 14dp so it’s reliably touchable on gesture-nav devices; the handle stays visible at this detent (dimmed).
peekDp Dp 56.dp Height in PEEK.
halfFraction Float 0.5f Fraction of parent height in HALF.
fullFraction Float 0.9f Fraction of parent height in FULL.
dragThresholdDp Dp 24.dp Cumulative drag needed per step. Swipe up steps one detent up; swipe down steps one detent down.
collapseOnBack Boolean true Back press steps down.
horizontalSwipeEnabled Boolean false Enables onSwipeLeft / onSwipeRight.
animateInTree Boolean true In-tree shell animates between heights; system-overlay always hard-jumps.
cornerRadiusDp Dp 16.dp Top-corner radius.
handleVisible Boolean true Centered drag-handle pill.
drawBehindNavBar Boolean false When true and the device uses button navigation, the sheet draws behind the system nav bar (exposed height unchanged) and the bar is forced see-through so content shows through. No-op in gesture navigation.

Automatic gesture-nav margin: independent of any config, AzHostActivityLayout imposes zero bottom margin on on-screen content when the device is in gesture navigation (detected via Settings.Secure navigation_mode); button-navigation devices keep the max(10% safe-zone, nav-bar inset) bottom margin.

System-overlay flavor

class AzBottomSheetWindowHost(context, controller, config, lifecycleOwner, viewModelStoreOwner, savedStateRegistryOwner, navBarHeightPx, content)

Method Description
attach() Adds the sheet’s TYPE_APPLICATION_OVERLAY window. Idempotent.
attachNavBarDecor() Adds the secondary TYPE_ACCESSIBILITY_OVERLAY that tints the system nav bar. Call after your accessibility service binds. No-op when navBarHeightPx <= 0.
detach() Removes both windows.
updateConfig(config) Replaces the live config and, while attached at HIDDEN/PEEK, immediately resizes the overlay window to the new hiddenStripDp/peekDp (HALF/FULL stay MATCH_PARENT).

The overlay window also delivers real window insets to its content: WindowInsets.navigationBars / Modifier.navigationBarsPadding() resolve to the actual system nav-bar inset inside the content slot (insets are forwarded un-consumed, so the app below still receives them).

Consumer manifest: declares SYSTEM_ALERT_WINDOW (and BIND_ACCESSIBILITY_SERVICE for the nav-bar decoration). The library itself ships no permissions or services.


6. Navigation Readiness

NavigationReadiness.kt exists in both aznavrail (Android) and aznavrail-cmp (commonMain). It guards against a cold-start race where the NavController is attached to AzHostActivityLayout one composition before AzNavHost installs its graph — calling navigate() in that window throws IllegalArgumentException: Navigation graph has not been set.

All of AzNavRail’s own route dispatchers call azNavigateWhenReady rather than navigate directly, so the race cannot crash a host application. Consumers wiring navigation calls outside the library can use the same extension.

// Extension on NavController — safe to call at any time:
fun NavController.azNavigateWhenReady(route: String)

// Drop any pending routes for this controller and remove the readiness listener.
// Call from a scope that replaces the held controller (e.g. on Activity recreation):
fun NavController.cancelPendingNavigation()

Semantics

Lifecycle

Call cancelPendingNavigation() on the old controller before replacing it (e.g. inside AzNavHostScopeImpl.setController). This drops the queue and detaches the listener so the old instance can be GC’d. AzNavHost already does this internally on controller replacement.


7. History / Autocomplete

AzTextBox stores per-context autocomplete history. The two library targets implement this differently due to platform constraints, but expose the same public surface.

Android — HistoryManager

object HistoryManager in aznavrail. Persists history to private files under Context.filesDir.

// Must be called before addEntry / getSuggestions:
fun HistoryManager.init(context: Context, suggestionLimit: Int = 5)

// Adjust the suggestion limit after init (also trims persisted files if the limit shrinks):
fun HistoryManager.updateSettings(suggestionLimit: Int)

// Record a submitted value (no-op if blank or limit is 0):
fun HistoryManager.addEntry(text: String, historyContext: String?)

// Ranked suggestions matching query (prefix-first, capped to suggestion limit):
suspend fun HistoryManager.getSuggestions(query: String, historyContext: String?): List<String>

init is idempotent — calling it again only updates the suggestion limit. The suggestion limit is clamped to 0..5.

CMP — HistoryStore

internal object HistoryStore in aznavrail-cmp. Persists history via multiplatform-settings backed by azCacheSettings. The public surface matches the Android sibling:

fun HistoryStore.updateSettings(suggestionLimit: Int)
suspend fun HistoryStore.addEntry(text: String, historyContext: String?)
suspend fun HistoryStore.getSuggestions(query: String, historyContext: String?): List<String>

Key difference: addEntry is suspend in the CMP target (no Context, no detached IO launch). A caller inside a composition scope controls the dispatcher, which guarantees that a getSuggestions call issued right after an addEntry sees the write — matching the Android sibling’s synchronous behavior without a GlobalScope race.

Injectable settings backend (test isolation)

Both implementations expose resetForTest/resetForTesting to inject a Settings backend, so unit tests never touch Context (Android) or azCacheSettings (CMP). Use com.russhwolf.settings.MapSettings as the in-memory substitute:

// CMP:
HistoryStore.resetForTest(backend = MapSettings())

// Android:
HistoryManager.resetForTesting()   // wipes in-memory state; context re-init required

Optional Play-only subscription gate

The independently published aznavrail-subscriptions module exposes AzSubscriptionGate (platform-agnostic Compose UI), AzSubscriptionUiState, AzSubscriptionOffer, AzSubscriptionAccess, and the Android-only AzPlaySubscriptionGate(activity, config, content) and AzPlaySubscriptionConfig. It has no effect on the normal rail/DSL/navigation APIs. See SUBSCRIPTIONS.md for the flavor-specific wiring, Play Console requirements, trial and localized pricing, restore, and security caveats.