Add missing FCM support classes
ClippyAuto CI / build (push) Has been cancelled

- ClippyResponseBus: SharedFlow event bus for FCM + sync responses
- ResponseDedup: SHA-256 dedup with sliding window
- Updated ClippyFCMService and FCMTokenManager to use MatrixRestClient
- Added putAccountData() to MatrixRestClient for FCM token storage

Co-authored-by: Spirit <spirit@hobbs.farm>
This commit is contained in:
2026-03-08 23:17:33 -05:00
parent fc39098ff5
commit e8ca593164
5 changed files with 130 additions and 53 deletions
@@ -164,6 +164,27 @@ class MatrixRestClient(
} }
} }
/**
* Store account data in Matrix.
* Used for FCM token registration so n8n can read it.
*/
suspend fun putAccountData(userId: String, type: String, data: Map<String, String>): Boolean {
return try {
val response = client.put("$homeserverUrl/_matrix/client/v3/user/$userId/account_data/$type") {
header("Authorization", "Bearer $accessToken")
contentType(ContentType.Application.Json)
setBody(buildJsonObject {
data.forEach { (key, value) -> put(key, value) }
}.toString())
}
response.status.isSuccess()
} catch (e: Exception) {
Log.e("MatrixREST", "putAccountData failed", e)
false
}
}
fun shutdown() { fun shutdown() {
httpClient.close() httpClient.close()
} }
@@ -8,10 +8,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import io.ktor.client.* import farm.hobbs.clippyauto.ClippyAutoApp
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*
import io.ktor.http.*
/** /**
* Firebase Cloud Messaging service for ClippyAuto. * Firebase Cloud Messaging service for ClippyAuto.
@@ -85,40 +82,11 @@ class ClippyFCMService : FirebaseMessagingService() {
/** /**
* Register the FCM token with Matrix via account data. * Register the FCM token with Matrix via account data.
* * Delegates to FCMTokenManager to avoid duplicate Ktor client setup.
* 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) { private fun registerTokenWithMatrix(token: String) {
scope.launch { scope.launch {
try { FCMTokenManager.registerToken(token)
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)
}
} }
} }
} }
@@ -0,0 +1,40 @@
package farm.hobbs.clippyauto.push
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
/**
* Singleton event bus for Clippy responses.
*
* Both delivery paths (FCM fast path + Matrix sync) emit here.
* VoiceLoop collects from [responses] and routes to TTS.
* ResponseDedup ensures only the first delivery of each message is processed.
*
* Flow:
* ClippyFCMService.onMessageReceived() ──emit──┐
* ├──► VoiceLoop.onClippyResponse()
* MatrixRestClient.sync() ──────────emit──────┘
*/
object ClippyResponseBus {
data class Response(
val text: String,
val path: String, // "fcm" or "sync"
val timestamp: Long = System.currentTimeMillis()
)
private val _responses = MutableSharedFlow<Response>(
replay = 0,
extraBufferCapacity = 16
)
val responses: SharedFlow<Response> = _responses.asSharedFlow()
/**
* Emit a Clippy response from either delivery path.
* Dedup is handled by the consumer (VoiceLoop) via ResponseDedup.
*/
fun emit(text: String, path: String) {
_responses.tryEmit(Response(text, path))
}
}
@@ -1,13 +1,9 @@
package farm.hobbs.clippyauto.push package farm.hobbs.clippyauto.push
import android.content.Context
import android.util.Log import android.util.Log
import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessaging
import farm.hobbs.clippyauto.ClippyAutoApp
import farm.hobbs.clippyauto.config.AppConfig 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -45,6 +41,7 @@ object FCMTokenManager {
/** /**
* Register a token with Matrix account data. * Register a token with Matrix account data.
* Uses the app's existing MatrixRestClient to avoid extra HTTP dependencies.
*/ */
suspend fun registerToken(token: String) { suspend fun registerToken(token: String) {
val accessToken = AppConfig.matrixAccessToken ?: run { val accessToken = AppConfig.matrixAccessToken ?: run {
@@ -53,25 +50,23 @@ object FCMTokenManager {
} }
try { try {
val matrixClient = ClippyAutoApp.instance.matrixClient
val userId = "@${AppConfig.matrixUser}:hobbs.farm" val userId = "@${AppConfig.matrixUser}:hobbs.farm"
val client = HttpClient(CIO)
val response = client.put( val success = matrixClient.putAccountData(
"${AppConfig.homeserverUrl}/_matrix/client/v3/user/" + userId = userId,
"${userId}/account_data/${ClippyFCMService.ACCOUNT_DATA_TYPE}" type = ClippyFCMService.ACCOUNT_DATA_TYPE,
) { data = mapOf(
header("Authorization", "Bearer $accessToken") "fcm_token" to token,
contentType(ContentType.Application.Json) "updated_at" to System.currentTimeMillis().toString()
setBody("""{"fcm_token":"$token","updated_at":"${System.currentTimeMillis()}"}""") )
} )
if (response.status.isSuccess()) { if (success) {
Log.d(TAG, "FCM token registered successfully") Log.d(TAG, "FCM token registered successfully")
} else { } else {
Log.e(TAG, "Failed to register FCM token: ${response.status}") Log.e(TAG, "Failed to register FCM token")
} }
client.close()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error registering FCM token with Matrix", e) Log.e(TAG, "Error registering FCM token with Matrix", e)
} }
@@ -0,0 +1,53 @@
package farm.hobbs.clippyauto.push
import java.security.MessageDigest
import java.util.concurrent.ConcurrentLinkedQueue
/**
* Deduplicates Clippy responses across FCM and sync delivery paths.
*
* Both paths deliver the same message — this ensures only the first
* arrival is processed (routed to TTS). The second is silently dropped.
*
* Uses SHA-256 hash of message text for comparison.
* Keeps a sliding window of recent hashes to bound memory.
*/
object ResponseDedup {
private const val MAX_HISTORY = 100
private val seenHashes = ConcurrentLinkedQueue<String>()
/**
* Generate a hash for dedup comparison.
*/
fun hash(text: String): String {
val digest = MessageDigest.getInstance("SHA-256")
val bytes = digest.digest(text.toByteArray())
return bytes.take(8).joinToString("") { "%02x".format(it) }
}
/**
* Check if this hash has been seen before.
*/
fun seen(hash: String): Boolean {
return seenHashes.contains(hash)
}
/**
* Mark a hash as seen. Trims history if over MAX_HISTORY.
*/
fun mark(hash: String) {
seenHashes.add(hash)
while (seenHashes.size > MAX_HISTORY) {
seenHashes.poll()
}
}
/**
* Clear all history (e.g., on new session).
*/
fun clear() {
seenHashes.clear()
}
}