Wire up FCM push notification service
ClippyAuto CI / build (push) Has been cancelled

- ClippyFCMService: Receives FCM data messages, routes to TTS pipeline
- FCMTokenManager: Stores FCM token in Matrix account data for n8n
- Enable Google Services plugin and Firebase dependencies
- Add FCM service to AndroidManifest.xml
- Register FCM token on app init in ClippySession and SetupActivity

Token stored at: /_matrix/client/v3/user/@clippyauto:hobbs.farm/account_data/farm.hobbs.clippy.fcm_token

Co-authored-by: Spirit <spirit@hobbs.farm>
This commit is contained in:
2026-03-08 23:12:21 -05:00
parent 55b85eb96f
commit fc39098ff5
7 changed files with 209 additions and 40 deletions
+5 -5
View File
@@ -2,8 +2,7 @@ plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization")
// Firebase deferred to Sprint 2
// id("com.google.gms.google-services")
id("com.google.gms.google-services")
}
android {
@@ -40,9 +39,9 @@ dependencies {
implementation("androidx.car.app:app:1.7.0")
implementation("androidx.car.app:app-projected:1.7.0")
// Firebase Cloud Messaging — Sprint 2
// implementation(platform("com.google.firebase:firebase-bom:33.9.0"))
// implementation("com.google.firebase:firebase-messaging-ktx")
// Firebase Cloud Messaging
implementation(platform("com.google.firebase:firebase-bom:33.1.0"))
implementation("com.google.firebase:firebase-messaging-ktx")
// HTTP client for Matrix REST API
implementation("io.ktor:ktor-client-okhttp:3.0.3")
@@ -51,6 +50,7 @@ dependencies {
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
// Lifecycle
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
+3 -3
View File
@@ -29,14 +29,14 @@
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<!-- FCM Service — Sprint 2 (needs Firebase project) -->
<!-- <service
<!-- FCM push service for fast Clippy response delivery -->
<service
android:name=".push.ClippyFCMService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service> -->
</service>
<!-- Phone UI -->
<activity
@@ -8,6 +8,7 @@ import androidx.lifecycle.LifecycleOwner
import farm.hobbs.clippyauto.VoiceLoop
import farm.hobbs.clippyauto.config.AppConfig
import farm.hobbs.clippyauto.matrix.MatrixRestClient
import farm.hobbs.clippyauto.push.FCMTokenManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -62,6 +63,8 @@ class ClippySession : Session(), DefaultLifecycleObserver {
withContext(Dispatchers.Main) {
if (loggedIn) {
voiceLoop?.initialize()
// Register FCM token for push delivery
FCMTokenManager.ensureTokenRegistered(scope)
voiceLoop?.startSession()
screen?.attachVoiceLoop(voiceLoop!!)
} else {
@@ -1,40 +1,124 @@
package farm.hobbs.clippyauto.push
// Firebase FCM Service — Sprint 2
// Requires google-services.json and Firebase project setup.
// For now, only the dedup + bus infrastructure is active.
// The actual FirebaseMessagingService subclass will be added in Sprint 2.
import android.util.Log
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import farm.hobbs.clippyauto.config.AppConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*
import io.ktor.http.*
/**
* Simple dedup cache — keeps last N response hashes.
* Prevents double-TTS when both FCM and Matrix sync deliver the same message.
* Firebase Cloud Messaging service for ClippyAuto.
*
* Receives push messages from the n8n webhook bridge:
* Clippy sends Matrix message → Synapse push gateway → n8n → FCM data message → here
*
* Data message format (from n8n):
* {
* "sender": "@clippy:hobbs.farm",
* "room_id": "!KSovCtghshGUfVDbQg:hobbs.farm",
* "event_id": "$abc123",
* "body": "Hey Jeremy. You've got three things on deck today."
* }
*
* Routes messages to ClippyResponseBus for dedup + TTS pipeline.
* Latency target: ~300ms from FCM delivery to TTS start.
*/
object ResponseDedup {
private val cache = LinkedHashSet<String>()
private const val MAX_SIZE = 50
class ClippyFCMService : FirebaseMessagingService() {
fun seen(hash: String): Boolean = hash in cache
companion object {
private const val TAG = "ClippyFCM"
fun mark(hash: String) {
cache.add(hash)
if (cache.size > MAX_SIZE) {
cache.remove(cache.first())
/** Key for storing the FCM token in Matrix account data */
const val ACCOUNT_DATA_TYPE = "farm.hobbs.clippy.fcm_token"
}
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
/**
* Called when a data message is received from FCM.
* This is the fast path — should beat the Matrix sync poll.
*/
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
val data = message.data
val sender = data["sender"] ?: return
val body = data["body"] ?: return
val eventId = data["event_id"] ?: ""
val roomId = data["room_id"] ?: ""
Log.d(TAG, "FCM message from $sender (event: $eventId)")
// Only process messages from Clippy in the voice room
if (sender != AppConfig.clippyUserId) {
Log.d(TAG, "Ignoring message from non-Clippy sender: $sender")
return
}
if (roomId.isNotEmpty() && roomId != AppConfig.roomId) {
Log.d(TAG, "Ignoring message from different room: $roomId")
return
}
// Route to the response bus — VoiceLoop picks it up via ClippyResponseBus
// ResponseDedup handles the case where sync also delivers the same message
ClippyResponseBus.emit(body, "fcm")
Log.d(TAG, "FCM → ResponseBus: ${body.take(50)}...")
}
/**
* Called when a new FCM token is generated.
* Stores the token in Matrix account data so n8n can retrieve it.
*/
override fun onNewToken(token: String) {
super.onNewToken(token)
Log.d(TAG, "New FCM token: ${token.take(20)}...")
registerTokenWithMatrix(token)
}
/**
* Register the FCM token with Matrix via account data.
*
* Stores at: PUT /_matrix/client/v3/user/{userId}/account_data/{type}
* n8n reads this to know where to send FCM pushes.
*/
private fun registerTokenWithMatrix(token: String) {
scope.launch {
try {
val accessToken = AppConfig.matrixAccessToken ?: run {
Log.w(TAG, "No Matrix access token — can't register FCM token")
return@launch
}
val userId = "@${AppConfig.matrixUser}:hobbs.farm"
val client = HttpClient(CIO)
val response = client.put(
"${AppConfig.homeserverUrl}/_matrix/client/v3/user/" +
"${userId}/account_data/${ACCOUNT_DATA_TYPE}"
) {
header("Authorization", "Bearer $accessToken")
contentType(ContentType.Application.Json)
setBody("""{"fcm_token":"$token","updated_at":"${System.currentTimeMillis()}"}""")
}
if (response.status.isSuccess()) {
Log.d(TAG, "FCM token registered in Matrix account data")
} else {
Log.e(TAG, "Failed to register FCM token: ${response.status}")
}
client.close()
} catch (e: Exception) {
Log.e(TAG, "Error registering FCM token", e)
}
}
}
}
/**
* Simple event bus for FCM → voice pipeline communication.
* In production, consider using SharedFlow or LiveData.
*/
object ClippyResponseBus {
private val listeners = mutableListOf<(String, String) -> Unit>()
fun subscribe(listener: (response: String, sessionId: String) -> Unit) {
listeners.add(listener)
}
fun emit(response: String, sessionId: String) {
listeners.forEach { it(response, sessionId) }
}
}
@@ -0,0 +1,79 @@
package farm.hobbs.clippyauto.push
import android.content.Context
import android.util.Log
import com.google.firebase.messaging.FirebaseMessaging
import farm.hobbs.clippyauto.config.AppConfig
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
/**
* Handles FCM token lifecycle.
*
* Call [ensureTokenRegistered] at app startup (after Matrix login)
* to make sure n8n has the current FCM token for push delivery.
*
* Token is stored in Matrix account data so n8n can read it:
* GET /_matrix/client/v3/user/{userId}/account_data/farm.hobbs.clippy.fcm_token
* → { "fcm_token": "...", "updated_at": 1741... }
*/
object FCMTokenManager {
private const val TAG = "FCMToken"
/**
* Ensure the current FCM token is registered with Matrix.
* Call after successful Matrix login.
*/
fun ensureTokenRegistered(scope: CoroutineScope) {
scope.launch(Dispatchers.IO) {
try {
val token = FirebaseMessaging.getInstance().token.await()
Log.d(TAG, "Current FCM token: ${token.take(20)}...")
registerToken(token)
} catch (e: Exception) {
Log.e(TAG, "Failed to get FCM token", e)
}
}
}
/**
* Register a token with Matrix account data.
*/
suspend fun registerToken(token: String) {
val accessToken = AppConfig.matrixAccessToken ?: run {
Log.w(TAG, "No Matrix access token available")
return
}
try {
val userId = "@${AppConfig.matrixUser}:hobbs.farm"
val client = HttpClient(CIO)
val response = client.put(
"${AppConfig.homeserverUrl}/_matrix/client/v3/user/" +
"${userId}/account_data/${ClippyFCMService.ACCOUNT_DATA_TYPE}"
) {
header("Authorization", "Bearer $accessToken")
contentType(ContentType.Application.Json)
setBody("""{"fcm_token":"$token","updated_at":"${System.currentTimeMillis()}"}""")
}
if (response.status.isSuccess()) {
Log.d(TAG, "FCM token registered successfully")
} else {
Log.e(TAG, "Failed to register FCM token: ${response.status}")
}
client.close()
} catch (e: Exception) {
Log.e(TAG, "Error registering FCM token with Matrix", e)
}
}
}
@@ -13,6 +13,7 @@ import androidx.core.content.ContextCompat
import farm.hobbs.clippyauto.VoiceLoop
import farm.hobbs.clippyauto.config.AppConfig
import farm.hobbs.clippyauto.matrix.MatrixRestClient
import farm.hobbs.clippyauto.push.FCMTokenManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -135,6 +136,9 @@ class SetupActivity : AppCompatActivity() {
if (loggedIn) {
log("✅ Logged in as @${AppConfig.matrixUser}:hobbs.farm")
voiceLoop?.initialize()
// Register FCM token for push delivery
FCMTokenManager.ensureTokenRegistered(scope)
log("📲 FCM token registration requested")
voiceLoop?.startSession()
talkButton.isEnabled = true
statusText.text = "Connected! Tap Talk to Clippy"
+1 -2
View File
@@ -2,6 +2,5 @@ plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.1.10" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.10" apply false
// Firebase deferred to Sprint 2 (needs google-services.json)
// id("com.google.gms.google-services") version "4.4.2" apply false
id("com.google.gms.google-services") version "4.4.2" apply false
}