Ali Mansour
Ali Mansour
Ali Mansour
Ali Mansour
Ali Mansour

Senior Software Engineer

Senior Mobile Engineer

Content Creator

Tech Speaker

Exposing Android Features to AI with AppFunctions

July 15, 2026 Code
Exposing Android Features to AI with AppFunctions

For years, Android users interacted with apps by tapping buttons, filling text fields, and navigating screens. Now, users interact through natural language with on-device assistants like Gemini. Instead of navigating four screens to send money, a user expects the assistant to do it directly.

Previous integration methods had limits. Standard Intent filters launched activities and required manual user interaction. App Actions introduced automation, but they relied on predefined Google schemas that didn’t fit custom workflows.

The androidx.appfunctions library lets you expose Kotlin suspend functions directly to Android as tool schemas. The OS indexes these tools, matches them to assistant prompts, and executes them in the background.

Here is how to configure the compiler, expose use cases, update the UI, and secure execution.


1. Project Configuration

AppFunctions uses a runtime library alongside a Kotlin Symbol Processing (KSP) compiler plugin to generate platform descriptors and metadata schemas at build time.

Add the dependencies to your module’s build.gradle.kts:

// build.gradle.kts
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.ksp)
}
dependencies {
    implementation("androidx.appfunctions:appfunctions:1.0.0-alpha10")
    ksp("androidx.appfunctions:appfunctions-compiler:1.0.0-alpha10")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}
ksp {
    arg("aggregateAppFunctions", "true")
}

Setting aggregateAppFunctions = true combines schema metadata from multiple feature modules into one manifest at build time.


2. Defining Schemas with KDoc and Annotations

The AI model needs accurate descriptions of your functions and their parameters to choose the right tool and provide valid arguments. You write regular KDoc comments, and KSP converts them into JSON Schemas.

Input and Output Data Contracts

Every data class passed to or returned from an AppFunction must be annotated with @AppFunctionSerializable.

package dev.alimansour.smartwallet.functions.model
import androidx.appfunctions.AppFunctionSerializable
@AppFunctionSerializable(isDescribedByKDoc = true)
data class TransferFundsInput(
    /** The recipient contact name or account handle. */
    val recipient: String,
    /** The monetary amount in USD to transfer. */
    val amount: Double,
    /** Optional transfer note or description. */
    val note: String? = null,
    /** Unique client UUID key for idempotency deduplication. */
    val idempotencyKey: String
)
@AppFunctionSerializable(isDescribedByKDoc = true)
data class TransferFundsResult(
    /** Status of the transfer: CONFIRMED, REQUIRES_CONFIRMATION, or FAILED. */
    val status: String,
    /** Unique transaction receipt identifier. */
    val transactionId: String? = null,
    /** The remaining wallet balance after transfer. */
    val remainingBalance: Double? = null,
    /** User-facing message or confirmation details. */
    val message: String? = null
)

Kotlin nullability directly defines schema requirements:

  • Non-nullable properties (recipient, amount, idempotencyKey) become required fields in the generated JSON schema.
  • Nullable properties (note) become optional.

Generated JSON Schema

During compilation, KSP emits a schema descriptor stored in your app assets:

{
  "functions": [
    {
      "functionId": "dev.alimansour.smartwallet.functions.WalletAppFunctions#transferFunds",
      "description": "Transfers money from the user's wallet to a recipient.",
      "parameters": {
        "type": "object",
        "properties": {
          "recipient": { "type": "string", "description": "Recipient contact name or account handle." },
          "amount": { "type": "number", "description": "The monetary amount in USD to transfer." },
          "note": { "type": "string", "description": "Optional transfer note or description." },
          "idempotencyKey": { "type": "string", "description": "Unique client UUID key for idempotency deduplication." }
        },
        "required": ["recipient", "amount", "idempotencyKey"]
      },
      "response": {
        "type": "object",
        "properties": {
          "status": { "type": "string" },
          "transactionId": { "type": "string" },
          "remainingBalance": { "type": "number" },
          "message": { "type": "string" }
        }
      }
    }
  ]
}

3. Implementing the Service Entry Point

Under Android 16, services use the @AppFunctionServiceEntryPoint annotation on an abstract class extending AppFunctionService. KSP generates the concrete service implementation, AIDL Binder, and XML descriptors.

package dev.alimansour.smartwallet.functions.service
import androidx.appfunctions.AppFunction
import androidx.appfunctions.AppFunctionContext
import androidx.appfunctions.AppFunctionService
import androidx.appfunctions.AppFunctionServiceEntryPoint
import dev.alimansour.smartwallet.domain.usecase.TransferFundsUseCase
import dev.alimansour.smartwallet.functions.model.TransferFundsInput
import dev.alimansour.smartwallet.functions.model.TransferFundsResult
@AppFunctionServiceEntryPoint(
    serviceName = "WalletAppFunctionService",
    appFunctionXmlFileName = "wallet_app_function_service"
)
abstract class AbstractWalletAppFunctionService : AppFunctionService() {
    abstract val transferFundsUseCase: TransferFundsUseCase
    /**
     * Transfers money from the user's wallet to a recipient.
     *
     * @param context Execution context provided by Android
     * @param input Recipient, amount, and idempotency key
     */
    @AppFunction(isDescribedByKDoc = true)
    suspend fun transferFunds(
        context: AppFunctionContext,
        input: TransferFundsInput
    ): TransferFundsResult {
        // Enforce user confirmation for sensitive operations
        if (!context.isUserConfirmed) {
            return TransferFundsResult(
                status = "REQUIRES_CONFIRMATION",
                message = "Send $${input.amount} to ${input.recipient}?"
            )
        }
        val domainResult = transferFundsUseCase(
            recipient = input.recipient,
            amount = input.amount,
            idempotencyKey = input.idempotencyKey
        )
        return when (domainResult) {
            is DomainResult.Success -> TransferFundsResult(
                status = "CONFIRMED",
                transactionId = domainResult.data.id,
                remainingBalance = domainResult.data.newBalance,
                message = "Successfully transferred $${input.amount} to ${input.recipient}."
            )
            is DomainResult.Error -> TransferFundsResult(
                status = "FAILED",
                message = domainResult.message
            )
        }
    }
}

4. Manifest Registration and Discovery

Register the generated concrete service in AndroidManifest.xml. Guard it with BIND_APP_FUNCTION_SERVICE so only the Android platform can bind to it:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application>
        <!-- Service generated by KSP from AbstractWalletAppFunctionService -->
        <service
            android:name=".functions.service.WalletAppFunctionService"
            android:permission="android.permission.BIND_APP_FUNCTION_SERVICE"
            android:exported="true">
            <property
                android:name="android.app.appfunctions.v2"
                android:value="wallet_app_function_service.xml" />
            <intent-filter>
                <action android:name="android.app.appfunctions.AppFunctionService" />
                <action android:name="androidx.appfunctions.action.EXECUTE_APP_FUNCTION" />
            </intent-filter>
        </service>
        <!-- Platform Discovery Metadata -->
        <property
            android:name="android.app.appfunctions.app_metadata"
            android:resource="@xml/app_metadata" />
    </application>
</manifest>

When the user installs the app, the OS reads app_metadata and indexes the available functions locally.


5. Clean Architecture Integration

Treat AppFunctions like API controllers or inbound request adapters. They deserialize inputs, validate them, and call domain use cases.

Domain Use Case

The use case contains pure Kotlin business rules with zero Android framework dependencies:

package dev.alimansour.smartwallet.domain.usecase
import dev.alimansour.smartwallet.domain.model.DomainResult
import dev.alimansour.smartwallet.domain.model.TransactionReceipt
import dev.alimansour.smartwallet.domain.repository.WalletRepository
class TransferFundsUseCase(
    private val walletRepository: WalletRepository
) {
    suspend operator fun invoke(
        recipient: String,
        amount: Double,
        idempotencyKey: String
    ): DomainResult<TransactionReceipt> {
        if (recipient.isBlank()) {
            return DomainResult.Error("INVALID_RECIPIENT", "Recipient name cannot be blank.")
        }
        if (amount <= 0.0) {
            return DomainResult.Error("INVALID_AMOUNT", "Transfer amount must be greater than zero.")
        }
        val currentBalance = walletRepository.getBalance()
        if (currentBalance < amount) {
            return DomainResult.Error(
                "INSUFFICIENT_FUNDS",
                "Current balance of $$currentBalance is insufficient for transfer of $$amount."
            )
        }
        return walletRepository.executeTransfer(
            recipient = recipient,
            amount = amount,
            idempotencyKey = idempotencyKey
        )
    }
}

Reactive UI Updates

When an AI assistant executes an AppFunction in the background while the user has the app open on screen, the UI should update automatically without manual refresh logic.

Using a single source of truth with Room and Kotlin Coroutine Flow handles this:

// 1. Repository exposes reactive Flow from Room
class WalletRepositoryImpl(
    private val walletDao: WalletDao
) : WalletRepository {
    override fun observeBalance(): Flow<Double> = walletDao.observeBalance()
    override suspend fun executeTransfer(
        recipient: String,
        amount: Double,
        idempotencyKey: String
    ): DomainResult<TransactionReceipt> {
        // Deduplicate against existing transaction
        val existing = walletDao.findByKey(idempotencyKey)
        if (existing != null) {
            return DomainResult.Success(existing.toReceipt())
        }
        return walletDao.insertTransfer(recipient, amount, idempotencyKey).toReceipt()
    }
}
// 2. ViewModel exposes StateFlow to Jetpack Compose
class WalletViewModel(
    observeBalanceUseCase: ObserveBalanceUseCase
) : ViewModel() {
    val balanceState = observeBalanceUseCase()
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = 0.0
        )
}

When executeTransfer writes to Room, Room emits the new value through the flow, updating balanceState in the ViewModel and triggering a recomposition in Compose.


6. Safety, Idempotency, and Confirmation

When you expose capabilities to external agents, you need to handle permissions, duplicates, and errors.

Explicit User Confirmation

Separate read-only queries (like fetching balances) from mutating operations (like transferring money or deleting data). Mutating actions should require user confirmation.

The platform provides context.isUserConfirmed. If false, return a status requesting confirmation. The OS displays the system confirmation sheet or biometric prompt directly, so you don’t need custom dialog code:

if (!context.isUserConfirmed) {
    return TransferFundsResult(
        status = "REQUIRES_CONFIRMATION",
        message = "Send $${input.amount} to ${input.recipient}?"
    )
}

Idempotency Keys

Network timeouts or retries from an AI assistant can cause repeated invocations. Requiring a client-generated UUID idempotencyKey prevents duplicate transactions:

suspend fun executeTransfer(input: TransferFundsInput): TransferFundsResult {
    val existing = walletRepository.findByIdempotencyKey(input.idempotencyKey)
    if (existing != null) {
        return TransferFundsResult(
            status = "CONFIRMED",
            transactionId = existing.id,
            remainingBalance = existing.newBalance,
            message = "Transaction already completed."
        )
    }
    return walletRepository.executeTransfer(...)
}

Structured Error Responses over Uncaught Exceptions

Throwing raw runtime exceptions causes the assistant to receive a generic invocation failure. Returning structured errors allows the model to explain the exact issue to the user (e.g., “Your balance is $20, but the transfer requires $50”):

sealed interface ExecutionOutcome {
    data class Success(val receiptId: String) : ExecutionOutcome
    data class InsufficientFunds(val current: Double, val required: Double) : ExecutionOutcome
}

7. Verification and Testing

CLI Verification via ADB

You can test AppFunctions from the command line without setting up an LLM prompt:

# List all registered AppFunctions for your package
adb shell cmd appfunctions list-functions --package dev.alimansour.smartwallet
# Execute a function directly with JSON arguments
adb shell cmd appfunctions execute-function \
  --package dev.alimansour.smartwallet \
  --function-id dev.alimansour.smartwallet.functions.service.WalletAppFunctionService#transferFunds \
  --params '{"recipient":"Omar","amount":30.0,"idempotencyKey":"tx_uuid_102"}'

JVM Unit Testing

AppFunctionContext is an interface, making it straightforward to test in local unit tests with fake contexts:

@Test
fun transferFunds_requiresConfirmation_whenUnconfirmed() = runTest {
    val fakeRepository = FakeWalletRepository(initialBalance = 100.0)
    val useCase = TransferFundsUseCase(fakeRepository)
    val service = TestWalletAppFunctionService(useCase)
    val context = FakeAppFunctionContext(isUserConfirmed = false)
    val result = service.transferFunds(
        context = context,
        input = TransferFundsInput(
            recipient = "Omar",
            amount = 30.0,
            idempotencyKey = "tx_01"
        )
    )
    assertEquals("REQUIRES_CONFIRMATION", result.status)
    assertEquals(100.0, fakeRepository.getBalance(), 0.0)
}

Key Takeaways

  • Expose existing use cases directly as suspend functions instead of writing custom Android boilerplate.
  • The assistant translates natural language into your JSON schemas, so you only need to handle the execution.
  • Never mutate data without checking context.isUserConfirmed.
  • Always use idempotency keys because network requests fail and assistants will retry.

AppFunctions remove the friction of building custom UI for every possible assistant request. Once your functions are registered, the OS takes over. You provide the logic, the user provides the intent, and the system connects the two.

Tags:
Write a comment

Verified by MonsterInsights