318867b133
- Android Auto CarAppService, Session, Screen - Voice pipeline: STT (SpeechManager), TTS (TTSManager), VoiceStateMachine - Matrix REST client (login, send, sync) - no E2EE, raw API - FCM push receiver with response dedup - Network monitor + offline message queue - Phone setup activity stub - Full architecture spec in docs/ARCHITECTURE.md
179 lines
6.0 KiB
Kotlin
179 lines
6.0 KiB
Kotlin
package farm.hobbs.clippyauto.matrix
|
|
|
|
import io.ktor.client.*
|
|
import io.ktor.client.call.*
|
|
import io.ktor.client.engine.okhttp.*
|
|
import io.ktor.client.plugins.contentnegotiation.*
|
|
import io.ktor.client.request.*
|
|
import io.ktor.http.*
|
|
import io.ktor.serialization.kotlinx.json.*
|
|
import kotlinx.serialization.json.*
|
|
import java.util.UUID
|
|
|
|
/**
|
|
* Lightweight Matrix REST API client.
|
|
* No E2EE — designed for private homeserver use.
|
|
*
|
|
* Handles: login, message send, long-poll sync (fallback to FCM).
|
|
*/
|
|
class MatrixRestClient(
|
|
private val homeserverUrl: String
|
|
) {
|
|
private val httpClient = HttpClient(OkHttp) {
|
|
install(ContentNegotiation) {
|
|
json(Json { ignoreUnknownKeys = true })
|
|
}
|
|
}
|
|
|
|
var accessToken: String? = null
|
|
private set
|
|
private var syncToken: String? = null
|
|
|
|
/**
|
|
* Login with username/password, store access token.
|
|
*/
|
|
suspend fun login(user: String, password: String): Boolean {
|
|
return try {
|
|
val response = httpClient.post("$homeserverUrl/_matrix/client/v3/login") {
|
|
contentType(ContentType.Application.Json)
|
|
setBody(buildJsonObject {
|
|
put("type", "m.login.password")
|
|
put("user", user)
|
|
put("password", password)
|
|
putJsonObject("identifier") {
|
|
put("type", "m.id.user")
|
|
put("user", user)
|
|
}
|
|
})
|
|
}
|
|
val body = response.body<JsonObject>()
|
|
accessToken = body["access_token"]?.jsonPrimitive?.content
|
|
accessToken != null
|
|
} catch (e: Exception) {
|
|
false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Login with existing access token (from Keystore).
|
|
*/
|
|
fun loginWithToken(token: String) {
|
|
accessToken = token
|
|
}
|
|
|
|
/**
|
|
* Send a text message to a room with optional custom fields for session tracking.
|
|
*/
|
|
suspend fun sendMessage(
|
|
roomId: String,
|
|
body: String,
|
|
source: String = "voice",
|
|
sessionId: String? = null
|
|
): Boolean {
|
|
val token = accessToken ?: return false
|
|
val txnId = UUID.randomUUID().toString()
|
|
return try {
|
|
httpClient.put("$homeserverUrl/_matrix/client/v3/rooms/$roomId/send/m.room.message/$txnId") {
|
|
bearerAuth(token)
|
|
contentType(ContentType.Application.Json)
|
|
setBody(buildJsonObject {
|
|
put("msgtype", "m.text")
|
|
put("body", body)
|
|
put("farm.hobbs.clippy.source", source)
|
|
sessionId?.let { put("farm.hobbs.clippy.session_id", it) }
|
|
})
|
|
}
|
|
true
|
|
} catch (e: Exception) {
|
|
false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send a session signal (start, end, resume).
|
|
*/
|
|
suspend fun sendSessionSignal(roomId: String, signal: String, sessionId: String): Boolean {
|
|
val token = accessToken ?: return false
|
|
val txnId = UUID.randomUUID().toString()
|
|
val signalBody = when (signal) {
|
|
"session_start" -> "[ClippyAuto session started — commute mode, voice-first]"
|
|
"session_resume" -> "[ClippyAuto session resumed]"
|
|
"session_end" -> "[ClippyAuto session ended]"
|
|
else -> "[ClippyAuto signal: $signal]"
|
|
}
|
|
return try {
|
|
httpClient.put("$homeserverUrl/_matrix/client/v3/rooms/$roomId/send/m.room.message/$txnId") {
|
|
bearerAuth(token)
|
|
contentType(ContentType.Application.Json)
|
|
setBody(buildJsonObject {
|
|
put("msgtype", "m.text")
|
|
put("body", signalBody)
|
|
put("farm.hobbs.clippy.signal", signal)
|
|
put("farm.hobbs.clippy.session_id", sessionId)
|
|
})
|
|
}
|
|
true
|
|
} catch (e: Exception) {
|
|
false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Long-poll sync — fallback when FCM doesn't deliver.
|
|
* Returns list of messages from the target room since last sync.
|
|
*/
|
|
suspend fun sync(
|
|
roomId: String,
|
|
timeoutMs: Int = 30000
|
|
): List<MatrixMessage> {
|
|
val token = accessToken ?: return emptyList()
|
|
return try {
|
|
val url = buildString {
|
|
append("$homeserverUrl/_matrix/client/v3/sync?timeout=$timeoutMs")
|
|
syncToken?.let { append("&since=$it") }
|
|
}
|
|
val response = httpClient.get(url) { bearerAuth(token) }
|
|
val body = response.body<JsonObject>()
|
|
syncToken = body["next_batch"]?.jsonPrimitive?.content
|
|
|
|
val rooms = body["rooms"]?.jsonObject
|
|
?.get("join")?.jsonObject
|
|
?.get(roomId)?.jsonObject
|
|
val timeline = rooms?.get("timeline")?.jsonObject
|
|
?.get("events")?.jsonArray ?: return emptyList()
|
|
|
|
timeline.mapNotNull { event ->
|
|
val obj = event.jsonObject
|
|
val type = obj["type"]?.jsonPrimitive?.content
|
|
if (type != "m.room.message") return@mapNotNull null
|
|
|
|
val content = obj["content"]?.jsonObject ?: return@mapNotNull null
|
|
val sender = obj["sender"]?.jsonPrimitive?.content ?: return@mapNotNull null
|
|
val msgBody = content["body"]?.jsonPrimitive?.content ?: return@mapNotNull null
|
|
|
|
MatrixMessage(
|
|
sender = sender,
|
|
body = msgBody,
|
|
source = content["farm.hobbs.clippy.source"]?.jsonPrimitive?.content,
|
|
sessionId = content["farm.hobbs.clippy.session_id"]?.jsonPrimitive?.content,
|
|
signal = content["farm.hobbs.clippy.signal"]?.jsonPrimitive?.content
|
|
)
|
|
}
|
|
} catch (e: Exception) {
|
|
emptyList()
|
|
}
|
|
}
|
|
|
|
fun shutdown() {
|
|
httpClient.close()
|
|
}
|
|
}
|
|
|
|
data class MatrixMessage(
|
|
val sender: String,
|
|
val body: String,
|
|
val source: String? = null,
|
|
val sessionId: String? = null,
|
|
val signal: String? = null
|
|
)
|