mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
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:
@@ -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" }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
+228
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user