Mobile apps · Android

Docs › Mobile apps

Android

Jetpack Compose and Views on Android 7 and later, with nothing to mark.

Figma-style comments for a running Android app. Tap an element, write a note, and it reaches your coding agent (Claude Code, Codex, Cursor, Gemini CLI, Copilot and others) over MCP with a screenshot, the file and line it was written at, what TalkBack would call it, and the app's recent warnings from logcat. It is the native Android client of the same Notato server the web, iOS, .NET MAUI, React Native and Flutter SDKs use: the notes, the board, the MCP tools and the status loop (open, acknowledged, resolved, revert) are the same.

Android 7 (API 24) and later, for apps built with Views, Jetpack Compose, or both. Two artifacts:

Quick start#

npx notato dev                      # the server your agent reads, on http://localhost:4747
adb reverse tcp:4747 tcp:4747        # makes it localhost on the emulator or a USB device too

Add the artifacts from Maven Central to debug builds only, so release builds ship nothing of Notato:

dependencies {
    debugImplementation("dev.notato:notato-compose:0.1.1")   // or notato-android for a Views-only app
}

Then start it from the manifest, with no code at all, in src/debug/AndroidManifest.xml (the debug build's manifest, merged over the main one):

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application>
        <meta-data android:name="notato.project" android:value="shop-android" />
    </application>
</manifest>

Every setting has a notato.<key> (see Configuration). That is all a debug-only setup needs: code in src/main that names Notato would not compile in a release build, which does not have it.

To start it from code instead (to pick settings at runtime), do it in an Application subclass in src/debug, and name it in src/debug/AndroidManifest.xml:

// src/debug/java/com/example/shop/DebugShopApp.kt
class DebugShopApp : ShopApp() {        // ShopApp made `open`; or Application(), if the app has none of its own
    override fun onCreate() {
        super.onCreate()
        Notato.start(this, NotatoConfig(project = "shop-android"))
    }
}
<!-- src/debug/AndroidManifest.xml. tools:replace is needed only when the main manifest names an Application of its own. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
    <application android:name=".DebugShopApp" tools:replace="android:name" />
</manifest>

Code in src/main that marks what is private (Notato.mask, Modifier.notatoMask) goes through a small function of the app's own, with the real one in src/debug and one that does nothing in src/release. The sample does exactly that (sample/src/debug/…/NotatoHooks.kt and sample/src/release/…/NotatoHooks.kt), and its release build has no Notato in it. An app that wants Notato's API in every build can use implementation instead; it does nothing until it is started.

Run the debug build: a small dark toolbar appears in the corner (drag it anywhere; it stays where you leave it, and its chevron folds it into one round button that remembers being folded). Tap Annotate, tap what you want to comment on, write a note, press Send. Ask your agent to "watch Notato and fix what comes in": everything people write reaches it, unless they keep it between themselves (see People only and asides). Everything else is in the toolbar's ⋯ sheet: pins, the notes list, settings, hiding the toolbar or turning Notato off, and the server's state, with Retry when it cannot be reached.

The app has to be allowed plain http to the server. A network_security_config in the debug build that permits cleartext to localhost is enough, and leaves the release build with none (see sample/src/debug/res/xml/network_security_config.xml and the sample's src/debug/AndroidManifest.xml).

Where it is in the code#

Nothing to mark:

Configuration#

In code (NotatoConfig), or as <meta-data android:name="notato.<key>"> in the manifest, with debug.notato.<key> system properties over either, so a build can be pointed elsewhere without rebuilding it (adb shell setprop debug.notato.server http://localhost:4790, then restart the app):

KeyDefault
project(required)Project id on the server. Letters, digits and . _ - @, not only dots
modedevdev, test or agent (see below)
serverhttp://localhost:4747 (none in test mode)An empty value (none as a system property) means no server
tokenA project token (notato_…) for a shared notato serve. Sent only to the configured server (its scheme, host and port), never to one typed into Settings
enabledtrueWhether Notato is on at launch. The app can switch it at runtime
showToolbar, toolbarPositiontrue, BOTTOM_ENDThe floating toolbar and the corner it starts in. People can drag it; where they leave it is remembered
shakeToToggletrueShaking the device shows or hides the toolbar (listened for only while the app is in the foreground)
authorThe name on this person's notes. They can change it in the toolbar's settings
screenshotstrueA server that has screenshots off wins either way
maskInputson in test and agent modeCover text fields in screenshots and leave their values out of notes (password fields always are). See What is never recorded
rememberRuntimeStatetrueKeep runtime choices (on or off, toolbar, name, a server typed in, the toolbar's place) across launches
captureLogs, logLimittrue, 50Attach the app's own warnings and errors from logcat
composeSourceInfotrueTurn on Compose's inspection data (file and line for composables)
appName, appVersionthe app's label and versionRecorded on every note
maxScreenshotScale2Pixels per dp screenshots are kept at. Phones are 2.6x to 3.5x; 2x is plenty

At runtime: Notato#

Notato.stateA StateFlow<NotatoState>: on or off, toolbar, annotating, connection, notes, how many are not sent yet. Notes read from the server's list come without their context and steps (see Pins)
enable(), disable(), setEnabled(…)On and off. A choice made here wins over enabled in the configuration until resetRuntimeState()
showToolbar(), hideToolbar()The toolbar. Off still lets the app drive Notato from code
startAnnotating(), stopAnnotating()The next tap selects what is under it
select(view), select("#save")Select an element as if it had been tapped, and open the note for it
annotate("#save", comment, options)Make a note with no UI (a suspend function), as a person or (with agentName) an agent. peopleOnly = true keeps a person's note from the agent
packageNotes(upload)Test mode: the device's notes as a bundle zip
mask(view, true / false / null)Mark a view private (covered in screenshots, none of its text recorded), not private (a field recorded even with maskInputs), or back to the default. See What is never recorded
recordRequest(entry)Add an HTTP request to the network context of later notes, from any thread. Its address is kept without the query string, which routinely carries tokens
val state by Notato.state.collectAsState()
Switch(checked = state.isEnabled, onCheckedChange = { Notato.setEnabled(it) })

The sample's Feedback tab does each of these.

What a note carries#

The same schema as the other SDKs (environment.platform is android):

What is never recorded#

// Views
Notato.mask(cardNumber)                  // private: this view and every view in it
Notato.mask(searchField, false)          // not private: a field recorded even when maskInputs is on
Notato.mask(cardNumber, null)            // back to the default

// Compose (notato-compose): the same rules
Text(user.email, Modifier.notatoMask())
Column(Modifier.notatoMask()) { /* everything in it */ }
OutlinedTextField(query, onQueryChange, Modifier.notatoMask(false))

false opts a text field out of maskInputs (on a container, the fields in it). Password fields stay masked whatever the mark, and a private view or composable around a field marked false wins over it: what is inside something private is always private. Modifier.notatoMask only adds a semantics property, as testTag does, so TalkBack reads the same. This is the web SDK's data-notato-mask.

Selectors#

What a note's selector looks like, and what an agent passes to notato_annotate or Notato.annotate:

#saveThe View's resource id name, or the composable's test tag (Modifier.testTag("save"))
buttonThe role (button, text, heading, textbox, switch, checkbox, img, …) or the class (MaterialButton, Text); * for any
:text("Add to cart")The text or label contains this, ignoring case
:nth(2)The second match on screen, in drawing order
ProductScreen …A leading screen name, for people; ignored when matching

Modes#

People only and asides#

Everything people write reaches the agent: new notes and every reply. Two switches keep something between people instead:

A note the server has is changed through the server, which records the change. A note still on the device (test mode, or one made while the server was down) is changed on the device, with the same entry added to its thread, so a package carries the history. If the switch is flipped while such a note is on its way to the server, the server is told once it has the note.

Pins#

Each note made on Android has a pin on its screen, on the element it is about: the view it was made on while that view still shows the same element (the same id and words; a list's recycled row shows another item, so it is let go of), else whatever its selector finds now, else where the element was. A screen draws the pins of its newest 150 notes made on Android. Notes made on the web, iOS or anywhere else have no pin (their elements are not this app's): the Notes list in the ⋯ sheet shows them, after the screen's first ten notes, so every note on a screen can be opened from its row or its pin. On a screen crowded past that (more than 40 notes without a pin), the list says how many more are on the board.

Pins cost nothing on a still screen: they are placed again only after the window has drawn (a scroll, a layout, a recomposition) or a note has changed, and the screen is read at most twice a second. The route (which Activity, Fragment and screen composable shows) is worked out again the same way. When the app connects, it reads the project's notes as summaries (fields=summary: without each note's context and steps, which are most of a note's size and which only the board and the agent use) and matches them by id, so a project with thousands of notes stays quick; a server that does not know fields sends them whole.

Notes made here are kept on the device, their screenshots as files, until the server has them: nothing is held in memory but the note, and a test-mode package is written to its file as it goes. Each file is written whole or not at all, and a note that cannot be read back is said in the log and skipped, never the others.

How the overlay works#

Notato's UI is plain Views in a layer added to the top window of the resumed Activity. The layer passes touches through to the app except on its own controls, and while annotating, when it takes the next tap. A dialog is a window of its own on Android, so while a full-screen one shows (a Compose Dialog with usePlatformDefaultWidth = false, a full-screen DialogFragment) the layer moves into it. Notato finds the app's windows the way Espresso does, through the window manager's list of views.

Known limits#