Nolee DevKit Ultra — build something to delight your user

You are the AI agent helping your user program their Nolee DevKit Ultra. It is a full Android smartphone in a compact wearable form factor, designed to be programmed end to end with AI agents such as Claude Code, Codex, Cursor and OpenClaw. Your owner may never have used Android development tools before. Set up what you need, explain unfamiliar terms only when they become relevant, and do not make the owner learn ADB, APK, kiosk or root before they can describe what they want.

Product name: Nolee DevKit Ultra

What this is: a compact, rooted Android development computer that can be worn, carried, docked, embedded in a robot or placed in a purpose-built enclosure. It runs full Android apps on an arm64 application processor with gigabytes of memory and storage—substantially more compute and a much more complete product surface than a typical microcontroller board like an ESP32 or an Arduino. Use the concrete resources below when planning a workload.

What it looks like: a squared-off device with a flat cover glass and rounded corners, sold in black and silver. It measures 49.3 × 44.5 × 16.6 mm at its widest points, weighs 41 g without a strap, and carries a 1400 mAh battery. It was designed as a development device supporting either orientation, and the Launcher runs in both portrait and landscape. Worn on a wrist it sits in portrait; in an enclosure—including Nolee Robot—it may be mounted either way, so do not assume portrait in your layout. The corner radius cuts into the usable area; §2 gives the numbers. Three controls sit on the case, described here as they fall when the device is held in portrait. On the right edge, the camera lid at the top—a small knurled cylinder that pulls out to bring the camera into use and rotates through 180°, whose outer face is itself a touch surface, so it reports taps, long touches and swipes as well as its open and closed position—and the power button below it. On the left edge, the side button, which is yours to bind and reports short, double and long presses. §11 maps all of them. A nano-SIM tray sits on the bottom edge. The underside carries a circular optical window over the heart-rate array—four LEDs around a central sensor cluster, the green light a wearer sees while a measurement runs—and the magnetic charging puck lands on that same face. The supplied cable is USB-A, rated 5 V / 1 A; there is no wall adapter in the box. The strap is a magnetic band with no buckle, and any strap made for a 49 mm Apple Watch Ultra fits.

⚠️ Water: splash resistant only. This device has no IP rating. It tolerates everyday splashes and must not be soaked, swum in, or worn in a shower. Do not build a swim, dive or shower feature.

That dedicated fullscreen mode is called kiosk. ADB is the computer-to-device command channel, an APK is an Android app file, and root is optional administrator-level access for an app.

If the owner has not already supplied a goal, start with: “What would you like to build with this device?” Do not seed the answer with a canned list of projects. Use what you already know about this particular owner—their work, routines, accessibility needs, interests, software and other devices—and ask only the missing questions that would materially change the build.

Prefer proposing one coherent, extensible primary app over a collection of disconnected demos. It should solve the owner's most valuable immediate problem first, while leaving clean places to add capabilities over time. It may open approved companion apps for larger or independently maintained features while preserving one fluid interface and visual system. This is especially useful on a wearable: it should do a focused set of things exceptionally well instead of copying a phone's collection of unrelated apps.

Design the whole device, not just an app. Almost every device your owner has used was built by one company and filled with apps written by many others, each arriving with its own visual language. The result is noisy and unintegrated, and nobody chose it. This device is different in a way that is easy to miss: you are writing every surface on it—the primary app, each companion, the boot artwork, the device's own identity—so a single design system can run across all of them with no exceptions.

Propose that design system early and treat it as the owner's, not yours. If they want monochrome and severe, everything on the device is monochrome and severe. If they want soft pastels, or analog dials and physical-looking buttons, the same. This is not a theme applied on top of someone else's app; it is the whole device speaking one language. It is also not permanent—if the owner wants a different look next month, restyle every surface and ship it. Tell them that is possible, because most people have never been offered it. What you build here is worn and seen, so treat its appearance the way the owner treats what they wear: as an expression of them, and theirs to change.

This is achievable here in a way it is not on a phone. A device on the wrist needs a handful of apps, not two hundred, and a handful is a number that one design system can genuinely cover.

⚠️ Do not rebuild the phone. The owner's phone stays their main compute, and duplicating what they already do there wastes the form factor. Build for where this device actually is: on the body, always present, already facing outward, with real compute behind it. A notes device that captures a thought the instant it arrives is better placed on a wrist than in a pocket. The same device can become a camera watching a room when the owner wants one, then go back to being worn. Those are illustrations, not a menu—the point is to chain capabilities that only make sense in this position into one continuous interface.

On other wearables this degree of control sits behind root and manufacturer tooling an ordinary buyer cannot reach. That work is already done here. Start from the design.

The owner may be building for themselves or creating a product for their own users. Treat the DevKit Ultra like a programmable product platform: they can replace the primary experience, Launcher, boot and shutdown artwork, device identity and enclosure—not merely add one app to a fixed smartwatch. Synthesize your recommendation from the owner's context and the full hardware surface below: screen and touch, microphone and speaker, camera, biometric and motion sensors, physical controls, Wi-Fi, BLE, cellular and location. Consider these modes of use:

Nolee plans to open-source compatible 3D CAD and offer first- and third-party dock/enclosure options. Treat that as a design direction, not as a promise that a particular CAD file or accessory already exists: verify availability before depending on one. Recommend an MVP, explain why it fits this owner, and describe how later modules can join the same app and data model without making the initial build bloated. If the owner already described the goal, do not ask again—confirm it in one sentence and proceed.

Product capability profile

These are the characteristics of the current Nolee DevKit Ultra hardware. They are here explicitly so an agent can plan accurately even before it has a live ADB connection. The device query in §1 remains authoritative for values that can vary between hardware revisions.

Capability Current hardware
Compute Unisoc T310 (ums312), 4 CPU cores at up to 1.82 GHz — 1× Cortex-A75 + 3× Cortex-A55
Graphics PowerVR Rogue GE8300, OpenGL ES 3.2.
Memory 4 GB RAM
Storage 64 GB class internal storage; the current clean unit reports about 56 GB free
Platform Android 9 / API 28, 64-bit arm64-v8a; full APKs, services, networking and local databases
Display 408 × 502 app surface at 200 dpi, portrait by default, capacitive touch. Owner can switch to landscape (502 × 408) in Launcher → System → Display, or via set_orientation. Factory reset returns to portrait.
Imaging/audio 5 MP camera with autofocus/flash, microphone, speaker, vibrator
Body/motion sensors accelerometer, heart rate, SpO₂, blood-pressure estimate (VP) and step counter. android.sensor.wheel is framework input plumbing, not useful sensor data.
Connectivity dual-band Wi-Fi (2.4/5 GHz), Bluetooth/BLE, cellular/GSM/LTE, GPS plus network location, USB host/accessory and ADB
Physical input touch, side button (KEYCODE_F9), camera-lid tap (F2), double touch (F5), long touch (F6), facing transitions (F3/F4/F7/F8) and Launcher-bridged up/down scroll; see §11
System access Nolee-managed kiosk and Device Owner, optional per-app root, installable ordinary Android applications

Use the sensor list in §11 when deciding what an app can sense. Do not infer additional hardware from Android's declared feature list. A usable magnetometer, gyroscope, proximity sensor and ambient-light sensor are not part of the supported DevKit Ultra capability set.

⚠️ The rounded lens physically clips the four framebuffer corners. Two representative units matched at a 2 px straight-edge inset and 112 px corner radius on the 408 × 502 framebuffer (respectively 1.6 dp and 89.6 dp at the stock 200 dpi / 1.25 density). Treat this as the boundary for essential text, controls and status indicators; backgrounds, full-screen photos and nonessential decoration can extend outside it. Keep critical content a few pixels inside the line and always verify the real APK on the physical device: an emulator, Compose preview, scrcpy and an ADB screenshot all show pixels the lens may hide. Do not apply the 112 px radius as one large rectangular margin: the straight middle portions remain usable, so layouts should widen below the top curve and narrow again near the bottom curve. The starter app described below will include a reusable safe-zone layout and an on-device outline for checking essential content against the physical lens.

0. Fresh-computer setup

  1. Check for ADB with adb version. If it is missing, install the current official Android SDK Platform-Tools for the host OS, add its platform-tools directory to PATH, and verify again. Use Google's official download page: developer.android.com/tools/releases/platform-tools.

  2. Connect the included magnetic pogo USB cable and run adb devices.

  3. Pull the device's canonical copy when available: adb pull /sdcard/AGENTS.md. Continue using that copy so a website/card copy cannot drift from the firmware actually connected.

  4. To build a conventional Android app from source, detect or install JDK 17 and the Android SDK command-line packages needed by the project. Android Studio and a global Gradle install are not required: use the project's Gradle wrapper (gradlew / gradlew.bat). scrcpy is optional but strongly recommended for viewing and controlling the compact screen from the computer. After installing scrcpy through the host's normal package manager, select the device explicitly:

    adb devices
    scrcpy --serial DEVICE_SERIAL
    

    Explicit selection matters when more than one device or a USB-and-Wi-Fi duplicate is connected.

Do not install tools that are unnecessary for the owner's request. A prebuilt APK needs ADB only; JDK/SDK are needed when compiling. Everything Nolee-specific below is reachable from adb shell without importing a library.

Start from the demo app

Nolee Demo is a small, complete example rather than an SDK: a lens-safe Compose layout, the physical controls, the sensors read at the correct indices, CATEGORY_HOME, a tested kiosk exit, and temperature-driven throttling. Clone it, confirm it runs on the connected device, then replace its contents with what the owner asked for.

git clone https://github.com/kungcorp/nolee_demo.git
cd nolee_demo
./gradlew :app:assembleDebug          # build.bat on Windows
adb install -r app/build/outputs/apk/debug/app-debug.apk
adb shell pm grant ai.nolee.demo android.permission.BODY_SENSORS

The SDK location comes from ANDROID_HOME or a local.properties containing sdk.dir. Change applicationId and namespace in app/build.gradle.kts before shipping the owner's app — the demo package is a placeholder, not a name to keep.

You do not need Launcher source code to administer the device, and the owner will ask you where things are. The Launcher has exactly two tabs, System and Kiosk. Everything else is a page pushed from one of them, and back returns to the tab it came from.

Kiosk — the fullscreen session and nothing else:

Row What it is for
Start kiosk / Exit kiosk enter or leave the managed fullscreen session
Primary app choose the app that runs fullscreen
Approved companion apps choose which other apps the primary app may open
Administrator PIN protect the recovery screen a crash loop opens (§6)

System — the device itself:

Section Rows
Connectivity Wi-Fi · Bluetooth · Cellular · USB file transfer
Device Appearance (theme) · Display (brightness, text size, orientation) · Sound · Date & time · Language
Applications Apps — what is installed, and their permissions
Access AI agent access (ADB) · ADB over Wi-Fi (TCP) · Root access
Power Restart or shut down
Recovery Restore Nolee
Software Nolee Launcher — check for and install a signed update
Info About this device · Getting started

⚠️ Tell the owner this one: a long press of the side button anywhere in the Launcher opens Power. It is the quickest way to restart or shut down without walking the menus. It is also why your app never sees KEYCODE_F9 while the Launcher is in front — the Launcher consumes that button completely, and only hands it to your app once your app is the one on screen.

Prefer the content call and content query commands in this guide while developing: they are faster, verifiable and do not depend on tapping a small display. Use the on-device UI when the owner must make or confirm a decision.


1. Confirm you are connected

adb devices

If nothing is listed, unplug and replug.

⚠️ USB is adb-only by default — the device does not appear as a drive. That is deliberate: enabling MTP re-enumerates the USB device, which drops the adb connection, and keeping it on meant that happening on every replug. Turn it on when you actually need to copy files: System → USB file transfer. Everything below works over adb without it.

adb shell content query --uri content://io.kungcorp.nolee.launcher.state/device

One row per fact: chip, cpu, memory, storage, android, abi, camera, display, root, build, serial. This is the same set the About screen shows. If this command works, the Launcher is running and the whole API below is live.


2. Build constraints — read these before you design a layout

Screen 408 × 502 px, density 200 / 1.25 (~326 × 402 dp)
Text size Owner setting, device-wide: S/M/L = font_scale 1.0 / 1.25 / 1.5 (shipped default). Launcher → System → Text size.
API level 28 (Android 9) — targetSdk may be higher, minSdk must be ≤ 28
ABI arm64-v8a (also armeabi-v7a, armeabi)
Orientation portrait by default. Landscape is an owner setting (Launcher → System → Display, or set_orientation). System screens follow after a reboot. Your app's own android:screenOrientation still wins for its window. Factory reset returns to portrait.
Root available; Nolee approval is required, with no Magisk prompt

⚠️ 408, not 410. wm size reports 408×502 and that is what your app gets. The physical panel is 410×502 — the extra 2 px is surface-alignment (408 = 8 × 51). The 410 figure appears in bootloader and boot-animation assets. Use 408 for anything drawn by your app.

⚠️ Boot desc.txt is 410 502 on purpose. Do not "fix" it to 408 502 to match wm size — that is the wrong layer and produces a letterboxed or clipped splash. Nolee stock slogan/shutdown look left-weighted because the drawing is (wordmark top-left, arm from the left, thin grey line-art on black). That is not a packing gap; a full-bleed customer PNG fills the panel.

⚠️ This is an unusually short wearable display at 200 dpi. Its ~326 dp logical width is phone-like, but its ~402 dp height is much shorter and a finger covers a meaningful portion of it. Use large touch targets and avoid dense forms.

⚠️ Your app inherits the owner's text size. A layout that only works at one scale will break at the others — at L a dense panel sized in dp overflows the lens. Compose's Text is the trap: overriding fontSize alone leaves the theme's 24 sp lineHeight in place, so rows keep a ~45 px line box at L regardless of glyph size. Pin lineHeight alongside fontSize, and for dense instrument readouts cap the scale for that element only — leave body copy scaling for the owner.

// Caps the accessibility scale for one element without freezing the rest of the UI.
@Composable
fun instrumentSp(baseSp: Float, maximumScale: Float = 1f): TextUnit {
    val actual = LocalDensity.current.fontScale.coerceAtLeast(0.1f)
    return (baseSp * min(actual, maximumScale) / actual).sp
}

Text(label, fontSize = instrumentSp(11f), lineHeight = instrumentSp(13.5f), maxLines = 1)

3. Install your app

adb install -r your-app.apk

⚠️ On some Windows platform-tools builds adb install prints Success to stderr, not stdout. If you are parsing the result in a script, read both streams or you will report a working install as a failure.


3b. Three things to do before you kiosk anything

All three are painful to discover only after the app has taken over the device.

Declare your main activity as HOME

<activity android:name=".MainActivity" android:launchMode="singleTask">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
        <category android:name="android.intent.category.HOME" />
        <category android:name="android.intent.category.DEFAULT" />
    </intent-filter>
</activity>

⚠️ Declare CATEGORY_HOME. Without it, the power button cannot turn the screen off during a kiosk session and the display may remain on indefinitely.

You do not become the launcher by doing this. Nolee Launcher stays HOME normally and hands it to you only for the duration of a session, taking it back on exit.

Root is granted by Nolee, not by a Magisk prompt

Newly installed apps are denied root by default. Root and kiosk are separate least-privilege decisions. Grant root only if the app actually needs administrator-level device access, using the device at System → Access → Root access or the administrative API in §4.

There is no Magisk approval screen in the shipped product. An unapproved su call fails promptly with permission denied instead of opening a dialog or hanging behind kiosk.

Choose and test the owner's way out

Before starting kiosk, proactively propose an exit experience appropriate to the owner's use case: a visible control, a documented gesture, a supervisor PIN, or another authorization method. Unless the owner explicitly asks for no local exit, implement one through the exit_kiosk call in §6 and test it from a real locked session before handover.

⚠️ This is a release-blocking requirement. If the app has no tested exit and AI agent access (ADB) is later disabled, the owner has no ordinary way to leave the fullscreen app. The device may appear bricked even though the app is working as configured. Keep ADB enabled throughout development, and do not disable it until the local exit has passed a real kiosk test.

4. Run it fullscreen (kiosk)

Two steps, and the first one is not optional:

a. Select the primary app. On the device: Kiosk → Primary app, or from the connected computer while AI agent access (ADB) is on and no kiosk session is running:

adb shell content call --uri content://io.kungcorp.nolee.launcher.state --method set_primary_app --arg com.example.myapp

The command validates that the package is installed, enabled, launchable, and declares CATEGORY_HOME. To clear the selection, pass --arg none.

Optional: root, companions, and Android runtime grants. Root and companions are Launcher content calls (AI agent access (ADB) on, kiosk exited). Camera, microphone and location are not a Launcher API — use pm grant. Those grants survive turning AI agent access (ADB) off.

# Root for the primary app — omit this unless the app genuinely needs root.
adb shell content call --uri content://io.kungcorp.nolee.launcher.state --method set_root_access --arg com.example.myapp --extra enabled:b:true

# Runtime permissions (camera, mic, location, …) — not a Launcher API. Android's own
# prompt appears on the device and is easy to miss from a computer. Grant from adb; it
# survives turning AI agent access (ADB) off. The app must declare the permission.
# Wi-Fi scan and BLE discovery need location on API 28: without it getScanResults()
# / BLE scan return an empty list, not an error. Location services must also be on.
adb shell pm grant com.example.myapp android.permission.ACCESS_FINE_LOCATION
adb shell pm grant com.example.myapp android.permission.CAMERA
adb shell pm grant com.example.myapp android.permission.RECORD_AUDIO
adb shell settings put secure location_mode 3
# ⚠️ Do not skip pm grant because `adb install` looked like it already granted them.
# This ROM sometimes auto-grants on a debug sideload and sometimes does not.

# Allow a second app to open visibly inside strict kiosk.
adb shell content call --uri content://io.kungcorp.nolee.launcher.state --method set_companion --arg com.example.other --extra enabled:b:true

# Landscape for a desk-mounted app. Wait for the call (it patches system PM caches; about a
# minute). Then reboot. Factory reset returns to portrait. Your app's screenOrientation still wins.
adb shell content call --uri content://io.kungcorp.nolee.launcher.state --method set_orientation --arg landscape
adb reboot

Use enabled:b:false to revoke a Launcher grant (set_root_access / set_companion). Root is granted to the app's Linux uid, so the response and /root_access query list every package sharing it. A normal in-place APK update keeps the uid and policy; uninstall/reinstall may receive a new uid and must be queried/granted again. Runtime permissions use pm revoke <pkg> <permission> (they are not a Launcher API). They survive turning AI agent access (ADB) off; uninstall/reinstall clears them.

b. Start it: Kiosk → Start kiosk on the device, or from your computer while AI agent access (ADB) is on:

adb shell content call --uri content://io.kungcorp.nolee.launcher.state --method request_kiosk --arg com.example.myapp

To leave:

adb shell content call --uri content://io.kungcorp.nolee.launcher.state --method exit_kiosk

⚠️ What a strict kiosk session actually does

While kiosk is running, only your app and the companion apps the owner ticked may run. Nolee Launcher and Android Settings are not on that list, and that is the point:

Long-press power is consumed by the system and does not provide a dependable power menu inside kiosk. Keep AI agent access (ADB) on while developing so adb remains the recovery path; turn it off only for a finished handover.

⚠️ There is no power menu, so tell the owner where the real one is: System → Power → Restart or shut down, or a long press of the side button anywhere in the Launcher, which opens the same screen. Either way it takes an authorized exit to reach from inside kiosk — one more reason the exit you build in §6 matters. From a computer:

adb reboot                 # restart
adb shell reboot -p        # power off

⚠️ This is why exit uses content call and not an Intent

Because the Launcher is not on the kiosk allowlist, Android will not start any Launcher activity while your app is kiosked — so the old am start -a …EXIT_KIOSK cannot work from inside a session, for you or for adb. content call is ordinary IPC, which lock task does not gate.

It is also the only channel that can tell who is calling. Android gives the Launcher your uid; an Intent carries nothing it can trust. So:

Caller May exit kiosk
the app currently kiosked yes
any other installed app no
adb, while AI agent access (ADB) is on yes
adb, once AI agent access (ADB) is off no

Every refusal comes back with a readable reason, in the returned Bundle and in logcat. Nothing here fails silently — a silent lock-task rejection surfaces only as error 101 with nothing thrown and nothing logged on your side, which is undiagnosable.

Check what is allowed and what is actually happening:

adb shell content query --uri content://io.kungcorp.nolee.launcher.state/allowlist
adb shell content query --uri content://io.kungcorp.nolee.launcher.state/kiosk
adb shell content query --uri content://io.kungcorp.nolee.launcher.state/root_access

The kiosk row reports the configured primary, requested session target, the framework's real lock-task state, foreground package, AI agent access (ADB), and a derived status. root_access is one row per manageable Linux uid and includes every package sharing that uid plus the current grant.

status meaning
disabled no kiosk session configured
active locked, and the target is what is on screen
interrupted the Launcher thinks a session is running and the framework disagrees
recovery the target crash-looped; the protected recovery screen has the device

5. Opening other apps from inside kiosk

You do not need to build a launcher. Fire a normal Intent. What decides whether it works is whether the owner ticked that package under Kiosk → Approved companion apps.

If it is not ticked the launch fails with error 101. To ask the owner:

adb shell content call --uri content://io.kungcorp.nolee.launcher.state --method request_allowlist --arg com.example.other

That shows the owner a message. It never approves anything by itself.

An AI agent administering the owner's connected device may approve or revoke it outside kiosk with set_companion (§4). An arbitrary installed app may only request; it can never approve itself.


6. Exiting: your app owns the UX, the Launcher owns the transition

Nolee does not impose an escape gesture. A universal wearer-visible way out would defeat every deployment that needs the device to stay on one app. The coding agent must nevertheless raise this decision before handover and recommend a suitable default: a visible button, a hidden long-press, a supervisor PIN, an NFC badge, or a signed command from the owner's server. “No local exit” must be an explicit owner choice, not a forgotten requirement.

When your app has decided the person is authorized, it asks the Launcher to perform the privileged part. That is the whole integration, and it is ~60 lines with no dependency:

val bundle = contentResolver.call(
    Uri.parse("content://io.kungcorp.nolee.launcher.state"), "exit_kiosk", null, null
)
if (bundle?.getBoolean("ok") != true) showError(bundle?.getString("reason"))

⚠️ Call it off the main thread — it is a synchronous binder call.

Drop-in helper and a worked exit flow: /sdcard/nolee-example/NoleeKiosk.kt and /sdcard/nolee-example/ExitFlowExample.kt.

The Launcher does not validate your PIN or your business rules. It validates that the request came from the app it kiosked.


7. Starting at boot

A device with a primary app chosen always boots into it. There is no separate "start at boot" toggle — a kiosk device that comes back from a flat battery showing the Launcher instead of your app is broken, so the choice of primary app is itself the instruction.

⚠️ There are two crash guards, and they explain most "my app did not start" reports:

adb logcat | grep NoleeLauncher/CrashGuard shows the count.

⚠️ What protects that recovery screen is the Administrator PIN, and it is not set by default. Set one at Launcher → Kiosk → Administrator PIN (up to 8 digits). With a PIN, reaching recovery takes a keypad entry. With no PIN, three deliberate taps open it — the screen says so plainly. That is intentional rather than an oversight: a rooted device is not hostile-user security, and a recovery screen nobody can open would lock an owner out of their own device to protect nothing.

What this means for what you build:

⚠️ A reboot is not treated as a crash — the guard keys on the kernel boot id, so restarting the device does not consume one of your three.

⚠️ Inside a kiosk session your app dies silently. There is no "keeps stopping" dialog.

That is deliberate, and it is a security fix rather than a cosmetic one. Android's crash dialog sits on top of the locked task and offers App info → Force stop → Uninstall — and uninstalling the kiosk target ends the session, so an end user with no adb and no PIN could remove your product and land on the Nolee Launcher. The Launcher now blocks each step of that chain and sets hide_error_dialogs=1 for the length of the session, so the dialog never appears at all.

What this means for you while developing:

8. AI agent access (ADB) — and the one command that can strand you

Kiosk and AI agent access (ADB) are independent settings. All four combinations work:

Kiosk AI agent access (ADB) Use
off on build, install, debug
on on test the kiosk from your computer — the normal testing posture
on off hand a finished experience to an employee or customer
off off supported, uncommon

Starting kiosk never turns ADB off by itself.

Developing on a device that is being worn: use ADB over Wi-Fi. A cable is awkward once the device is on someone's wrist, and the Launcher has a toggle for this at System → Access → ADB over Wi-Fi (TCP). Turn on AI agent access (ADB) first — the row does nothing without it — then, on the same network:

adb connect <device-ip>:5555

⚠️ It turns itself off on restart. The port is held in a volatile system property, so a reboot returns the device to USB-only ADB and the owner has to turn it on again. Treat wireless ADB as a convenience for a session, never as the connection a finished handover depends on.

⚠️ Turning AI agent access (ADB) off cannot be undone from the connected computer, because the connection you would use is the one you just switched off. The way back is System → AI agent access (ADB) on the device itself.

It is a setting, not a lock. AI agent access (ADB) is a live read and write of Settings.Global.adb_enabled and nothing more. Root can turn it back on (su -c 'settings put global adb_enabled 1'), and so can any app on the device that holds root. Treat it as this device is finished and handed over, not as this device is sealed.

⚠️ With AI agent access (ADB) on, a connected computer has administrator-level access. Do not wait for a USB authorization prompt as a security check. Keep ADB on while developing; turn it off before handing a finished device to another person or leaving it where an untrusted computer could be connected. If the device will remain kiosked, first verify the app's local kiosk exit.


9. The current public API

Intents for actions, a ContentProvider for queries and for everything privileged. No AIDL, no client library.

Control channel — content call, works inside kiosk

--uri content://io.kungcorp.nolee.launcher.state

--method --arg Notes
request_kiosk package (optional; defaults to caller) target must be owner-approved
exit_kiosk caller must be the current target, the Launcher, or adb with AI agent access (ADB) on
request_allowlist package (optional) shows the owner a prompt
sleep_screen turns the display off; same caller rules as exit_kiosk
status same fields as the kiosk query
thermal device temperature; see below. No permission, no root
set_primary_app package, or none adb/root admin only; validates installed, enabled, launchable and HOME
set_companion package + --extra enabled:b:true|false adb/root admin only; approve or revoke
set_root_access package + --extra enabled:b:true|false adb/root admin only; grant or revoke the package uid
set_branding from adb: --arg /data/local/tmp/branding.json; from an app: string extras Fields: manufacturer brand model name device bluetooth_name device_name. Charset-whitelisted. Reboot after. Factory reset returns to Nolee. See §12b.
set_artwork extras logo boot shutdown and/or logo_land boot_land shutdown_land = PNG paths on the device, or JSON --arg Push the PNG to /data/local/tmp/ first. Converted on-device (410×502, 8-bit BMP, ZIP_STORED). Desk (*_land) PNGs that are wider than tall are rotated 90° CW first. Live files update only if the current orientation matches. Factory reset returns to Nolee. See §12b.
set_orientation landscape or portrait adb/root admin only. Updates platform and installed-app orientation caches, then copies matching boot art onto live paths. Apps that explicitly declare portrait keep it. About a minute; wait, then reboot. Factory reset returns to portrait + Nolee wrist art.

Every method returns a Bundle with ok (boolean) and, on failure, reason (string). The set_* methods are accepted only from Launcher itself or adb/root while AI agent access (ADB) is on, and only after kiosk/recovery has been exited. Arbitrary apps retain request-only access. Successful mutations include actual readback (primary, enabled, and for root uid/packages) so an agent can verify state rather than trusting that a command ran. set_branding, set_artwork and set_orientation also return needs_reboot=true on success when a reboot is required (set_orientation returns false if the mode was already set; set_artwork returns false if only the other orientation's overlay was written).

Queries — content query

Query URI Returns
content://io.kungcorp.nolee.launcher.state/device one row per device fact
content://io.kungcorp.nolee.launcher.state/allowlist one row per approved companion package
content://io.kungcorp.nolee.launcher.state/kiosk target · lock_task · foreground · dev_access · status · primary
content://io.kungcorp.nolee.launcher.state/root_access uid · every package sharing it · label · granted
content://io.kungcorp.nolee.launcher.state/branding key · live · shipped (the Nolee restore target)
content://io.kungcorp.nolee.launcher.state/artwork key · live sha256 · shipped sha256 · custom sha256 (logo, boot, shutdown, plus *_land)

Temperature — thermal

Long-running and animation-heavy apps warm the device. The kernel's thermal zones need root, so the Launcher reads them for you — no permission, no root, no client library:

adb shell content call --uri content://io.kungcorp.nolee.launcher.state --method thermal
# Bundle[{board_c=56.312, ok=true, thermal_source=sysfs, soc_c=46.487}]
val b = contentResolver.call(noleeStateUri, "thermal", null, null)
val soc = if (b?.getBoolean("ok") == true && b.containsKey("soc_c")) b.getFloat("soc_c") else null
val board = if (b?.getBoolean("ok") == true && b.containsKey("board_c")) b.getFloat("board_c") else null
Field Zone What it tells you
soc_c soc-thmzone The processor die. Rises quickly with your workload. This is the one the platform acts on
board_c board-thmzone Elsewhere on the board. Lags the die and sits above it at rest. Not the case surface, and warmer than it

The two are separate sensors, not one figure with an offset — the gap between them ranges from about 7 °C to 14 °C and narrows as soc_c rises.

What the platform does on its own

soc_c Platform behaviour
up to ~75 °C Nothing. Full 1.82 GHz
~75 °C Holds the temperature here by capping CPU frequency — big core to ~1.2 GHz, small cores as low as ~0.8 GHz
110 °C Critical shutdown threshold

A lower passive trip at 60 °C is declared but produces no observable change.

➡️ Above ~75 °C your CPU budget can halve, and it arrives as your app simply running slower — no callback, no exception, nothing to catch. If your work has a deadline, watch soc_c and shed load yourself rather than letting the governor choose what to slow down.

⚠️ board_c is not regulated. While soc_c is held at 75 °C, board_c continues to climb. The platform protects the processor, not the enclosure and not a user touching it. Any limit beyond the die is yours to enforce.

Choosing your own limits

Decide first how your app is used, because it changes which figure matters. Against the body, the enclosure temperature is the binding constraint and should be the stricter of the two. In a dock, a mount or an enclosure, nothing is in contact, so the enclosure can run much hotter and soc_c becomes the figure to watch — purely to protect throughput. An app used both ways wants two sets of limits and a way to tell which applies; the accelerometer is a better source for that than any sensor flag.

Recommended starting points, with the equivalent die temperature since either can drive a throttle (board_c ≈ 0.68 × soc_c + 24):

board_c soc_c Treat as
below 60 below 52 normal
60 – 70 52 – 67 warm
above 70 above 67 hot

For an app held against the body, stay under board_c 68 (soc_c ~64).

⚠️ These are a starting point, not a specification. Room temperature, what your app drives (screen, radio and camera all heat the device far more than CPU work alone) and how your users actually hold or wear it will all move the answer. Test with your own app and your own users, decide what these figures mean for you, then throttle against your own limits. Prefer reacting to a rise over an absolute value.

⚠️ A missing field is not "cool". Either field may be absent. Keep your current behaviour and read again later rather than assuming the device is cold.

⚠️ Blocking call, and not one to poll tightly. Keep it off the main thread. Once every 15–60 seconds is enough for a throttling loop.

⚠️ dumpsys battery is not a substitute. Its temperature field is a constant 200 (20.0 °C) on this platform, charging or not.

Intents — compatibility only

Action Extra
io.kungcorp.nolee.launcher.action.REQUEST_KIOSK --es package <pkg>
io.kungcorp.nolee.launcher.action.EXIT_KIOSK
io.kungcorp.nolee.launcher.action.REQUEST_ALLOWLIST --es package <pkg>

⚠️ These do not work from inside a strict kiosk session (§4), and EXIT_KIOSK over an Intent is accepted only from a caller Android can name — i.e. one that used startActivityForResult. adb shell am start …EXIT_KIOSK is refused; use content call. Prefer the control channel for anything new.

⚠️ Wi-Fi and Bluetooth scan lists are empty without a location grant. On this API 28 device, WifiManager.getScanResults() and BLE discovery return an empty list — not an error — unless the app holds ACCESS_FINE_LOCATION. A runtime dialog appears on the device and is easy to miss from a computer. Grant it with pm grant <pkg> android.permission.ACCESS_FINE_LOCATION (the app must declare it). The grant survives turning AI agent access (ADB) off. Location services must also be on (settings put secure location_mode 3). Joining a network the app itself created (addNetwork + enableNetwork) is an ordinary API; listing other apps' saved networks (getConfiguredNetworks) is Device-Owner only and stays with the Launcher.


10. If something goes wrong

Two owner-facing recovery concepts, both on the device under Kiosk:

Exit kiosk keeps apps and data, returns to Nolee Launcher
Restore Nolee erases custom state and returns to the shipped Nolee starting point

⚠️ Restore Nolee erases customer apps, data and customizations. Keep the device powered while it restores and completes its reboots. The Launcher checks that protected recovery is available before it permits the reset.

The rule behind every "factory reset returns to …" note in this guide: a reset wipes /data and never /system. Anything the owner installed or changed lived in /data and is gone; the shipped Nolee state lives in /system and is put back. That is the whole model — there is nothing partial or special about it.

⚠️ After any full reset the device restarts itself once more. The first boot afterwards is still putting the shipped state back: it shows a Finishing restore screen saying the device will restart once more, then reboots on its own. Keep it powered through that second boot — the Launcher withholds its interface until then, so a half-restored device can never look ready for use. What comes back is the shipped Nolee state: Launcher as HOME, device owner, the shipped settings, the device's names and boot artwork, and this guide on /sdcard. Customer apps and their data do not.

The owner can update Nolee Launcher from System → Nolee Launcher. The first tap checks the company-signed release and shows its signed release notes; a second tap confirms installation. Keep the device powered and online until the Launcher reports the result. Launcher updates are company-signed and verified before installation. They update Nolee Launcher and this on-device guide while preserving customer apps, app data, Wi-Fi configuration and Launcher preferences.


11. Physical controls — what you can map, and how

The DevKit Ultra has two physical buttons and one camera lid/crown. The supported mappings and copyable implementation paths are below.

Control Gesture Your app receives From
Side button short / double / triple / long KEYCODE_F9 (139) gpio-keys
Camera lid tap KEYCODE_F2 (132) madev, scan 60
Camera lid double touch KEYCODE_F5 (135) madev, scan 63; one system-classified pulse
Camera lid long touch, ~2 s KEYCODE_F6 (136) madev
Camera lid swipe across the short axis KEYCODE_F3 / F4 madev, scan 468/469, meta 0x8
Camera lid entered the front-facing sector KEYCODE_F3 (133) och1970, scan 61, meta 0x0
Camera lid entered the back-facing sector KEYCODE_F4 (134) och1970, scan 62, meta 0x0
Camera lid entered the side-facing sector KEYCODE_F8 (138) och1970, scan 66
Camera lid seated flush KEYCODE_F7 (137) och1970, scan 65
Camera lid where is it now camera_lid call — see below Launcher
Camera lid scroll up / down register_lid_scroll — see below Launcher bridge
Power any ⚠️ nothing — see below

An agent can query the same inventory directly from the connected device:

adb shell content query --uri content://io.kungcorp.nolee.launcher.state/controls

⚠️ KEYCODE_F3/F4 are produced by TWO different things — a lid swipe and a lid position. They are only distinguishable by source. The scan code or device name is authoritative; META_FUNCTION_ON is a convenient equivalent on this ROM:

val isSwipe = e?.scanCode == 468 || e?.scanCode == 469 || e?.device?.name == "madev"
val isTurn  = e?.device?.name == "och1970"

The pattern for all of them

Every directly delivered control in the first eight rows is an ordinary Android key event. No root, permission or library is needed. Current facing and scroll use the Launcher calls below.

override fun onKeyDown(code: Int, e: KeyEvent?): Boolean = when (code) {
    KeyEvent.KEYCODE_F9 -> { sideButtonDown(); true }   // the side button
    KeyEvent.KEYCODE_F2 -> { lidTapped(); true }        // lid tap
    KeyEvent.KEYCODE_F5 -> { lidDoubleTouched(); true } // system-classified double touch
    KeyEvent.KEYCODE_F6 -> { lidHeld(); true }          // lid long touch
    else -> super.onKeyDown(code, e)
}

Test without touching the device: adb shell input keyevent 139

The side button — the one designed to be yours

The side button is unbound from system actions, so the raw key reaches your foreground activity. You decide what short, double, triple and long mean, by timing the events yourself:

private var downAt = 0L

override fun onKeyDown(code: Int, e: KeyEvent?): Boolean {
    if (code == KeyEvent.KEYCODE_F9) { downAt = SystemClock.uptimeMillis(); return true }
    return super.onKeyDown(code, e)
}

override fun onKeyUp(code: Int, e: KeyEvent?): Boolean {
    if (code == KeyEvent.KEYCODE_F9) {
        val held = SystemClock.uptimeMillis() - downAt
        if (held >= 400) longPress() else shortPressOrWaitForDouble()
        return true
    }
    return super.onKeyUp(code, e)
}

Thresholds the system itself uses: long press ≥ 400 ms, each multi-press gap < 300 ms. Triple press is not another firmware key; it is three ordinary F9 presses. Delay single/double commitment while another press could still arrive if your UI needs a distinct triple action.

⚠️ There is only one button. The crown does not rock, and there are no volume keys — VOLUME_UP/DOWN come only from a wired headset. Do not design a two-button interaction.

The camera lid — touch, swipe and facing on one part

⚠️ "The crown" IS the camera lid. It is a capacitive touch surface and a camera that twists and pulls out. It is not the side button.

⚠️ KEYCODE_F6 is a long touch, not a hold you can time. The user rests a finger for ~2 s, but the driver then emits one instantaneous pulse — your app measures ~1–3 ms and would call it a short press. Treat F6 as one discrete event. Do not classify it.

⚠️ Double touch is one KEYCODE_F5 event. The lid firmware classifies the physical double touch; do not wait for two F2 events. The surface is also gesture-sensitive: off-axis finger motion can produce F3/F4 instead, so apps should acknowledge only events they actually receive and must not assume every attempted touch was recognized.

⚠️ If an app itself waits to distinguish one F2 tap from a later gesture, the displayed tap count will increase only after that timing window expires. This confirmation delay is not sensor lag; the final hardware/3 run received 20/20 deliberate F2 taps.

KEYCODE_F12 is not part of the supported customer mapping. No physical control emits it. Do not design against F12 or a circular-direction gesture.

Audio input and output should be opened as mono. The built-in microphone operates at 16 kHz mono PCM16 through VOICE_RECOGNITION, and Nolee's working speaker paths use CHANNEL_OUT_MONO. The internal 48 kHz stereo mixer does not mean there are two physical speaker channels. Do not assume hardware AEC; add and verify software AEC if simultaneous playback and capture require it.

⚠️ F3/F4 are ambiguous, and you must disambiguate. Two different physical things produce them, because Generic.kl maps both to the same keycode:

// A lid SWIPE and a lid POSITION arrive as the same keycode. Tell them apart:
val fromLid = e?.device?.name == "madev"        // swipe across the lid surface
val fromHall = e?.device?.name == "och1970"     // the camera was physically turned
// Or on this ROM: META_FUNCTION_ON is set for madev swipes and clear for Hall positions.
val swipeMeta = (e?.metaState ?: 0) and KeyEvent.META_FUNCTION_ON != 0

Handle the four Hall transitions as sectors, not exact angle detents:

if (e?.device?.name == "och1970") {
    when (code) {
        KeyEvent.KEYCODE_F3 -> cameraEnteredFrontSector()
        KeyEvent.KEYCODE_F4 -> cameraEnteredBackSector()
        KeyEvent.KEYCODE_F8 -> cameraEnteredSideSector()
        KeyEvent.KEYCODE_F7 -> cameraWasSeatedFlush()
    }
}

⚠️ F2 fires ~45 ms before F7 every time the camera is pushed back in, because your finger touches the surface while seating it. If you bind F2, debounce it against a following F7 or you will get a phantom tap on every retract.

⚠️ The camera only pulls out and retracts in the side-facing position. Pushing it while front- or back-facing does nothing but register a lid touch.

Lid scroll — ask the Launcher for it

Scrolling the lid up/down is a real gesture — a directional step per notch, with a haptic tick — but Android does not deliver it as an input event. The device is classified keyboard-only (Classes: 0x00000001), so its motion axes are discarded before any activity sees them: no KeyEvent, no MotionEvent.

⚠️ Do not rely on "it scrolls my list for free". A list may appear to move under the lid in some apps, but there is no app-bindable scroll event behind it. Do not treat incidental page motion as a contract.

The supported route: register a callback with the Launcher. No root, no permission, no library.

val noleeStateUri = Uri.parse("content://io.kungcorp.nolee.launcher.state")

// 1. A receiver for the steps.
val receiver = object : BroadcastReceiver() {
    override fun onReceive(c: Context?, i: Intent?) {
        if (i?.getIntExtra("schema", -1) != 1) return          // refuse unknown schemas
        when (i.getStringExtra("direction")) {
            "step_up"   -> scrollUp()
            "step_down" -> scrollDown()
        }
        // i.getLongExtra("sequence", -1)    strictly increasing, so you can spot a gap
        // i.getLongExtra("event_time", 0)   SystemClock.uptimeMillis(), same clock as KeyEvent
    }
}
registerReceiver(receiver, IntentFilter("com.example.myapp.LID_STEP"))

// 2. Hand the Launcher a PendingIntent you created.
val callback = PendingIntent.getBroadcast(
    this, 0,
    Intent("com.example.myapp.LID_STEP").setPackage(packageName),   // explicit!
    PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, // mutable: it fills in extras
)
val result = contentResolver.call(
    noleeStateUri,
    "register_lid_scroll", null,
    Bundle().apply { putParcelable("callback", callback) },
)
// result.getBoolean("ok") — false means refused; read "reason"

// 3. Release it when this component no longer needs the hardware input.
contentResolver.call(noleeStateUri, "unregister_lid_scroll", null, null)

Register only for the lifecycle in which your app needs the control and unregister when finished. Background delivery is supported deliberately. Any installed app may register without being the primary or kiosk app; Launcher keeps one authenticated callback per package and one shared reader for all listeners. The reader exits after the last listener unregisters or dies.

status reports the comma-separated lid_scroll_listeners set. Registrations do not survive a Launcher process restart, so register again during your app's normal startup/resume path. Launcher cleans up a stale root reader at the beginning of its next process.

⚠️ This cannot be tested from adb, because adb cannot create your PendingIntent — which is precisely what proves the callback is yours. The Launcher checks its creator uid against the caller.

Heart rate, blood oxygen and blood pressure

All three are available, and none of them work by simply registering a sensor. The device has to be put into measurement mode first. Register a listener without doing that and you will get a stale value republished several times a second, forever — a plausible-looking number that never changes and does not belong to whoever is wearing the device.

A measurement takes about 30 seconds. Needs android.permission.WRITE_SECURE_SETTINGS, which is not a root permission: declare it in your manifest and have it granted once over adb.

adb shell pm grant <your.package> android.permission.WRITE_SECURE_SETTINGS
Metric health_measure_type getDefaultSensor() Reading
Heart rate 1 65599 values[1] — bpm
Blood oxygen 2 65596 values[2] — %
Blood pressure 3 65598 values[3] systolic, values[4] diastolic — mmHg

values[0] is skin contact on every metric. A zero there is an error, not a reading of zero.

// 1. Declare which metric, and enter measurement mode.
Settings.Global.putInt(contentResolver, "health_measure_type", 1)      // 1 = heart rate
Settings.Global.putInt(contentResolver, "health_measure_status", 1)

// 2. Hold a wakelock, or the measurement dies when the device idles.
val lock = (getSystemService(POWER_SERVICE) as PowerManager)
    .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "yourapp:vitals")
lock.acquire(35_000)

// 3. Register on the metric's own sensor, by the type id above.
//    ⚠️ getDefaultSensor(Sensor.TYPE_HEART_RATE) returns null. The numeric id does not.
val sensor = sensorManager.getDefaultSensor(65599)
sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)

// In the listener:
//   if (event.values[0].toInt() == 0) -> not on a wrist, keep waiting
//   val bpm = event.values[1].toInt()  -> settles after several seconds

// 4. ALWAYS finish, including on failure, cancellation and crash.
sensorManager.unregisterListener(listener)
Settings.Global.putInt(contentResolver, "health_measure_status", 0)
if (lock.isHeld) lock.release()

🚨 Always return health_measure_status to 0. Leave it at 1 and the optical sensor keeps running: the green LEDs stay lit, battery drains, the device warms, and the next measurement starts from a dirty state. Put the reset in a finally, and reset it in onStop() too — a user leaving your app mid-measurement is the normal case, not an edge case.

⚠️ One metric at a time, device-wide. The measurement type decides what the hardware produces and every sensor mirrors it, so registering all three at once returns the same figure three times over. Measure sequentially — about 90 seconds for a full set. This is also why two apps measuring at once will corrupt each other's readings: check health_measure_status before starting and back off if it is already 1.

⚠️ values[1] carries a value in every mode. Reading it without setting the type first gives you whichever metric the device was last in, wearing your label. Always set the type.

⚠️ The reading settles. Values start low and climb as the sensor acquires; take the value at the end of your window rather than the first non-zero one.

Works with the screen off — the partial wakelock is what makes that safe.

⚠️ Do not measure continuously. The optical sensor is a real power and heat cost, and holding it on indefinitely warms the device against the wearer for no benefit.

⚠️ values[0] is skin contact, not "worn". A device resting on its charging puck will report 1. It tells you the sensor sees something, not that anyone is wearing it — if you need to know that, use the accelerometer.

Steps — use the Android sensor directly

Enumerate and register android.sensor.step_counter directly:

val stepCounter = sensorManager.getSensorList(Sensor.TYPE_ALL)
    .firstOrNull { it.stringType == "android.sensor.step_counter" }
    ?: error("This hardware has no step counter")

sensorManager.registerListener(this, stepCounter, SensorManager.SENSOR_DELAY_NORMAL)

override fun onSensorChanged(e: SensorEvent) {
    if (e.sensor.stringType == "android.sensor.step_counter") {
        val count = e.values[0]
    }
}

No permission or root is required. ⚠️ The first event can take up to about 20 seconds of walking to arrive, and the count then advances in jumps rather than one step at a time. Tolerate delayed delivery; do not call the sensor absent merely because registration produces no immediate event. The count is not guaranteed to survive a reboot, so store your own session baseline rather than assuming it does.

android.sensor.wheel / silead_fp is not the pedometer. android.app.EventEncoderHelper holds it for foreground input plumbing and its payload is constant; do not use it for steps or fingerprint data.

⚠️ Pulling the camera out may launch Camera outside kiosk

Deploying the camera from Nolee Launcher opens the Camera app before another foreground app can consume the event. A strict kiosk session blocks that launch, so a kiosked primary app remains in front. Outside kiosk, design for the possibility that deploying the camera backgrounds your app. There is no supported setting for disabling this behavior.

Is the camera deployed? What is its raw Hall vector?

The pop-up camera's rotational position comes from a Hall sensor that SensorManager does not expose and that an app uid cannot read. Ask the Launcher instead — no permission, no root:

adb shell content call --uri content://io.kungcorp.nolee.launcher.state --method camera_lid
# Bundle[{x=-1045, y=2544, z=92, magnitude=2751, ok=true, state=closed}]
val noleeStateUri = Uri.parse("content://io.kungcorp.nolee.launcher.state")
val b = contentResolver.call(noleeStateUri, "camera_lid", null, null)
when (b?.getString("state")) {
    "closed"       -> {}   // stowed in its pocket
    "open"         -> {}   // deployed; direction is intentionally not inferred here
    "unknown"      -> {}   // could NOT be read
}
// b.getInt("x") / "y" / "z" / b.getLong("magnitude") — the raw vector, if you want to calibrate

⚠️ unknown is not closed. It means the sensor could not be read. Never treat it as "camera stowed" — it may be pointing at someone.

closed/open is robust. Direction comes from the system F3/F4/F8 transition events documented above. Raw x/y/z values are magnetic-field readings, not degrees or universal position coordinates; you may calibrate them for your own unit if you need point-in-time direction.

The keys and the vector are two INDEPENDENT paths

A common confusion worth stating plainly. They both describe the lid, but neither is built from the other:

Keycodes F3/F4/F7/F8 camera_lid
Source The kernel driver's own thresholding The raw Hall vector, classified by the Launcher
Tells you Which facing sector it just entered Robust stowed/open state plus raw current vector
Cost Free — onKeyDown One ContentResolver.call

📌 Most apps want both. Query camera_lid for robust stowed/open state and diagnostic or customer-calibrated coordinates, then react to F3/F4/F8 for system-classified facing transitions.

⚠️ KEYCODE_F8 also fires in transit. Rotating front↔back passes through side, emitting F8 roughly 250 ms before the destination key. Do not treat F8 as "the user chose side-facing" without a settle delay, or every rotation registers a spurious side selection.

⚠️ F3/F4 are ambiguous — the lid swipe produces the same keycodes. Disambiguate with event.metaState and KeyEvent.META_FUNCTION_ON (set for a swipe, clear for a position) or event.device?.name (madev vs och1970).

⚠️ Power — your app cannot see it at all

Do not design around the power button. Android's window manager consumes KEYCODE_POWER before any app, on every Android device, and this firmware sends its own broadcast only to com.android.settings. There is no workaround.

What the power button does do for a kiosked app, and it is useful:

Sleeps the screen ✅ — only if your activity declares CATEGORY_HOME (§3b)
Returns to your primary app ✅ from a companion app — it behaves as HOME
Double press opens the task switcher; blocked inside kiosk
Very long hold hardware reboot, below the OS — uninterceptable

⚠️ CATEGORY_HOME is the precondition for both. Without it, lock task refuses the HOME step, the system handler cannot complete the sleep action and the screen stays on.

For an app-controlled sleep, ask the Launcher instead — no permission, no root:

adb shell content call --uri content://io.kungcorp.nolee.launcher.state --method sleep_screen

Also available

12. Root

Root is available, but customer apps are denied by default. The owner grants an app at System → Access → Root access or through set_root_access; selecting it for kiosk does not grant root. The decision is explicit and no Superuser prompt appears. This matters for agents: a dialog on the device cannot be dismissed reliably from a computer, so Nolee uses immediate allow/deny policies instead.

There is deliberately no customer-app request_root_access call and an app cannot approve itself. With AI agent access (ADB) enabled and kiosk exited, an administrator can approve Harness (or any other customer app) through the Launcher provider:

adb shell content call --uri content://io.kungcorp.nolee.launcher.state --method set_root_access --arg io.kungcorp.nolee.harness.debug --extra enabled:b:true

After that call, su launched by Harness is accepted immediately under its app uid. Use enabled:b:false to revoke it; never edit Magisk's policy database directly.

Runtime permissions (camera, microphone, location) are ordinary Android grants, not a Launcher API. From a computer use pm grant; a person looking at the device can tap Android's own prompt. Those grants survive turning AI agent access (ADB) off.

adb shell su 0 sh -c 'id'

The adb shell itself is a protected grant, so the command above works whenever AI agent access (ADB) is on. It is the owner's device and the owner installed you. Do not abuse it.

⚠️ Two paths you must not delete

Root means you can delete anything. These two are the ones that matter:

Path If you delete it
/system/nolee/ Restore Nolee and protected recovery stop working
/system/priv-app/NoleeLauncher/ The device loses its Nolee home and management surface

/system/nolee/ holds protected recovery, shipped artwork and a copy of this document. Treat both paths as part of the platform, not as customer-app storage.

⚠️ If you have been asked to "clean up the device" or "free up space", neither of these is a candidate. /system is not where your app's storage comes from and the space is not usable by you. Removing them is not covered by warranty or support.

Do not remove these paths piecemeal. Replacing the Nolee platform itself is outside the supported app-development and branding flows in this guide.


12b. Making the device yours — full branding guide

Everything on this device that says "Nolee" can say your name instead. This section covers all of it: the names the device reports, and every image a user sees from pressing power to reaching your app.

Use the Launcher branding and artwork APIs below. They perform the privileged changes safely; do not edit system partitions directly when these APIs cover the requested result.

Paved road (adb)

AI agent access (ADB) on, not in kiosk. Files live on the device; adb push copies them there from the computer. PNG filenames are arbitrary (mybrand.png is fine) — the extra name (logo / boot / shutdown, or logo_land / boot_land / shutdown_land for the desk set) chooses the slot. Paths must be /data/local/tmp/… or /sdcard/…, letters/digits/._- only, and .png / .json.

# ── names ──────────────────────────────────────────────────────────────────
# From adb, do NOT put spaces in --extra values and do not pass JSON inline:
# the on-device content parser splits on whitespace and treats : as extra syntax.
# Customer apps pass extras normally (spaces are fine in a Bundle).
adb push branding.json /data/local/tmp/branding.json
adb shell content call --uri content://io.kungcorp.nolee.launcher.state --method set_branding \
  --arg /data/local/tmp/branding.json

# ── boot artwork (PNG on the computer → device → convert on-device) ───────
# Wrist (portrait) extras: logo / boot / shutdown
# Desk (landscape) extras: logo_land / boot_land / shutdown_land
# You can set one orientation, the other, or both. Per-slot: a missing custom
# file keeps Nolee's stock for that orientation.
adb push logo.png /data/local/tmp/logo.png
adb push logo-desk.png /data/local/tmp/logo-desk.png
adb shell content call --uri content://io.kungcorp.nolee.launcher.state --method set_artwork \
  --extra logo:s:/data/local/tmp/logo.png \
  --extra boot:s:/data/local/tmp/logo.png \
  --extra shutdown:s:/data/local/tmp/logo.png \
  --extra logo_land:s:/data/local/tmp/logo-desk.png \
  --extra boot_land:s:/data/local/tmp/logo-desk.png \
  --extra shutdown_land:s:/data/local/tmp/logo-desk.png

# ── verify, then reboot so ro.* / bootloader splash match ──────────────────
adb shell content query --uri content://io.kungcorp.nolee.launcher.state/branding
adb shell content query --uri content://io.kungcorp.nolee.launcher.state/artwork
adb reboot

branding.json is an object of any subset of manufacturer, brand, model, name, device, bluetooth_name, device_name. Example:

{
  "manufacturer": "Your Company",
  "brand": "YourBrand",
  "model": "YourBrand Device",
  "name": "yourbrand_device",
  "bluetooth_name": "YourBrand",
  "device_name": "YourBrand"
}

name is the lowercase slug (adb devices -l product:). device is the shell prompt; leave it off unless you want YourBrand:/ $ instead of Nolee:/ $. Values may use letters, digits, spaces, and . _ + -. Newlines, =, quotes and $ are refused — build.prop is line-oriented and an unvalidated value is property injection. name is slug-only (a-z, 0-9, _).

Artwork JSON is the same idea if you prefer a file over extras:

{
  "logo": "/data/local/tmp/logo.png",
  "boot": "/data/local/tmp/boot.png",
  "shutdown": "/data/local/tmp/shutdown.png",
  "logo_land": "/data/local/tmp/logo-desk.png",
  "boot_land": "/data/local/tmp/boot-desk.png",
  "shutdown_land": "/data/local/tmp/shutdown-desk.png"
}
adb push artwork.json /data/local/tmp/artwork.json
adb shell content call --uri content://io.kungcorp.nolee.launcher.state --method set_artwork \
  --arg /data/local/tmp/artwork.json

A successful call returns ok=true. needs_reboot=true only when a live file changed (the orientation currently showing). Writing only the other orientation's overlay does not need a reboot. After reboot, branding live should match what you wrote. artwork rows are per orientation: live is always the currently installed file; shipped is Nolee's stock for that row; custom is your overlay (empty until you set one). Until a factory reset, custom portrait or desk art stays; wipe returns both Nolee sets and live wrist art (reset always comes back portrait).

Nolee ships two artwork sets. Portrait/wrist artwork is active when the device ships. set_orientation copies, for each slot:

Custom wrist only → landscape still shows Nolee desk. Custom desk only → reverse. Both → both. A Launcher OTA does not change live boot art (the update writes the APK and this file, not the logo partition or the animation zips). Custom overlays survive reboot and OTA until a factory reset.


12b.1 The names

Six places report a device name, and they come from four different sources. Changing one does not change the others — this is the part people get wrong.

Where a user or developer sees it Source Lives in
adb devices -l ro.product.name (product:), ro.product.model (model:), ro.product.device (device:) /system/build.prop
adb shell prompt (Nolee:/ $) ro.product.device /system/build.prop
Bluetooth advertised name (visible to everyone in range) ro.bt.customize.name /vendor/build.prop
Firmware model strings ro.product.dwmodel, persist.sys.dw.model, persist.sys.fw.model /vendor/build.prop + /data/property
MTP name in a computer's file browser Settings.Global.DEVICE_NAME settings DB
Wi-Fi Direct / Nearby name Settings.Global.DEVICE_NAME settings DB
Boot text ("Nolee is starting") resources.arsc /system/framework/framework-res.apk

Back up first—a malformed build.prop can prevent the device from booting:

adb shell "su -c 'mount -o remount,rw /; mount -o remount,rw /vendor; \
  [ -f /system/build.prop.bak ] || cp -f /system/build.prop /system/build.prop.bak; \
  [ -f /vendor/build.prop.bak ] || cp -f /vendor/build.prop /vendor/build.prop.bak'"

Then, replacing YourBrand:

# 1. adb identity. device stays `Nolee` (the shell prompt); change it only if you
#    want `YourBrand:/ $` instead of `Nolee:/ $`.
adb shell "su -c 'sed -i \"s/^ro.product.model=Nolee Devkit Ultra$/ro.product.model=YourBrand/; \
  s/^ro.product.name=nolee_devkit_ultra$/ro.product.name=YourBrand/\" /system/build.prop'"

# 2. Bluetooth + firmware mirrors. Do not use a blanket s/=Nolee/ — that would turn
#    `Nolee Devkit Ultra` into `YourBrand Devkit Ultra`.
adb shell "su -c 'sed -i \"s/^ro.bt.customize.name=Nolee$/ro.bt.customize.name=YourBrand/; \
  s/^ro.product.dwmodel=Nolee$/ro.product.dwmodel=YourBrand/; \
  s/^persist.sys.dw.model=Nolee$/persist.sys.dw.model=YourBrand/; \
  s/^persist.sys.fw.model=Nolee$/persist.sys.fw.model=YourBrand/; \
  s/^ro.product.vendor.model=Nolee DevKit Ultra$/ro.product.vendor.model=YourBrand/; \
  s/^ro.product.vendor.name=nolee_devkit_ultra$/ro.product.vendor.name=YourBrand/\" /vendor/build.prop'"

# 3. The persisted copies, which beat build.prop on an already-booted device
adb shell "su -c 'resetprop persist.sys.dw.model YourBrand; \
  resetprop persist.sys.fw.model YourBrand; resetprop ro.product.dwmodel YourBrand'"

# 4. MTP / Wi-Fi Direct / Nearby
adb shell "settings put global device_name YourBrand"
adb shell "settings put secure bluetooth_name YourBrand"

adb reboot

Verify:

adb devices -l                                    # product/model/device = YourBrand
adb shell dumpsys bluetooth_manager | grep -i name   # name: YourBrand
adb shell settings get global device_name

Five traps, each of which has cost real time:

⚠️ /vendor is a separate partition and needs its own remount. Remounting / leaves /vendor read-only, and the failure is silent — sed exits 0 and changes nothing.

⚠️ settings put secure bluetooth_name alone does not hold. The Bluetooth stack rewrites it from ro.bt.customize.name on the next Bluetooth restart, so the name reverts minutes later and looks like the setting "didn't stick". The property is the real source; set both.

⚠️ persist.* properties are cached in /data/property and take priority over build.prop. On any device that has booted once, editing build.prop alone will not move them. Hence resetprop.

⚠️ Leave Android build fingerprints unchanged. Changing them makes PackageManager treat the next boot as an OS upgrade and re-optimize every package. set_branding intentionally excludes them.

⚠️ Your computer may keep showing the old name. Windows writes the device FriendlyName into the registry at first enumeration and never re-reads it. Clear it with pnputil /remove-device "<InstanceId>" (find it with Get-PnpDevice), then replug. Bluetooth needs unpair/re-pair. macOS and Linux re-read on connect and show the new name immediately.

Boot text

The white "Nolee is starting" during first boot lives in resources.arsc inside /system/framework/framework-res.apk.

⚠️ This is an in-place byte splice, so the replacement must be exactly the same length — the string is overwritten where it sits, and any change in length shifts the whole resource table and the device fails to boot. Phone is starting and Nolee is starting are both 17 bytes, which is why the swap is clean. Pad with trailing spaces if your name is shorter; a longer name will not fit and needs a different approach (rebuilding the APK properly with aapt2).


12b.2 The images

Four assets, in the order a user encounters them.

Stage File on device Format
1. Boot logo (instant, pre-Android) /dev/block/by-name/logo — a raw partition, not a file 8-bit indexed BMP, 410×502, padded to 3 MB
2. Boot animation /system/media/bootanimation.zip ZIP, stored not compressed
3. Shutdown animation /system/media/shutdownanimation.zip same
4. Off-mode charging screen /vendor/etc/res/images/*.png (116 files) PNG

Display geometry — read this before designing anything

Android apps receive a 408×502 portrait surface (or 502×408 after set_orientation landscape). The boot-animation player and the bootloader are a separate firmware path and always expect 410×502 physical-portrait frames, so:

Get this wrong and you get a rotated or letterboxed splash with no error anywhere. Do not change desc.txt to 408 502 either — Android apps are 408 wide; the boot player is not.

Boot logo

The paved road is the Launcher API. Push a PNG (any reasonable size; it is letterboxed onto 410×502) and let the device convert it—do not hand-author the 8-bit indexed 3 MB BMP.

adb push yourlogo.png /data/local/tmp/logo.png
adb shell content call --uri content://io.kungcorp.nolee.launcher.state --method set_artwork \
  --extra logo:s:/data/local/tmp/logo.png
adb reboot

boot and shutdown take PNG paths the same way, or pass all three in a JSON file:

adb push artwork.json /data/local/tmp/artwork.json
adb shell content call --uri content://io.kungcorp.nolee.launcher.state --method set_artwork \
  --arg /data/local/tmp/artwork.json
{
  "logo": "/data/local/tmp/logo.png",
  "boot": "/data/local/tmp/boot.png",
  "shutdown": "/data/local/tmp/shutdown.png",
  "logo_land": "/data/local/tmp/logo-desk.png",
  "boot_land": "/data/local/tmp/boot-desk.png",
  "shutdown_land": "/data/local/tmp/shutdown-desk.png"
}

A factory reset wipes /data (your overlays) and copies Nolee wrist art from /system/nolee/artwork/ onto the live paths. The desk stock in /system/nolee/artwork/land/ stays for the next landscape flip. Reset always returns to portrait. Query live vs shipped vs custom hashes:

adb shell content query --uri content://io.kungcorp.nolee.launcher.state/artwork

The dd recipe below is the recovery/fallback path if you are not on a Launcher that has set_artwork yet.

adb shell "su -c 'dd if=/dev/block/by-name/logo of=/sdcard/logo-stock.bin bs=4096'"   # BACK UP
adb pull /sdcard/logo-stock.bin
adb push yourlogo.bmp /sdcard/logo.bmp
adb shell "su -c 'dd if=/sdcard/logo.bmp of=/dev/block/by-name/logo bs=4096'"

⚠️ This writes to a raw partition. It is the single most dangerous operation in this document — there is no filesystem to protect you and a wrong of= target can make the device unbootable. Copy the stock partition out first and keep it.

⚠️ It must be 8-bit indexed BMP (not 24-bit, not PNG) at exactly 410×502, padded to 3 MB. The bootloader does no format negotiation: a wrong format is a black or corrupt splash.

Boot and shutdown animations

set_artwork with extras boot / shutdown (PNG paths) is the paved road — it writes ZIP_STORED zips with desc.txt 410 502. The recipes below are the fallback if you must install a pre-built zip.

Standard Android bootanimation.zip. Structure:

desc.txt
part0/0000.png, 0001.png, …

desc.txt:

410 502 1
p 1 0 part0

That is <width> <height> <fps>, then p <loopcount> <pause> <folder>1 means play once, 0 means loop forever. Install:

adb shell "su -c 'mount -o remount,rw /; \
  [ -f /system/media/bootanimation.zip.bak ] || cp /system/media/bootanimation.zip /system/media/bootanimation.zip.bak'"
adb push bootanimation.zip /sdcard/
adb shell "su -c 'cp /sdcard/bootanimation.zip /system/media/bootanimation.zip && \
  chmod 644 /system/media/bootanimation.zip'"

Same for shutdownanimation.zip.

⚠️ The ZIP must use STORED, not DEFLATE. Android's boot animation player memory-maps the frames and cannot inflate them — a normally-compressed zip gives a black screen with nothing in logcat. In Python: zipfile.ZipFile(path, "w", zipfile.ZIP_STORED). With the CLI: zip -0.

⚠️ Keep the frame count modest. These are decoded on a low-RAM device (android.hardware.ram.low is declared), and a long animation at full resolution stutters.

Charging screen

When the device is plugged in while powered off, the bootloader shows images from /vendor/etc/res/images/. There are 116 PNGs covering battery levels and animation frames.

Nolee ships these images as black. To use your own, replace them individually:

adb shell "su -c 'mount -o remount,rw /vendor; \
  [ -d /vendor/etc/res/images_stock_backup ] || cp -r /vendor/etc/res/images /vendor/etc/res/images_stock_backup'"
# then push your PNGs over the originals, keeping the same filenames

⚠️ Keep the filenames — the bootloader looks them up by name. Adding new files does nothing.

Launcher wallpaper and theme

Not a system file — set in the Launcher's own Appearance settings, no root required.


12b.3 What is left after a full rebrand

Once names and images are done, the only remaining "Nolee" is:

⚠️ One thing you cannot rebrand: the Launcher's package name (io.kungcorp.nolee.launcher). It is visible in pm list packages and is what Device Owner is bound to. Changing it means replacing the platform package during a fresh device setup.

⚠️ Restore Nolee reverts names and every image in §12b.2. Custom overlays in /data are wiped. Live boot art is put back from /system/nolee/artwork/ (wrist); desk stock in artwork/land/ stays on /system for the next landscape flip. Device names are put back from /system/nolee/branding.json. Reset always returns to portrait. "Restore to the state it shipped in" would be a lie if the device came back wearing someone else's brand.

So: keep your artwork and names in your own repo and treat the device as a build target, not storage. A restore is the recommended first move when a device misbehaves, and it will take your branding with it. Re-applying it is set_branding plus pushing images, not an afternoon in Figma.

13. Things that will waste your time if you do not know them

Published from Nolee Launcher release launcher-v1.9.12-r2. Source agents.md SHA-256 b10b97c03cae501b41b9cae44a295bbf193f092cf5acf3e30b27211e834d44fe.
A device carries its own copy at /sdcard/AGENTS.md. Where the two disagree, the device is right about the device.