Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Added

- Android can mute built-in English tool updates independently of assistant readback on Standard and Relay voice output. (#753)
- **Sideload optional BYO FCM push wake for offline proactive delivery.** When the phone has no live Relay WebSocket, the host can send a high-priority data wake so the app reconnects and drains the offline buffer. Firebase client config is entered at runtime on the phone (Settings → Threads → Push wake); the Admin service account stays on the host (Dashboard Push wake, CLI install, or default secrets path). Google Play builds keep a no-op controller.
- Secure Link reverse-proxies Relay HTTP under `/relay/*` (including `POST /push/token`) and Dashboard transport allowlists the push-token route so registration works behind pinned TLS.

- Android marks unread completed conversations in the session drawer and shows unread conversation counts on profiles and the chat header.
- Reply notifications show the owning agent's official profile name and cached avatar, plus the conversation title.

Expand Down
6 changes: 6 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,12 @@ dependencies {
"googlePlayImplementation"(libs.play.app.update)
"googlePlayImplementation"(libs.play.app.update.ktx)

// BYO FCM — sideload only. Runtime FirebaseOptions (no google-services
// plugin). googlePlay stays free of messaging in this theme; Device
// Control remains sideload-only as before.
"sideloadImplementation"(platform(libs.firebase.bom))
"sideloadImplementation"(libs.firebase.messaging)

// Markdown rendering
implementation(libs.markdown.renderer.m3)
implementation(libs.markdown.renderer.code)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.hermesandroid.relay.push

import android.content.Context
import com.hermesandroid.relay.data.FcmClientConfig

/** googlePlay: BYO FCM is sideload-only in this theme. */
fun createFcmPushController(context: Context): FcmPushController =
object : FcmPushController {
override val isSupported: Boolean = false
override suspend fun applyConfig(config: FcmClientConfig, enabled: Boolean): String =
"FCM is available on the sideload build only"
override suspend fun currentToken(): String = ""
override suspend fun clear() = Unit
}
212 changes: 212 additions & 0 deletions app/src/main/kotlin/com/hermesandroid/relay/data/FcmPushConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
package com.hermesandroid.relay.data

import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive

/**
* Client-side Firebase options for BYO FCM (phone half only).
*
* The Mac holds the service-account JSON used to *send*. Never store a
* service account here.
*/
data class FcmClientConfig(
val projectId: String = "",
val applicationId: String = "",
val apiKey: String = "",
val gcmSenderId: String = "",
) {
val isComplete: Boolean
get() = listOf(projectId, applicationId, apiKey, gcmSenderId)
.all { it.isNotBlank() }
}

private val KEY_FCM_ENABLED = booleanPreferencesKey("fcm_push_enabled")
private val KEY_FCM_PROJECT_ID = stringPreferencesKey("fcm_project_id")
private val KEY_FCM_APPLICATION_ID = stringPreferencesKey("fcm_application_id")
private val KEY_FCM_API_KEY = stringPreferencesKey("fcm_api_key")
private val KEY_FCM_GCM_SENDER_ID = stringPreferencesKey("fcm_gcm_sender_id")
private val KEY_FCM_LAST_TOKEN = stringPreferencesKey("fcm_last_token")
private val KEY_FCM_LAST_ERROR = stringPreferencesKey("fcm_last_error")
/** When true, notifications use generic copy (no message preview). Default false = show preview. */
private val KEY_FCM_HIDE_NOTIFICATION_CONTENT =
booleanPreferencesKey("fcm_hide_notification_content")

data class FcmPushPreferences(
val enabled: Boolean = false,
val config: FcmClientConfig = FcmClientConfig(),
val lastToken: String = "",
val lastError: String = "",
/** Default false: show message preview on wake/reply notifications. */
val hideNotificationContent: Boolean = false,
)

fun Context.fcmPushPreferencesFlow(): Flow<FcmPushPreferences> =
relayDataStore.data.map { prefs ->
FcmPushPreferences(
enabled = prefs[KEY_FCM_ENABLED] ?: false,
config = FcmClientConfig(
projectId = prefs[KEY_FCM_PROJECT_ID].orEmpty(),
applicationId = prefs[KEY_FCM_APPLICATION_ID].orEmpty(),
apiKey = prefs[KEY_FCM_API_KEY].orEmpty(),
gcmSenderId = prefs[KEY_FCM_GCM_SENDER_ID].orEmpty(),
),
lastToken = prefs[KEY_FCM_LAST_TOKEN].orEmpty(),
lastError = prefs[KEY_FCM_LAST_ERROR].orEmpty(),
hideNotificationContent = prefs[KEY_FCM_HIDE_NOTIFICATION_CONTENT] ?: false,
)
}

suspend fun Context.setFcmPushEnabled(enabled: Boolean) {
relayDataStore.edit { it[KEY_FCM_ENABLED] = enabled }
}

suspend fun Context.setFcmHideNotificationContent(hide: Boolean) {
relayDataStore.edit { it[KEY_FCM_HIDE_NOTIFICATION_CONTENT] = hide }
}

suspend fun Context.setFcmClientConfig(config: FcmClientConfig) {
relayDataStore.edit {
it[KEY_FCM_PROJECT_ID] = config.projectId.trim()
it[KEY_FCM_APPLICATION_ID] = config.applicationId.trim()
it[KEY_FCM_API_KEY] = config.apiKey.trim()
it[KEY_FCM_GCM_SENDER_ID] = config.gcmSenderId.trim()
}
}

suspend fun Context.setFcmLastToken(token: String) {
relayDataStore.edit {
it[KEY_FCM_LAST_TOKEN] = token
if (token.isNotBlank()) it[KEY_FCM_LAST_ERROR] = ""
}
}

suspend fun Context.setFcmLastError(message: String) {
relayDataStore.edit { it[KEY_FCM_LAST_ERROR] = message }
}

suspend fun Context.clearFcmClientConfig() {
relayDataStore.edit {
it.remove(KEY_FCM_PROJECT_ID)
it.remove(KEY_FCM_APPLICATION_ID)
it.remove(KEY_FCM_API_KEY)
it.remove(KEY_FCM_GCM_SENDER_ID)
it.remove(KEY_FCM_LAST_TOKEN)
it.remove(KEY_FCM_LAST_ERROR)
it.remove(KEY_FCM_HIDE_NOTIFICATION_CONTENT)
it[KEY_FCM_ENABLED] = false
}
}

/**
* Sync read for notification paths (FCM service / turn-complete). Default false =
* show preview. Brief [runBlocking] is acceptable on those background threads.
*/
fun Context.hideNotificationContentBlocking(): Boolean =
runBlocking {
runCatching { fcmPushPreferencesFlow().first().hideNotificationContent }
.getOrDefault(false)
}

/**
* Parse Firebase Android client fields from pasted `google-services.json`
* text or a compact JSON object with the same keys.
*/
object FcmClientConfigParser {
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
}

fun parse(raw: String): Result<FcmClientConfig> {
val text = raw.trim()
if (text.isEmpty()) {
return Result.failure(IllegalArgumentException("Empty Firebase config"))
}
return try {
val root = json.parseToJsonElement(text).jsonObject
val fromClient = root["client"]
?.let { el ->
// google-services.json uses an array under "client"
runCatching { el.jsonObject }.getOrNull()
?: el.toString().let { null }
}
// Prefer first client entry when present
val clientObj: JsonObject? = when (val client = root["client"]) {
is kotlinx.serialization.json.JsonArray ->
client.firstOrNull()?.jsonObject
is JsonObject -> client
else -> null
}
val projectInfo = root["project_info"]?.jsonObject
val clientInfo = clientObj?.get("client_info")?.jsonObject
val androidClientInfo = clientInfo?.get("android_client_info")?.jsonObject
val apiKeyObj = clientObj
?.get("api_key")
?.let { keyEl ->
when (keyEl) {
is kotlinx.serialization.json.JsonArray ->
keyEl.firstOrNull()?.jsonObject
is JsonObject -> keyEl
else -> null
}
}

val projectId = listOfNotNull(
root["project_id"]?.jsonPrimitive?.contentOrNull,
projectInfo?.get("project_id")?.jsonPrimitive?.contentOrNull,
).firstOrNull().orEmpty()

val applicationId = listOfNotNull(
root["application_id"]?.jsonPrimitive?.contentOrNull,
root["mobilesdk_app_id"]?.jsonPrimitive?.contentOrNull,
clientInfo?.get("mobilesdk_app_id")?.jsonPrimitive?.contentOrNull,
).firstOrNull().orEmpty()

val apiKey = listOfNotNull(
root["api_key"]?.jsonPrimitive?.contentOrNull,
root["apiKey"]?.jsonPrimitive?.contentOrNull,
apiKeyObj?.get("current_key")?.jsonPrimitive?.contentOrNull,
).firstOrNull().orEmpty()

val gcmSenderId = listOfNotNull(
root["gcm_sender_id"]?.jsonPrimitive?.contentOrNull,
root["messaging_sender_id"]?.jsonPrimitive?.contentOrNull,
root["messagingSenderId"]?.jsonPrimitive?.contentOrNull,
projectInfo?.get("project_number")?.jsonPrimitive?.contentOrNull,
).firstOrNull().orEmpty()

// package_name is informational; Firebase still needs the app
// registered under the install applicationId.
val config = FcmClientConfig(
projectId = projectId,
applicationId = applicationId,
apiKey = apiKey,
gcmSenderId = gcmSenderId,
)
if (!config.isComplete) {
Result.failure(
IllegalArgumentException(
"Missing fields — need project_id, mobilesdk_app_id, api_key, gcm_sender_id",
),
)
} else {
Result.success(config)
}
} catch (e: Exception) {
Result.failure(IllegalArgumentException("Invalid JSON: ${e.message}", e))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1099,6 +1099,89 @@ class RelayHttpClient(
}
}

/**
* Register (or clear) a BYO FCM device token on the relay.
*
* POST /push/token — empty [token] clears the registration for this device.
*/
suspend fun registerPushToken(
token: String,
platform: String = "android",
projectId: String = "",
includePreview: Boolean = true,
): Result<Unit> = withContext(Dispatchers.IO) {
val relayUrl = relayUrlProvider()?.trim().orEmpty()
if (relayUrl.isEmpty()) {
return@withContext Result.failure(IllegalStateException("Relay URL not configured"))
}
val sessionToken = sessionTokenProvider()
if (sessionToken.isNullOrBlank()) {
return@withContext Result.failure(
IllegalStateException("Relay not paired — session token missing"),
)
}
val httpBase = relayHttpBaseOrNull(relayUrl)
?: return@withContext Result.failure(IOException("Invalid relay URL"))
val url = try {
"$httpBase/push/token".toHttpUrl()
} catch (e: IllegalArgumentException) {
return@withContext Result.failure(IOException("Invalid relay URL: ${e.message}"))
}
val bodyJson = buildString {
append('{')
append("\"token\":").append(jsonString(token))
append(",\"platform\":").append(jsonString(platform))
if (projectId.isNotBlank()) {
append(",\"project_id\":").append(jsonString(projectId))
}
if (token.isNotBlank()) {
append(",\"include_preview\":").append(if (includePreview) "true" else "false")
}
append('}')
}
val request = Request.Builder()
.url(url)
.post(bodyJson.toRequestBody("application/json; charset=utf-8".toMediaType()))
.relaySessionCredential(sessionToken, isDashboardRelayIngressUrl(relayUrl))
.header("Accept", "application/json")
.build()
try {
callClient(relayUrl).newCall(request).execute().use { response ->
if (!response.isSuccessful) {
val reason = when (response.code) {
401, 403 -> "Unauthorized — re-pair with the relay"
in 500..599 -> "Relay error (HTTP ${response.code})"
else -> "HTTP ${response.code}: ${response.message.ifBlank { "request failed" }}"
}
return@withContext Result.failure(IOException(reason))
}
Result.success(Unit)
}
} catch (e: IOException) {
Log.w(TAG, "registerPushToken failed: ${e.message}")
Result.failure(e)
} catch (e: Exception) {
Log.w(TAG, "registerPushToken unexpected error: ${e.message}")
Result.failure(e)
}
}

private fun jsonString(value: String): String =
buildString {
append('"')
value.forEach { c ->
when (c) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> if (c.code < 0x20) append("\\u%04x".format(c.code)) else append(c)
}
}
append('"')
}

/**
* Reduce this bearer session's TTL or grants. Omitted grants are preserved;
* any lifetime or grant expansion requires fresh operator-approved pairing.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.hermesandroid.relay.MainActivity
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.hideNotificationContentBlocking

/** One reply slot per durable conversation, routed to its exact owner. */
object TurnCompleteNotifier {
Expand Down Expand Up @@ -67,8 +68,17 @@ object TurnCompleteNotifier {
val conversation = identity?.conversationTitle?.takeIf { it.isNotBlank() }
?: "…${target.sessionId.takeLast(12)}"
val title = "$agent · ${conversation.take(100)}"
val collapsed = responseText.take(120)
val expanded = responseText.take(400)
val hideContent = context.hideNotificationContentBlocking()
val collapsed = if (hideContent) {
context.getString(R.string.notification_content_hidden)
} else {
responseText.take(120)
}
val expanded = if (hideContent) {
context.getString(R.string.notification_content_hidden)
} else {
responseText.take(400)
}

val publicVersion = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
Expand All @@ -77,8 +87,8 @@ object TurnCompleteNotifier {
.build()
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setLargeIcon(avatar)
.setContentTitle(if (hideContent) "Hermes replied" else title)
.setLargeIcon(if (hideContent) null else avatar)
.setContentText(collapsed)
.setStyle(NotificationCompat.BigTextStyle().bigText(expanded))
.setContentIntent(tapPending)
Expand Down
Loading
Loading