diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts
index 7bf0cda845..be6e7894bc 100644
--- a/amethyst/build.gradle.kts
+++ b/amethyst/build.gradle.kts
@@ -5,6 +5,7 @@ plugins {
alias(libs.plugins.googleServices)
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
+ alias(libs.plugins.googleKsp)
}
fun getCurrentBranch(): String =
@@ -413,6 +414,15 @@ dependencies {
// on de-Googled / GrapheneOS devices that ship the F-Droid build.
"playImplementation"(libs.play.services.cast.framework)
+ // androidx.appfunctions — Gemini App Functions adapter. Pre-stable
+ // (alpha) as of May 2026 — scoped to the play channel so the F-Droid
+ // build stays free of Google AI dependencies. Surface is an
+ // AppFunctionService registered in amethyst/src/play/AndroidManifest.xml,
+ // generated at compile time by the KSP-driven appfunctions-compiler.
+ "playImplementation"(libs.androidx.appfunctions)
+ "playImplementation"(libs.androidx.appfunctions.service)
+ "kspPlay"(libs.androidx.appfunctions.compiler)
+
// Charts
implementation(libs.vico.charts.compose)
implementation(libs.vico.charts.m3)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt
index 4ac9fefc0e..3ae80f681f 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt
@@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.LogLevel
-class Amethyst : Application() {
+open class Amethyst : Application() {
init {
Log.minLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.ERROR
Log.d("AmethystApp") { "Creating App $this" }
diff --git a/amethyst/src/play/AndroidManifest.xml b/amethyst/src/play/AndroidManifest.xml
index aafa18bfb6..23fc8df4b3 100644
--- a/amethyst/src/play/AndroidManifest.xml
+++ b/amethyst/src/play/AndroidManifest.xml
@@ -2,8 +2,13 @@
+
+ android:name=".PlayAmethyst"
+ tools:replace="android:name">
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/PlayAmethyst.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/PlayAmethyst.kt
new file mode 100644
index 0000000000..89f4416751
--- /dev/null
+++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/PlayAmethyst.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst
+
+import androidx.appfunctions.service.AppFunctionConfiguration
+import com.vitorpamplona.amethyst.appfunctions.AmethystAppFunctions
+
+/**
+ * Play-flavor Application subclass that adds the
+ * [AppFunctionConfiguration.Provider] surface required by
+ * androidx.appfunctions.
+ *
+ * Lives in the `play` source set only because [AppFunctionConfiguration]
+ * (and the entire appfunctions library) is a play-channel `playImplementation`
+ * dependency — the F-Droid build does not ship Google AI libraries and
+ * continues to use the unmodified [Amethyst] Application class.
+ *
+ * Registered via `tools:replace="android:name"` in
+ * `amethyst/src/play/AndroidManifest.xml`.
+ */
+class PlayAmethyst :
+ Amethyst(),
+ AppFunctionConfiguration.Provider {
+ override val appFunctionConfiguration: AppFunctionConfiguration
+ // Lazy single instance — AmethystAppFunctions is stateless (it
+ // reaches into Amethyst.instance on every call), but the runtime
+ // is free to invoke functions concurrently, so we hand back the
+ // same object for every dispatch rather than rebuilding.
+ get() =
+ AppFunctionConfiguration
+ .Builder()
+ .addEnclosingClassFactory(AmethystAppFunctions::class.java) { AmethystAppFunctions() }
+ .build()
+}
diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt
new file mode 100644
index 0000000000..2e67499e73
--- /dev/null
+++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt
@@ -0,0 +1,228 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.appfunctions
+
+import androidx.appfunctions.AppFunctionContext
+import androidx.appfunctions.AppFunctionSerializable
+import androidx.appfunctions.service.AppFunction
+import com.vitorpamplona.amethyst.Amethyst
+import com.vitorpamplona.amethyst.commons.actions.SearchActions
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
+import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
+import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
+import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
+import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
+import kotlinx.coroutines.withTimeoutOrNull
+
+/**
+ * Bridge that exposes Amethyst's "verbs" (commons/.../actions/) to the Android
+ * App Functions runtime, which Gemini and other system agents can drive.
+ *
+ * **Status — pre-stable.** Built against androidx.appfunctions 1.0.0-alpha09.
+ * The API is still moving; treat every release as ABI-breaking until 1.0.0
+ * ships. Scoped to the `play` build flavor only — the F-Droid channel
+ * ships without any Google AI dependencies.
+ *
+ * Plain class, no inheritance — the KSP compiler discovers `@AppFunction`
+ * methods and generates the dispatcher glue. Construction is wired in
+ * [PlayAmethyst.appFunctionConfiguration].
+ *
+ * Only read-only verbs are exposed so far. Write verbs (post, follow, zap)
+ * are intentionally deferred until we resolve the signer-prompt flow for
+ * NIP-46 / NIP-55 signers, which cannot interact with the user from a
+ * background AppFunctionService invocation.
+ *
+ * Account scoping uses the currently active account from
+ * [com.vitorpamplona.amethyst.Amethyst.instance.sessionManager] — the same
+ * Account the foreground UI is bound to. When no account is signed in,
+ * every function returns an empty result rather than failing the call.
+ */
+class AmethystAppFunctions {
+ /**
+ * Searches for Nostr user profiles matching [query] via NIP-50 full-text
+ * search across the active account's configured search relays
+ * (kind:10007), falling back to Amethyst's curated default search-relay
+ * set when none is configured.
+ *
+ * @param query free-form search text (display name, NIP-05 handle, etc.)
+ * @param limit max number of profiles to return — capped to 50.
+ */
+ @AppFunction(isDescribedByKDoc = true)
+ suspend fun searchProfiles(
+ appFunctionContext: AppFunctionContext,
+ query: String,
+ limit: Int = 10,
+ ): SearchProfilesResult {
+ val cappedLimit = limit.coerceIn(1, 50)
+ val filter = SearchActions.searchProfilesFilter(query, cappedLimit) ?: return SearchProfilesResult.empty()
+
+ val app = Amethyst.instance
+ val account = app.sessionManager.loggedInAccount() ?: return SearchProfilesResult.empty()
+
+ // SearchRelayListState's flow already resolves to a concrete relay
+ // set: NIP-44-decrypted private entries + public entries, or the
+ // curated default set when the user has no kind:10007. Same source
+ // of truth the foreground UI uses.
+ val relays = account.searchRelayList.flow.value
+ if (relays.isEmpty()) return SearchProfilesResult.empty()
+
+ val events = drain(app, relays, filter, GEMINI_DRAIN_TIMEOUT_MS)
+
+ val hits =
+ events
+ .mapNotNull { it as? MetadataEvent }
+ .distinctBy { it.pubKey }
+ .sortedByDescending { it.createdAt }
+ .take(cappedLimit)
+ .map { it.toProfileHit() }
+
+ return SearchProfilesResult(matches = hits)
+ }
+
+ /**
+ * One-shot relay drain: subscribe with [filter] against [relays], collect
+ * events until every relay sends EOSE or [timeoutMs] elapses, then
+ * unsubscribe. Mirrors `Context.drain` in amy — kept inline here because
+ * Account exposes a live `INostrClient` rather than a drain helper.
+ */
+ private suspend fun drain(
+ app: com.vitorpamplona.amethyst.AppModules,
+ relays: Set,
+ filter: Filter,
+ timeoutMs: Long,
+ ): List {
+ val client = app.client
+ val incoming = Channel(UNLIMITED)
+ val done = mutableSetOf()
+ val subId = newSubId()
+ val listener =
+ object : SubscriptionListener {
+ override fun onEvent(
+ event: Event,
+ isLive: Boolean,
+ relay: NormalizedRelayUrl,
+ forFilters: List?,
+ ) {
+ incoming.trySend(event)
+ }
+
+ override fun onEose(
+ relay: NormalizedRelayUrl,
+ forFilters: List?,
+ ) {
+ done += relay
+ }
+
+ override fun onClosed(
+ message: String,
+ relay: NormalizedRelayUrl,
+ forFilters: List?,
+ ) {
+ done += relay
+ }
+
+ override fun onCannotConnect(
+ relay: NormalizedRelayUrl,
+ message: String,
+ forFilters: List?,
+ ) {
+ done += relay
+ }
+ }
+
+ val collected = mutableListOf()
+ try {
+ client.subscribe(subId, relays.associateWith { listOf(filter) }, listener)
+ withTimeoutOrNull(timeoutMs) {
+ while (done.size < relays.size) {
+ collected += incoming.receive()
+ }
+ while (true) {
+ val r = incoming.tryReceive()
+ if (!r.isSuccess) break
+ collected += r.getOrThrow()
+ }
+ }
+ } finally {
+ client.unsubscribe(subId)
+ incoming.close()
+ }
+ return collected
+ }
+
+ private fun MetadataEvent.toProfileHit(): ProfileHit {
+ val meta = contactMetaData()
+ return ProfileHit(
+ npub = NPub.create(pubKey),
+ pubkeyHex = pubKey,
+ displayName = meta?.bestName(),
+ about = meta?.about,
+ nip05 = meta?.nip05,
+ picture = meta?.picture,
+ lnAddress = meta?.lnAddress(),
+ )
+ }
+
+ companion object {
+ /**
+ * 6-second drain window. App Functions invocations are user-initiated
+ * foreground requests in the Gemini UI — anything beyond a few seconds
+ * is a poor user experience.
+ */
+ private const val GEMINI_DRAIN_TIMEOUT_MS = 6_000L
+ }
+}
+
+/**
+ * Single match in [SearchProfilesResult]. Nullable fields let callers
+ * render whatever subset of metadata the profile happens to publish.
+ */
+@AppFunctionSerializable(isDescribedByKDoc = true)
+class ProfileHit(
+ /** Bech32 npub identifier (`npub1…`) for the matched profile. */
+ val npub: String,
+ /** Hex-encoded pubkey (same identity as [npub], non-bech32 form). */
+ val pubkeyHex: String,
+ /** Best-effort display name (display_name then name). */
+ val displayName: String?,
+ /** Profile bio / about. */
+ val about: String?,
+ /** NIP-05 verified handle, e.g. `alice@example.com`. */
+ val nip05: String?,
+ /** Avatar image URL. */
+ val picture: String?,
+ /** Lightning address (lud16 preferred, otherwise lud06 LNURL). */
+ val lnAddress: String?,
+)
+
+@AppFunctionSerializable(isDescribedByKDoc = true)
+class SearchProfilesResult(
+ /** Matched profiles, deduplicated by pubkey and sorted newest-first. */
+ val matches: List,
+) {
+ companion object {
+ fun empty() = SearchProfilesResult(matches = emptyList())
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index 8d1b957095..4ffcd6c263 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -12,6 +12,7 @@ plugins {
alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false
alias(libs.plugins.serialization)
+ alias(libs.plugins.googleKsp) apply false
}
// Shared app version for all subprojects — read from gradle/libs.versions.toml.
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 24ca396591..7465579767 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -83,12 +83,21 @@ sqlite = "2.6.2"
ktor = "3.4.3"
fourkoma = "1.2.0"
+# Phase 2 (Gemini App Functions) — both still pre-stable as of May 2026.
+# Scoped to the play flavor only (see amethyst/build.gradle.kts) so the
+# fdroid channel doesn't pull in Google alpha dependencies.
+appfunctions = "1.0.0-alpha09"
+ksp = "2.3.8"
+
[libraries]
abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor-enhanced" }
accompanist-adaptive = { group = "com.google.accompanist", name = "accompanist-adaptive", version.ref = "accompanistAdaptive" }
accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistAdaptive" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+androidx-appfunctions = { group = "androidx.appfunctions", name = "appfunctions", version.ref = "appfunctions" }
+androidx-appfunctions-service = { group = "androidx.appfunctions", name = "appfunctions-service", version.ref = "appfunctions" }
+androidx-appfunctions-compiler = { group = "androidx.appfunctions", name = "appfunctions-compiler", version.ref = "appfunctions" }
androidx-benchmark-junit4 = { group = "androidx.benchmark", name = "benchmark-junit4", version.ref = "benchmark" }
androidx-biometric-ktx = { group = "androidx.biometric", name = "biometric-ktx", version.ref = "biometricKtx" }
androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidxCamera" }
@@ -213,3 +222,4 @@ kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref =
androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
+googleKsp = { id = "com.google.devtools.ksp", version.ref = "ksp" }