From 17cee60aac54d4250910c34adb471eac7968a64d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 18:23:59 +0000 Subject: [PATCH] feat(amethyst): expose searchProfiles to Gemini via androidx.appfunctions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- amethyst/build.gradle.kts | 10 + .../com/vitorpamplona/amethyst/Amethyst.kt | 2 +- amethyst/src/play/AndroidManifest.xml | 25 +- .../vitorpamplona/amethyst/PlayAmethyst.kt | 52 ++++ .../appfunctions/AmethystAppFunctions.kt | 228 ++++++++++++++++++ build.gradle.kts | 1 + gradle/libs.versions.toml | 10 + 7 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/play/java/com/vitorpamplona/amethyst/PlayAmethyst.kt create mode 100644 amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt 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" }