- 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:
@@ -2,8 +2,7 @@ plugins {
|
|||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
id("org.jetbrains.kotlin.plugin.serialization")
|
id("org.jetbrains.kotlin.plugin.serialization")
|
||||||
// Firebase deferred to Sprint 2
|
id("com.google.gms.google-services")
|
||||||
// id("com.google.gms.google-services")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -40,9 +39,9 @@ dependencies {
|
|||||||
implementation("androidx.car.app:app:1.7.0")
|
implementation("androidx.car.app:app:1.7.0")
|
||||||
implementation("androidx.car.app:app-projected:1.7.0")
|
implementation("androidx.car.app:app-projected:1.7.0")
|
||||||
|
|
||||||
// Firebase Cloud Messaging — Sprint 2
|
// Firebase Cloud Messaging
|
||||||
// implementation(platform("com.google.firebase:firebase-bom:33.9.0"))
|
implementation(platform("com.google.firebase:firebase-bom:33.1.0"))
|
||||||
// implementation("com.google.firebase:firebase-messaging-ktx")
|
implementation("com.google.firebase:firebase-messaging-ktx")
|
||||||
|
|
||||||
// HTTP client for Matrix REST API
|
// HTTP client for Matrix REST API
|
||||||
implementation("io.ktor:ktor-client-okhttp:3.0.3")
|
implementation("io.ktor:ktor-client-okhttp:3.0.3")
|
||||||
@@ -51,6 +50,7 @@ dependencies {
|
|||||||
|
|
||||||
// Coroutines
|
// Coroutines
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||||
|
|||||||
@@ -29,14 +29,14 @@
|
|||||||
android:name="com.google.android.gms.car.application"
|
android:name="com.google.android.gms.car.application"
|
||||||
android:resource="@xml/automotive_app_desc" />
|
android:resource="@xml/automotive_app_desc" />
|
||||||
|
|
||||||
<!-- FCM Service — Sprint 2 (needs Firebase project) -->
|
<!-- FCM push service for fast Clippy response delivery -->
|
||||||
<!-- <service
|
<service
|
||||||
android:name=".push.ClippyFCMService"
|
android:name=".push.ClippyFCMService"
|
||||||
android:exported="false">
|
android:exported="false">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service> -->
|
</service>
|
||||||
|
|
||||||
<!-- Phone UI -->
|
<!-- Phone UI -->
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.lifecycle.LifecycleOwner
|
|||||||
import farm.hobbs.clippyauto.VoiceLoop
|
import farm.hobbs.clippyauto.VoiceLoop
|
||||||
import farm.hobbs.clippyauto.config.AppConfig
|
import farm.hobbs.clippyauto.config.AppConfig
|
||||||
import farm.hobbs.clippyauto.matrix.MatrixRestClient
|
import farm.hobbs.clippyauto.matrix.MatrixRestClient
|
||||||
|
import farm.hobbs.clippyauto.push.FCMTokenManager
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -62,6 +63,8 @@ class ClippySession : Session(), DefaultLifecycleObserver {
|
|||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
voiceLoop?.initialize()
|
voiceLoop?.initialize()
|
||||||
|
// Register FCM token for push delivery
|
||||||
|
FCMTokenManager.ensureTokenRegistered(scope)
|
||||||
voiceLoop?.startSession()
|
voiceLoop?.startSession()
|
||||||
screen?.attachVoiceLoop(voiceLoop!!)
|
screen?.attachVoiceLoop(voiceLoop!!)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,40 +1,124 @@
|
|||||||
package farm.hobbs.clippyauto.push
|
package farm.hobbs.clippyauto.push
|
||||||
|
|
||||||
// Firebase FCM Service — Sprint 2
|
import android.util.Log
|
||||||
// Requires google-services.json and Firebase project setup.
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
// For now, only the dedup + bus infrastructure is active.
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
// The actual FirebaseMessagingService subclass will be added in Sprint 2.
|
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.
|
* Firebase Cloud Messaging service for ClippyAuto.
|
||||||
* Prevents double-TTS when both FCM and Matrix sync deliver the same message.
|
*
|
||||||
|
* 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 {
|
class ClippyFCMService : FirebaseMessagingService() {
|
||||||
private val cache = LinkedHashSet<String>()
|
|
||||||
private const val MAX_SIZE = 50
|
|
||||||
|
|
||||||
fun seen(hash: String): Boolean = hash in cache
|
companion object {
|
||||||
|
private const val TAG = "ClippyFCM"
|
||||||
|
|
||||||
fun mark(hash: String) {
|
/** Key for storing the FCM token in Matrix account data */
|
||||||
cache.add(hash)
|
const val ACCOUNT_DATA_TYPE = "farm.hobbs.clippy.fcm_token"
|
||||||
if (cache.size > MAX_SIZE) {
|
}
|
||||||
cache.remove(cache.first())
|
|
||||||
|
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.VoiceLoop
|
||||||
import farm.hobbs.clippyauto.config.AppConfig
|
import farm.hobbs.clippyauto.config.AppConfig
|
||||||
import farm.hobbs.clippyauto.matrix.MatrixRestClient
|
import farm.hobbs.clippyauto.matrix.MatrixRestClient
|
||||||
|
import farm.hobbs.clippyauto.push.FCMTokenManager
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -135,6 +136,9 @@ class SetupActivity : AppCompatActivity() {
|
|||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
log("✅ Logged in as @${AppConfig.matrixUser}:hobbs.farm")
|
log("✅ Logged in as @${AppConfig.matrixUser}:hobbs.farm")
|
||||||
voiceLoop?.initialize()
|
voiceLoop?.initialize()
|
||||||
|
// Register FCM token for push delivery
|
||||||
|
FCMTokenManager.ensureTokenRegistered(scope)
|
||||||
|
log("📲 FCM token registration requested")
|
||||||
voiceLoop?.startSession()
|
voiceLoop?.startSession()
|
||||||
talkButton.isEnabled = true
|
talkButton.isEnabled = true
|
||||||
statusText.text = "Connected! Tap Talk to Clippy"
|
statusText.text = "Connected! Tap Talk to Clippy"
|
||||||
|
|||||||
+1
-2
@@ -2,6 +2,5 @@ plugins {
|
|||||||
id("com.android.application") version "8.7.3" apply false
|
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.android") version "2.1.10" apply false
|
||||||
id("org.jetbrains.kotlin.plugin.serialization") 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
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user