feat(amethyst): expose searchProfiles to Gemini via androidx.appfunctions

First Phase 2 verb wired through to the Android App Functions runtime so
Gemini (and other system agents) can drive Amethyst.

Scope is intentionally narrow:
  * One read-only verb (searchProfiles), built on top of the existing
    SearchActions in commons. No write verbs yet — they need a story
    for NIP-46 / NIP-55 signer prompts from a background dispatcher.
  * Play channel only. appfunctions 1.0.0-alpha09 is a Google AI alpha;
    F-Droid builds continue to ship without any Google AI dependencies.

Architecture:
  * AmethystAppFunctions — plain Kotlin host with @AppFunction methods.
    The KSP-driven appfunctions-compiler discovers them and generates
    the dispatch metadata XML at build time.
  * PlayAmethyst — play-only Application subclass implementing
    AppFunctionConfiguration.Provider; supplies the factory the
    library uses to construct the host class. Manifest replaces
    android:name in the play flavor only; F-Droid keeps the unmodified
    Amethyst class.
  * The androidx-provided PlatformAppFunctionService is registered in
    the play manifest as the bind point — Amethyst doesn't ship a
    custom Service.

KSP is now a project-wide plugin (apply false at the root); applied in
amethyst/ to run the appfunctions-compiler over the play sourceSet.

Amethyst becomes `open class` so PlayAmethyst can extend it. No other
behavior change.
This commit is contained in:
Claude
2026-05-24 18:23:59 +00:00
parent 2e47cb7110
commit 17cee60aac
7 changed files with 326 additions and 2 deletions
+10
View File
@@ -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)
@@ -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" }
+24 -1
View File
@@ -2,8 +2,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Play flavor overrides android:name with PlayAmethyst, which is
the same Amethyst Application class plus the
AppFunctionConfiguration.Provider surface required by
androidx.appfunctions. F-Droid keeps the plain Amethyst. -->
<application
android:name=".Amethyst">
android:name=".PlayAmethyst"
tools:replace="android:name">
<service
android:name=".service.notifications.PushNotificationReceiverService"
@@ -38,6 +43,24 @@
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.vitorpamplona.amethyst.service.cast.chromecast.AmethystCastOptionsProvider" />
<!-- Gemini / system-agent bridge via androidx.appfunctions
(pre-stable as of May 2026). The androidx-provided
PlatformAppFunctionService dispatches to plain Kotlin
classes annotated with @AppFunction (see
com.vitorpamplona.amethyst.appfunctions.AmethystAppFunctions);
instances are constructed by PlayAmethyst's
AppFunctionConfiguration. Play flavor only — the F-Droid
channel ships without the alpha Google AI dependency. -->
<service
android:name="androidx.appfunctions.service.PlatformAppFunctionService"
android:exported="true"
android:permission="android.permission.BIND_APP_FUNCTION_SERVICE"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.app.AppFunctionService" />
</intent-filter>
</service>
</application>
</manifest>
@@ -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()
}
@@ -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<NormalizedRelayUrl>,
filter: Filter,
timeoutMs: Long,
): List<Event> {
val client = app.client
val incoming = Channel<Event>(UNLIMITED)
val done = mutableSetOf<NormalizedRelayUrl>()
val subId = newSubId()
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
incoming.trySend(event)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
done += relay
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
done += relay
}
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
done += relay
}
}
val collected = mutableListOf<Event>()
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<ProfileHit>,
) {
companion object {
fun empty() = SearchProfilesResult(matches = emptyList())
}
}
+1
View File
@@ -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.
+10
View File
@@ -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" }