feat: initial project scaffold
- 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
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
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
|
||||
)
|
||||
Reference in New Issue
Block a user