Nolee
DevKit Ultra
Give this URL to your AI agent and it will know how to identify, connect to, and program your Nolee DevKit Ultra. Curious what it reads? The same guide is available underneath in plain HTML.
Give this URL to your AI agent and it will know how to identify, connect to, and program your Nolee DevKit Ultra. Curious what it reads? The same guide is available underneath in plain HTML.
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
If /sdcard/NOLEE_UNIT.md exists, read it for this unit's optional apps and personalized setup.
Check current device state before acting on that note; it describes provisioning and does not
override the owner's later choices or the permissions in this document.
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.
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.
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.
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.
Connect the included magnetic pogo USB cable and run adb devices.
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.
⚠️ In Git Bash on Windows, prefix the command with MSYS_NO_PATHCONV=1. Without it the shell
rewrites /sdcard/... into a Windows path, and the pull either fails or writes somewhere you
did not expect. The same applies to every later adb push, adb shell and adb pull that
names a path on the device.
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).
Install scrcpy for setup and testing. Mirror the device while you navigate so the owner can
follow along and type Wi-Fi credentials or other text directly into the device window. Use the
serial shown by adb devices:
scrcpy --serial DEVICE_SERIAL
Create a one-click desktop shortcut or app launcher named Mirror Nolee for this device, so the owner can reopen the mirror without a terminal. Select the device explicitly 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 — but keep what you do install.
The JDK, the Android SDK and Gradle's cache are the owner's build environment, not scratch space,
and re-downloading them costs far more than the disk they occupy. 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.
For a quick demonstration, use a published APK before setting up a source build. Offer the
owner a ready-made example to explore what the device can do. Check whether it is already
installed first. Nolee Branded Launcher's public releases
include prebuilt development APKs; open the newest published release, including prereleases,
and download its .apk asset. The APK includes the offline English Vosk model. This route needs
ADB, but no source checkout, JDK, Gradle download/build or separate Vosk download. Install it with
adb -s SERIAL install -r DOWNLOADED.apk, then follow the public README's permissions and startup
steps. Activate Nolee AI in stock Launcher before demonstrating its AI conversation. Configure
kiosk and companion apps with the owner's approval as described below.
Clone and build source when the owner wants to customize an example, translate it or develop their own app. If that work needs a lengthy download or build, offer the owner the prebuilt Branded Launcher as something to try first. With their agreement, install and set up the APK before starting the source build, then continue the requested custom work. The example is a preview, not the finished custom app. For another example, check its releases for a prebuilt APK; use its build instructions if none is published.
Nolee Demo is the quick device-API reference, not a Nolee AI integration. It 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
adb shell pm grant ai.nolee.demo android.permission.WRITE_SECURE_SETTINGS
Run both grant commands before opening or kiosking the demo. A successful adb install does
not make the sensor experience ready. BODY_SENSORS permits the optical readings;
WRITE_SECURE_SETTINGS lets the demo enter measurement mode. If the second grant is missing, all
three vitals report needs permission. Re-grant both after an uninstall/reinstall.
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.
Nolee Branded Launcher is the full-app reference, including runtime Nolee-key access, app-defined AI prompts, streaming text/audio, bounded local tools, Profile updates, device settings and companion-app kiosk navigation. Its Android app uses portrait orientation. Read or fork it when the owner's goal is closer to a complete launcher or an AI-powered app:
git clone https://github.com/kungcorp/nolee_branded_launcher.git
Follow its README for owner-approved permissions and startup; build prerequisites apply only when compiling from source. Before kiosking it, ask which companion apps the owner wants: offer Nolee Phone, SMS, Camera, Gallery and Files unless they already have their own set. Install and approve the chosen companions; an unapproved app is blocked in kiosk. Replacement packages also need matching app launch mappings.
Both examples are MIT licensed and indexed at https://nolee.ai/examples. Demo is the smaller
device-API starting point; Branded Launcher shows a deeper implementation of Nolee AI and a full
device interface. Neither replaces the stock Launcher's management or credential lifecycle.
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 | Nolee AI activation, spoken answers, voice, allowance and developer key · 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 short press of the side button anywhere in the Launcher opens
Nolee AI; a long press opens the Power screen. The Nolee AI page under Applications handles
activation, voice and key settings; the
full-screen Nolee AI surface handles voice questions and spoken/text answers. 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.
⚠️ Tell the owner this one too: the keyboard keeps its own language list, and a long press of the globe key is the only way into it. Language under Device sets the three languages Nolee's own screens use; the keyboard starts with English and Chinese enabled and carries hundreds more. Long press the globe, then Language settings → Add keyboard. There is no keyboard row in Settings and no keyboard icon in Apps, so an owner who does not know the gesture cannot type in their own language at all.
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.
adb devices
If nothing is listed, unplug and replug, then try a different USB port and a different cable before investigating the computer. A dead port is the usual cause, takes seconds to rule out, and looks exactly like a driver or permission problem if you start there.
⚠️ 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.
| 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)
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.
All four are painful to discover only after the app has taken over the device.
<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.
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.
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.
The device ships without a screen lock. Setting one would put a credential prompt in front of your app on every wake, which is the opposite of what a kiosk is for — so both the decision and the implementation are yours.
Ask what the owner is actually building. A photo frame on a shelf needs nothing. Something holding
recordings, messages or health history does: without a lock, whoever picks the device up has what
is on it. Your app owns the whole screen and receives KEYCODE_F9 and the camera-lid events
(§11), so a gesture, a passcode, a button sequence, or a re-prompt after idle are all yours to
build — and you choose when they appear, rather than the system choosing for you.
⚠️ Keep it separate from the exit above. A lock that also leaves kiosk hands an unlocked device to whoever guessed it.
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
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:
adb shell am start … into any other package fails with error 101
(LOCK_TASK_MODE_VIOLATION).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 inside kiosk, so tell the owner where the real one is: System → Power → Restart or shut down. 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
content call and not an IntentBecause 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 |
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.
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.
A device with a primary app chosen normally boots into it. An optional demo can be preselected
with set_primary_app --arg PACKAGE --extra defer_start:b:true; the device then opens Getting
started across reboots until the owner starts that primary app through Start kiosk or an approved
request_kiosk call. A successful start enables normal boot-into-kiosk behavior. The provider's
status response includes primary_pending_start so your app can verify this state.
For ordinary primary-app selection, 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:
status reads interrupted,
lock_task reads none, and the wearer can leave your app until the next restart, which locks
normally. Re-read both after any restart instead of assuming the lock survived, and call
request_kiosk again if the device must be locked before it is handed back.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.
Android's crash dialog offers App info → Force stop → Uninstall. These actions would let an
end user stop or remove the kiosk target and leave the session. The Launcher blocks those actions
and sets hide_error_dialogs=1 for the length of the session, so the dialog does not appear.
What this means for you while developing:
adb logcat -s AndroidRuntime:E is how you see the crash. The stack trace is
still there — only the dialog is gone.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.
The owner activates the included Nolee AI service at System → Applications → Nolee AI. The included year begins when they tap Activate Nolee AI. Nolee AI, custom applications and off-device applications all use the same allowance.
From the Launcher, briefly press the F9 side button to open Nolee AI; speaking starts immediately. A short press while the overlay is open closes it. Hold the side button to open the Power screen. The on-screen control is Hold to ask, then Release to send. If no speech is heard during the opening eight-second listen, the overlay and listening glow fade out together. After an answer, it listens for a follow-up for ten seconds; silence then ends listening but keeps the conversation visible until thirty idle seconds closes the overlay. Silence is not sent as a question. The session log scrolls so earlier questions and answers stay readable. Spoken answers on the settings page turns spoken replies off for text-only answers; questions are still spoken into the microphone. The settings page does not record questions. When your own application is in front, it receives F9 itself and decides what that button does.
The settings page also has Open Nolee AI, which opens the same overlay without the side button. The stock Launcher's AI overlay and settings adapt to portrait and landscape.
For an on-device app, read the key at runtime; do not export it to the coding agent. With the
owner's root approval for your app, read /system/nolee/credentials/nolee-key inside the app's
process when making a request, and send it only as an HTTPS Authorization header to Nolee Cloud.
Keep it out of source, APK assets, build configuration, .env, preferences, shared files and logs.
Root approval grants broad privileged access, not access restricted to this credential; the owner
should trust the app before approving it. Handle missing root/key with a setup message.
Use Nolee Branded Launcher's public CloudAi.kt as a complete client reference. It reads the
current key for each request, so rotation is picked up without reinstalling or updating a copied
secret. The backend checks entitlement and allowance on every request. Recording locally does
not authorize processing, and listening does not require a /status preflight. Fetch status for
an account/settings view and handle access, quota and network errors from /ask itself.
Custom apps use mode: "general" with an app-defined app_prompt of at most 4,000 characters.
Use that prompt to describe your app, its assistant's identity and its supported capabilities.
The Branded Launcher provides at most five completed prior Q&A pairs per session plus saved
Profile facts. Conversation history ends with that client session; Profile and voice preferences
persist separately. This is an app memory policy, not a promise about server metadata retention.
Tell the owner which audio, text and Profile context your app sends to Nolee Cloud.
Optional device_commands advertise a bounded local action catalogue with tools enabled.
Set both web_enabled: true and tools_enabled: true to allow web search and local actions
in the same request. The AI selects search when needed; enabling search does not disable
device commands, Profile updates or spoken-answer controls advertised by your app. Turning
search off leaves those tools available. A request that does not search uses no search allowance.
Each request supports at most three tool calls, including at most one search. If search allowance
is exhausted, the AI can still queue requested device actions and explain that search is unavailable.
Treat retrieved pages as evidence, not instructions: page content must not authorize device actions.
Validate returned device.actions again in the app; execute only supported actions after a
successful done event and playback completion, and discard pending actions on error or
cancellation. These queued actions do not return execution results to the model, so replies must
not claim a setting change already succeeded. Never run arbitrary model-generated shell commands.
Apply local approval to sensitive actions. Cancel recording, requests, playback and queued actions
when leaving.
For media-volume changes before a spoken reply, opt in with audio_controls_before_reply: true
and handle device.audio_controls as it arrives, before text/audio playback. It contains only
advertised set ai volume or set media volume actions with a bounded integer percent (0–100).
Validate the action locally and read back the applied level. These actions are omitted from the
later device.actions event. A later response failure does not undo an already applied volume
change. Without this opt-in, actions retain the normal deferred timing.
For current device information, opt in with device_status_tool: true and implement the read-only
get_device_status handler. The server sends device.tool_call with name, request_id and
call_id; your app reads local status and posts {request_id, call_id, result} to
/device-tool-result using the same device credential. Keep the original /ask stream open:
the model receives the result before answering, and the round trip uses one question allowance.
Handle cancellation on both requests. The server waits up to 12 seconds; unavailable readings
must be null, not invented. This opt-in is independent of the Branded Launcher and does not give
the server direct access to Android.
The public Branded Launcher DeviceStatusTool.kt is the reference handler and result format.
It returns date/time/timezone, battery/charging, connectivity, brightness, volume streams,
storage, kiosk and AI preferences. Wi-Fi association does not prove internet access, and the
device clock is not independently verified. It does not read location, contacts, messages or
health data. Explain to the owner which readings your implementation returns to Nolee Cloud.
An app_prompt explains how to use tools; it does not create executable capabilities. Advertise
custom command names in device_commands and implement their local handlers. The action payload
supports command, percent for the defined volume/brightness commands, and profile for
update profile. Arbitrary argument schemas and custom result tools are not accepted by this
interface. The status round trip above supports get_device_status specifically.
Off-device applications need a deliberately exported copy instead.
The Nolee key survives an Android factory reset. It is root-only on the device and must not be printed into a terminal, pasted into chat, committed, placed in a command-line argument or written to shared storage. With AI agent access (ADB) enabled, stream it directly into a private laptop file:
adb exec-out su -c nolee-key-export > .nolee-key
The command is silent and writes only the key to stdout. Redirect stdout as shown; running it
without redirection displays the secret. Treat .nolee-key as a password and pass its path to an
application instead of interpolating its contents into a command. The owner can also use the
two-step Copy Nolee key control on the Nolee AI screen when deliberate clipboard access is
more appropriate.
Rotate Nolee key creates and installs a replacement on the device and disables the old key. It keeps the same allowance and expiry; no support request is needed. After rotation, export the current key again and update any external application or private key file that used the old copy, because requests using that copy are rejected.
Nolee Cloud accepts the key as Authorization: Bearer <key> at:
https://rqcdmkgonibfiolgbexo.supabase.co/functions/v1/nolee-ai
GET /status reports pending, active or expired state without consuming allowance.GET /usage reports the active plan's customer-visible allowance.POST /ask accepts either question text or audio_wav_base64, plus audio_enabled,
web_enabled, tools_enabled and a UUID client_request_id. For custom app behavior include
mode: "general" and app_prompt; request return_transcript for spoken-question text.POST /device-tool-result returns an opted-in status tool result for an active request./ask responds as server-sent events. Handle transcript, text.delta, audio.delta, sources,
usage, error and done, plus device.actions when your app advertises local commands.
Handle device.audio_controls and device.tool_call when opting into their flows above.
Audio deltas are base64 24 kHz mono PCM and may begin after text. Input audio is a 16 kHz mono
PCM WAV. Do not blindly retry a failed stream: it may already have consumed allowance.Your app uses the existing device key. Direct the owner to the stock Nolee Launcher for activation and key management. Changing the shared key can interrupt every application that uses it, so do not manage it as part of your app's setup or normal requests.
Intents for actions, a ContentProvider for queries and for everything privileged. No AIDL, no client library.
⚠️ Two package prefixes, one product. The platform shell is io.kungcorp.nolee.launcher; the
applications are ai.nolee.* — ai.nolee.files, ai.nolee.camera and the rest. Both are Nolee.
Use each identifier exactly as written; they are not interchangeable.
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).
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) |
content://io.kungcorp.nolee.launcher.state/appearance |
one row: schema_version · theme · background · accent · grain_enabled · grain_strength |
content://io.kungcorp.nolee.launcher.state/configuration |
one row: schema_version · requested/active language · requested/active orientation · restart-pending fields |
appearance.background and appearance.accent are signed Android ARGB integers after the selected
preset or custom colours have been resolved. grain_enabled is 0/1 and grain_strength is 0–100.
Register a ContentObserver on the exact appearance URI when your app should follow owner changes
without restarting.
configuration.language_requested and language_active are BCP-47 tags (en-US, zh-CN or
zh-HK). Orientations are portrait or landscape. The three pending fields are 0/1.
Render from language_requested, which is the language the owner chose. Override
attachBaseContext and wrap the base in a Configuration carrying that locale through
createConfigurationContext, then recreate the activity when a later query answers with a
different language; getString and your values-zh-rCN and values-zh-rHK resources follow on
the next frame. language_active is what Android itself is rendering. The system locale changes
only on the next restart, so Settings, the keyboard and notifications stay in the old language
until then, and language_restart_pending is 1 while the two disagree. An app that renders from
the active language instead leaves the owner unable to read its screens until they restart the
device.
Orientation works the same way: follow orientation_requested immediately if your app can re-lay
out live, and read orientation_active for what Android is rendering now when it cannot.
Register a ContentObserver on the exact configuration URI to receive owner-setting changes.
Both rows are read-only, need no permission and use schema_version=1.
thermalLong-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.
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.
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.
| 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.
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.
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"
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 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 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.
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.
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.
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.
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.
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.
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).
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
KEYCODE_BACK. Handle it with an
ordinary BackHandler/onBackPressed; do not re-implement it. ⚠️ Re-implementing it does not
work. The gesture is handled below your app, so your own edge detector never receives the finger
and never fires, at any start position.settings put global isSlide 0, but then there is no back gesture on the device at all, so
give the owner another way back before you do it. A factory reset restores it.KEYCODE_F10 or
expose a wrist-lift toggle. The accelerometer remains available while the device is awake.Settings.Global.palm_press_screen_enabled is the runtime switch (1 on,
0 off). Apps receive no palm-cover KeyEvent; use the Launcher's
existing screen-sleep control if the app itself needs to turn the display off.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 your app
through the Launcher provider. Replace com.example.myapp with your app's package name:
adb shell content call --uri content://io.kungcorp.nolee.launcher.state --method set_root_access --arg com.example.myapp --extra enabled:b:true
After that call, su launched by your app 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.
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.
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.
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:
/system/nolee/artwork/ wrist,
/system/nolee/artwork/land/ desk)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.
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.
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).
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 |
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:
desc.txt must say 410 502 — never 502 410logo / boot / shutdown)logo_land / boot_land / shutdown_land).
The converter rotates 90° CW into the framebuffer. Pre-rotated 410×502 desk PNGs also work
(taller than wide → no rotate).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.
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
Use set_artwork for normal customization. The dd recipe below is for direct boot-logo
partition recovery.
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.
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.
When connected to power while shut down, the device runs /vendor/bin/charge. It displays
Nolee artwork, the live battery percentage and a lightning symbol. This runs outside Android's
app interface; Launcher themes and set_artwork do not change it. The charging screen stays in
portrait even when the Launcher is in landscape.
Check which charging interface this device has before editing:
adb shell ls /system/nolee/charging/SHA256SUMS
If that file is missing, the device uses an earlier charging layout. Pull its current images and inspect their dimensions before replacing anything; the layout below does not apply.
The current layout uses RGB PNGs in /vendor/etc/res/images/. The active filename suffix is
_480X800 — it is a resource name, not the image size. Keep these names and dimensions:
indeterminate0_480X800.png: 408×502, background for 0–99%, including your wordmark,
labels and charging symbol. The percentage is drawn over it from separate digit images.indeterminate6_480X800.png: 408×502, background for 100%, including the full-charge label.
Keep frames 1–5 at the same size if replacing the full image set.number_0_480X800.png through number_9_480X800.png: 54×115 each, one digit per PNG.number_percent_480X800.png: 48×115, the percent sign.The first digit starts at x=60, y=218, with a 54 px advance and no leading zeros. The percent sign follows the last digit with a 4 px gap. The shipped digit baseline is 97 px inside each sprite. Keep the rectangle x=60…274, y=218…333 clear in your background so the live percentage does not cover your artwork. Use the same opaque background colour for the background and digit sprites: the renderer copies pixels and does not blend transparency. Changing the background alone leaves the percentage font and colour unchanged — replace all ten digits and the percent sign to restyle the complete screen. Keep the live percentage; do not paint a fixed number into the background. Leave error images intact unless designing equivalent error indicators at their existing dimensions.
Back up the live images to your computer before customizing. Push replacements to a staging folder, then copy only your edited PNGs into the live directory as root:
adb pull /vendor/etc/res/images charging-backup
adb shell mkdir -p /data/local/tmp/my-charging
adb push indeterminate0_480X800.png /data/local/tmp/my-charging/
# Push the full-charge background and any edited digit sprites to the same folder.
adb shell "su -c 'mount -o remount,rw /vendor && cp /data/local/tmp/my-charging/*.png /vendor/etc/res/images/ && chmod 644 /vendor/etc/res/images/*.png; sync; mount -o remount,ro /vendor'"
Pull the installed files back and compare them with your originals, then shut down while connected to power to check the physical screen. Android screenshots cannot capture this mode. Custom images survive ordinary reboots. Restore Nolee and factory reset restore the shipped charging screen on the first Android boot after the wipe, just like the boot artwork.
The known-good copy is /system/nolee/charging/. Customize the live images only. Changing
that baseline changes what a factory reset restores and can prevent reset preflight from passing.
Keep /vendor/bin/charge intact: it owns the percentage layout, power-button behaviour and
charging-mode control. You can restore just the charging screen without wiping personal data:
adb shell "su -c 'sh /system/nolee/charging/restore.sh'"
Not a system file — set in the Launcher's own Appearance settings, no root required.
Once names and images are done, the only remaining "Nolee" is:
AGENTS.md on /sdcard, which remains available to the owner's AI agent.Nolee/nolee_devkit_ultra/Nolee:…. Leave
them — set_branding does not rewrite them, and doing so by hand costs a full dexopt boot.⚠️ 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.
dpm set-device-owner — it will fail, and it is not how kiosk works here. Use request_kiosk./system survives a factory reset; /data does not.screen_off_timeout is maxed). Do not add your own wake lock
expecting the default; equally, do not assume the screen ever turns itself off.Everything in this section is the owner's, not yours to design around — but you are the one who will be asked, so it is here rather than on a card in the box.
⚠️ No accuracy claim is made for the health figures. Heart rate, blood oxygen and blood pressure all come from one optical sensor at the wrist, and blood pressure is reported as an estimate. Build with them freely — workouts, trends, sleep, experiments, whatever the owner wants — and where a build turns on an absolute number being right, compare against a proper instrument first. Day-over-day change from the same sensor on the same wrist is the sturdier signal either way. Label an estimate as an estimate on screen, and keep these figures out of anything presented as diagnosis or medical monitoring. §11 covers how to take a reading.
⚠️ The battery is a sealed lithium cell. Charge it with the supplied magnetic puck and nothing else, and do not open the case: the cell sits directly behind the back plate, and a punctured lithium cell burns. Opening the case also ends the hardware warranty.
⚠️ 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.
⚠️ Cellular is not guaranteed to work where the owner lives. An LTE modem and a nano-SIM tray are fitted, but registration depends on the local bands and on a SIM the network will accept for this device. Confirm it on the owner's own SIM before designing around it, and give any feature that needs it a path that still works on Wi-Fi.
⚠️ Being open costs the device some other people's apps. Root and unlocked system access are the point of this hardware, and they are also exactly what Play Integrity checks for. Google Play, banking, payment and DRM video apps can refuse to install or run here, and that refusal is theirs to make. Build what the owner wants natively rather than planning around a third-party app that may decline to start.
The short version. Full terms are at nolee.ai/warranty and are the ones that apply.
/system/nolee/unit.json. A return address comes back with the authorisation; nothing should be
posted before that.Nolee is a brand of KungCorp Limited, a company registered in Hong Kong.
Published from Nolee Launcher release launcher-v1.10.25-r4. Source agents.md SHA-256 4e3be7e28819b7d0c7c183ed80c9f4938ea5fa031c40b6dc3b46091718b55c73.
A device carries its own copy at /sdcard/AGENTS.md. Where the two disagree, the device is right about the device.