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
}