From fc39098ff5c430d2a2a1b41f8be0f918c9aa1762 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 8 Mar 2026 23:12:21 -0500 Subject: [PATCH] Wire up FCM push notification service - 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 --- app/build.gradle.kts | 10 +- app/src/main/AndroidManifest.xml | 6 +- .../hobbs/clippyauto/auto/ClippySession.kt | 3 + .../hobbs/clippyauto/push/ClippyFCMService.kt | 144 ++++++++++++++---- .../hobbs/clippyauto/push/FCMTokenManager.kt | 79 ++++++++++ .../farm/hobbs/clippyauto/ui/SetupActivity.kt | 4 + build.gradle.kts | 3 +- 7 files changed, 209 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/farm/hobbs/clippyauto/push/FCMTokenManager.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d19597f..d1d0d9b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3fe710f..770bd03 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,14 +29,14 @@ android:name="com.google.android.gms.car.application" android:resource="@xml/automotive_app_desc" /> - - + - --> + () - 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) } - } -} diff --git a/app/src/main/java/farm/hobbs/clippyauto/push/FCMTokenManager.kt b/app/src/main/java/farm/hobbs/clippyauto/push/FCMTokenManager.kt new file mode 100644 index 0000000..25aac57 --- /dev/null +++ b/app/src/main/java/farm/hobbs/clippyauto/push/FCMTokenManager.kt @@ -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) + } + } +} diff --git a/app/src/main/java/farm/hobbs/clippyauto/ui/SetupActivity.kt b/app/src/main/java/farm/hobbs/clippyauto/ui/SetupActivity.kt index 1697c20..d496290 100644 --- a/app/src/main/java/farm/hobbs/clippyauto/ui/SetupActivity.kt +++ b/app/src/main/java/farm/hobbs/clippyauto/ui/SetupActivity.kt @@ -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" diff --git a/build.gradle.kts b/build.gradle.kts index 3b423a6..40ce864 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 }