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:
2026-03-05 22:07:30 +00:00
parent 6c76d18f58
commit 318867b133
23 changed files with 1371 additions and 2 deletions
@@ -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
)