A small but complete Android notes app that demonstrates the experimental AppFunctions API — Android's on-device equivalent of the Model Context Protocol (MCP). The app exposes its note operations (list / create / edit / delete) as tools an on-device AI agent can discover and call, while real users do the same things through a Material 3 UI. Both paths run through the same domain logic, so a note an agent creates shows up live in the list, and vice-versa.
Package: in.singhangad.notefunctions

The four functions discovered by the system, invoked from AppFunction Studio's Debug tab.
Built as the companion sample for the blog post "Android App Functions: turning your app into a tool an agent can call."
- AppFunctions — four
@AppFunctions exposed to the system, with KDoc-as-tool-description, predefined error types, and a Hilt-provided function factory. - Room persistence — notes survive the app being closed.
- Clean Architecture — strict domain / data / presentation separation.
- Hilt — dependency injection across the whole graph.
- MVI — unidirectional state for every screen (one immutable state out, events in).
- Jetpack Compose + Navigation 3 + Material 3 — list screen and a dedicated new/edit screen.
| Area | Choice |
|---|---|
| Language | Kotlin 2.3.20 |
| Build | AGP 9.2.1, Gradle 9.4.1, KSP 2.3.9 |
| SDK | minSdk 33, targetSdk / compileSdk 37 |
| UI | Jetpack Compose (BOM 2026.03.01), Material 3, Navigation 3 (1.0.1) |
| DI | Hilt 2.59.2, hilt-navigation-compose 1.4.0-rc01 |
| Persistence | Room 2.8.4 |
| AppFunctions | androidx.appfunctions:* 1.0.0-alpha09 |
Why
compileSdk = 37and AGP 9.2?appfunctions:1.0.0-alpha09requires API 37 and AGP 9.1+, which in turn needs Gradle 9.4.1.The
inpackage gotcha:inis a hard keyword in Kotlin. The Gradlenamespace/applicationIdstrings are plainin.singhangad.notefunctions, but everypackageandimportescapes it with backticks —package `in`.singhangad.notefunctions.
Clean Architecture with three layers; dependencies point inward (presentation and data depend on domain, never the reverse).
in.singhangad.notefunctions
├── domain/ ← pure Kotlin, no Android/framework types
│ ├── model/Note ← core entity
│ ├── repository/NoteRepository← contract (interface)
│ └── usecase/ ← Observe / Get / GetAll / Create / Edit / Delete
├── data/
│ ├── local/ ← Room: NoteEntity, NoteDao, NoteDatabase
│ └── repository/NoteRepositoryImpl
├── di/ ← Hilt modules
│ ├── DatabaseModule ← @Provides Room database + DAO
│ └── RepositoryModule ← @Binds NoteRepository → NoteRepositoryImpl
├── presentation/ ← MVI: each screen has Contract + ViewModel + Screen
│ ├── notes/ ← list screen
│ └── editor/ ← new/edit screen
├── appfunctions/
│ ├── Note ← @AppFunctionSerializable agent-facing contract
│ └── NoteFunctions ← the @AppFunction tools (Hilt @Inject)
├── NoteFunctionsApplication ← @HiltAndroidApp + AppFunctionConfiguration.Provider
├── MainActivity ← @AndroidEntryPoint
├── Navigation / NavigationKeys ← Navigation 3 back stack & routes
└── theme/ ← Material 3 theme
Every screen exposes a single immutable …UiState via StateFlow and accepts a
single onEvent(…Event). The composable only renders state and dispatches events;
all transitions happen in the ViewModel.
notes—NotesUiState(notes, loading) +NotesEvent.DeleteNote.editor—NoteEditorUiState(title, content, isEditing, isSaved) +Load / TitleChanged / ContentChanged / Save.
Navigation 3 with two routes (NavigationKeys.kt):
Main→ notes list.NoteEditor(noteId: Int? = null)→ editor;null= new note, non-null = edit.
appfunctions/NoteFunctions.kt exposes four suspend functions, each annotated
@AppFunction(isDescribedByKDoc = true) and delegating to a domain use case:
| Function | Purpose |
|---|---|
listNotes |
Returns all notes (or null if none). |
createNote(title, content) |
Creates a note; rejects a blank title with AppFunctionInvalidArgumentException. |
editNote(noteId, title?, content?) |
Edits an existing note; AppFunctionElementNotFoundException if missing. |
deleteNote(noteId) |
Deletes a note. |
Because NoteFunctions has constructor dependencies, the system can't build it
alone. NoteFunctionsApplication implements AppFunctionConfiguration.Provider
and hands the AppFunctions runtime the Hilt-provided instance:
@HiltAndroidApp
class NoteFunctionsApplication : Application(), AppFunctionConfiguration.Provider {
@Inject lateinit var noteFunctions: NoteFunctions
override val appFunctionConfiguration get() =
AppFunctionConfiguration.Builder()
.addEnclosingClassFactory(NoteFunctions::class.java) { noteFunctions }
.build()
}At build time, the AppFunctions KSP compiler (with ksp { arg("appfunctions:aggregateAppFunctions", "true") })
generates assets/app_functions.xml (the schema the OS indexes) and
NoteFunctionsIds (function-id constants for runtime gating).
# Build the debug APK
./gradlew :app:assembleDebug
# Install and launch on a connected device/emulator
./gradlew :app:installDebug
# or, with the android CLI:
android run
# Run unit tests
./gradlew :app:testDebugUnitTestThe android CLI can manage the SDK/emulators too — e.g.
android sdk install platforms/android-37.0, android emulator start <avd>.
JVM unit tests (app/src/test/) cover the domain use cases and both MVI
ViewModels, backed by an in-memory FakeNoteRepository and a MainDispatcherRule
(no Room/Android needed):
NoteUseCasesTest— trimming, blank-title rejection, partial edits, not-found, delete.NotesViewModelTest— initial load, empty state, delete.NoteEditorViewModelTest— load new vs. existing, save-create, save-edit, blank-title no-op.
./gradlew :app:testDebugUnitTestAppFunctions only execute on Android 16 (API 36) or higher — on older
devices AppFunctionManager.getInstance() returns null. So use a physical device
running Android 16+.
-
Enable USB debugging on the device (Settings → Developer options), connect it, and confirm it's visible:
adb devices
-
Install the app:
./gradlew :app:installDebug # or: android run -
Confirm the system indexed your functions. The OS picks up the generated schema after install (give it a few seconds):
adb shell cmd app_function list-app-functions \ | grep --after-context 10 in.singhangad.notefunctionsYou should see
createNote,editNote,deleteNote,listNotes. Runadb shell cmd app_functionon its own to see the full command usage (you can also check enabled state and toggle functions from here). -
Invoke them. A caller needs the
android.permission.EXECUTE_APP_FUNCTIONSpermission. The easiest path is the bundled AppFunctions Studio test agent inrunner/:# Install the test agent (one time), then launch it with elevated privileges adb install runner/AppFunctionStudio.apk ./runner/startAppFunctionStudioThe script verifies the agent is installed, adds it to the AppFunctions allowlist (required on SDK 37+), and starts it via
ShellIdentityInstrumentationso it holdsEXECUTE_APP_FUNCTIONS. (Agent package:com.example.appfunctionsstudio.)AppFunction Studio has two tabs:
- Debug — search
NoteFunctions, expand a function (e.g.createNote), fill its arguments, and tap Run. This invokes the function directly, with no API key — it's the quickest way to confirm everything works, and it's what the demo above shows. - Agent Demo — natural-language prompts ("List all the notes") driven by
Gemini, so it needs a Gemini API key in Settings (Google AI Studio →
personal account; preview models may be gated, prefer
gemini-2.5-flash).
Alternative: Gemini in Android Studio — prompt it to "Execute
adb shell cmd app_functionto learn how the tool works, then act as a chat agent invoking AppFunctions for this app," and it drives your functions over ADB. (Full on-device assistant integration is in private preview/EAP as of mid-2026.) - Debug — search
-
Watch it work both ways. Create a note via the Debug tab or an agent → it appears in the on-screen list immediately; add one in the UI →
listNotesreturns it. That shared state is the whole point.
Tip: the four functions are
enabled_by_default = true. To gate one, set@AppFunction(isEnabled = false)and flip it at runtime withAppFunctionManager.setAppFunctionEnabled(NoteFunctionsIds.CREATE_NOTE_ID, …).
AppFunctions is experimental (Android 16+, API subject to change). This app builds and is testable today; broad assistant invocation is still rolling out.