Skip to content

singhangadin/NoteFunctions

Repository files navigation

NoteFunctions

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

Invoking NoteFunctions live from AppFunction Studio's Debug tab
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."


What it demonstrates

  • 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.

Tech stack

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 = 37 and AGP 9.2? appfunctions:1.0.0-alpha09 requires API 37 and AGP 9.1+, which in turn needs Gradle 9.4.1.

The in package gotcha: in is a hard keyword in Kotlin. The Gradle namespace / applicationId strings are plain in.singhangad.notefunctions, but every package and import escapes it with backticks — package `in`.singhangad.notefunctions.


Architecture

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

MVI

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.

  • notesNotesUiState (notes, loading) + NotesEvent.DeleteNote.
  • editorNoteEditorUiState (title, content, isEditing, isSaved) + Load / TitleChanged / ContentChanged / Save.

Navigation

Navigation 3 with two routes (NavigationKeys.kt):

  • Main → notes list.
  • NoteEditor(noteId: Int? = null) → editor; null = new note, non-null = edit.

AppFunctions

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 & run

# 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:testDebugUnitTest

The android CLI can manage the SDK/emulators too — e.g. android sdk install platforms/android-37.0, android emulator start <avd>.


Testing

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:testDebugUnitTest

Dev-testing AppFunctions on a physical device

AppFunctions only execute on Android 16 (API 36) or higher — on older devices AppFunctionManager.getInstance() returns null. So use a physical device running Android 16+.

  1. Enable USB debugging on the device (Settings → Developer options), connect it, and confirm it's visible:

    adb devices
  2. Install the app:

    ./gradlew :app:installDebug      # or: android run
  3. 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.notefunctions

    You should see createNote, editNote, deleteNote, listNotes. Run adb shell cmd app_function on its own to see the full command usage (you can also check enabled state and toggle functions from here).

  4. Invoke them. A caller needs the android.permission.EXECUTE_APP_FUNCTIONS permission. The easiest path is the bundled AppFunctions Studio test agent in runner/:

    # Install the test agent (one time), then launch it with elevated privileges
    adb install runner/AppFunctionStudio.apk
    ./runner/startAppFunctionStudio

    The script verifies the agent is installed, adds it to the AppFunctions allowlist (required on SDK 37+), and starts it via ShellIdentityInstrumentation so it holds EXECUTE_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_function to 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.)

  5. 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 → listNotes returns 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 with AppFunctionManager.setAppFunctionEnabled(NoteFunctionsIds.CREATE_NOTE_ID, …).


Status

AppFunctions is experimental (Android 16+, API subject to change). This app builds and is testable today; broad assistant invocation is still rolling out.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors