- Fix settings.gradle.kts (dependencyResolutionManagement) - Add gradle-wrapper.properties + gradle.properties - Defer Firebase/FCM to Sprint 2 (no google-services.json yet) - Add placeholder ic_clippy drawable - Add test deps (JUnit, coroutines-test) - VoiceStateMachineTest: 14 tests covering happy path, interruption, cancellation, invalid transitions, reset - ResponseDedupTest: basic dedup cache tests - Add kotlinx-serialization plugin for Ktor
This commit is contained in:
@@ -29,14 +29,14 @@
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
|
||||
<!-- FCM Service -->
|
||||
<service
|
||||
<!-- FCM Service — Sprint 2 (needs Firebase project) -->
|
||||
<!-- <service
|
||||
android:name=".push.ClippyFCMService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</service> -->
|
||||
|
||||
<!-- Phone UI -->
|
||||
<activity
|
||||
|
||||
@@ -1,45 +1,9 @@
|
||||
package farm.hobbs.clippyauto.push
|
||||
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Receives FCM data messages from n8n bridge.
|
||||
* Fast path for Clippy responses — bypasses Matrix sync latency.
|
||||
*
|
||||
* Payload format from n8n:
|
||||
* {
|
||||
* "response": "Clippy's text response",
|
||||
* "session_id": "2026-03-05T08:15:00",
|
||||
* "message_hash": "sha256 of response text"
|
||||
* }
|
||||
*/
|
||||
class ClippyFCMService : FirebaseMessagingService() {
|
||||
|
||||
override fun onMessageReceived(message: RemoteMessage) {
|
||||
val responseText = message.data["response"] ?: return
|
||||
val sessionId = message.data["session_id"] ?: ""
|
||||
val hash = sha256(responseText)
|
||||
|
||||
// Dedup: skip if we already got this via Matrix sync
|
||||
if (ResponseDedup.seen(hash)) return
|
||||
ResponseDedup.mark(hash)
|
||||
|
||||
// Notify the voice pipeline to start TTS
|
||||
ClippyResponseBus.emit(responseText, sessionId)
|
||||
}
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
// TODO: Send updated token to n8n/server for push targeting
|
||||
super.onNewToken(token)
|
||||
}
|
||||
|
||||
private fun sha256(text: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
return digest.digest(text.toByteArray()).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
// Firebase FCM Service — Sprint 2
|
||||
// Requires google-services.json and Firebase project setup.
|
||||
// For now, only the dedup + bus infrastructure is active.
|
||||
// The actual FirebaseMessagingService subclass will be added in Sprint 2.
|
||||
|
||||
/**
|
||||
* Simple dedup cache — keeps last N response hashes.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Placeholder icon — replace with actual Clippy icon -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
<path
|
||||
android:fillColor="#4285F4"
|
||||
android:pathData="M24,4C12.95,4 4,12.95 4,24s8.95,20 20,20 20,-8.95 20,-20S35.05,4 24,4zM24,36c-6.63,0 -12,-5.37 -12,-12s5.37,-12 12,-12 12,5.37 12,12 -5.37,12 -12,12z"/>
|
||||
<path
|
||||
android:fillColor="#4285F4"
|
||||
android:pathData="M24,16c-4.42,0 -8,3.58 -8,8s3.58,8 8,8 8,-3.58 8,-8 -3.58,-8 -8,-8z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,24 @@
|
||||
package farm.hobbs.clippyauto.push
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class ResponseDedupTest {
|
||||
|
||||
@Test
|
||||
fun `unseen hash returns false`() {
|
||||
assertFalse(ResponseDedup.seen("newhash123"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `marked hash returns true`() {
|
||||
ResponseDedup.mark("test1")
|
||||
assertTrue(ResponseDedup.seen("test1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different hashes are independent`() {
|
||||
ResponseDedup.mark("a")
|
||||
assertFalse(ResponseDedup.seen("b"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package farm.hobbs.clippyauto.voice
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class VoiceStateMachineTest {
|
||||
|
||||
private lateinit var sm: VoiceStateMachine
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
sm = VoiceStateMachine()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state is IDLE`() {
|
||||
assertEquals(VoiceState.IDLE, sm.state.value)
|
||||
}
|
||||
|
||||
// --- Happy path ---
|
||||
|
||||
@Test
|
||||
fun `IDLE to LISTENING is valid`() {
|
||||
assertTrue(sm.transition(VoiceState.LISTENING))
|
||||
assertEquals(VoiceState.LISTENING, sm.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LISTENING to PROCESSING is valid`() {
|
||||
sm.transition(VoiceState.LISTENING)
|
||||
assertTrue(sm.transition(VoiceState.PROCESSING))
|
||||
assertEquals(VoiceState.PROCESSING, sm.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PROCESSING to RECEIVING is valid`() {
|
||||
sm.transition(VoiceState.LISTENING)
|
||||
sm.transition(VoiceState.PROCESSING)
|
||||
assertTrue(sm.transition(VoiceState.RECEIVING))
|
||||
assertEquals(VoiceState.RECEIVING, sm.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RECEIVING to SPEAKING is valid`() {
|
||||
sm.transition(VoiceState.LISTENING)
|
||||
sm.transition(VoiceState.PROCESSING)
|
||||
sm.transition(VoiceState.RECEIVING)
|
||||
assertTrue(sm.transition(VoiceState.SPEAKING))
|
||||
assertEquals(VoiceState.SPEAKING, sm.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SPEAKING to IDLE is valid`() {
|
||||
sm.transition(VoiceState.LISTENING)
|
||||
sm.transition(VoiceState.PROCESSING)
|
||||
sm.transition(VoiceState.RECEIVING)
|
||||
sm.transition(VoiceState.SPEAKING)
|
||||
assertTrue(sm.transition(VoiceState.IDLE))
|
||||
assertEquals(VoiceState.IDLE, sm.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `full happy path cycle`() {
|
||||
assertTrue(sm.transition(VoiceState.LISTENING))
|
||||
assertTrue(sm.transition(VoiceState.PROCESSING))
|
||||
assertTrue(sm.transition(VoiceState.RECEIVING))
|
||||
assertTrue(sm.transition(VoiceState.SPEAKING))
|
||||
assertTrue(sm.transition(VoiceState.IDLE))
|
||||
}
|
||||
|
||||
// --- Interruption ---
|
||||
|
||||
@Test
|
||||
fun `SPEAKING to LISTENING is valid (interruption)`() {
|
||||
sm.transition(VoiceState.LISTENING)
|
||||
sm.transition(VoiceState.PROCESSING)
|
||||
sm.transition(VoiceState.RECEIVING)
|
||||
sm.transition(VoiceState.SPEAKING)
|
||||
assertTrue(sm.transition(VoiceState.LISTENING))
|
||||
assertEquals(VoiceState.LISTENING, sm.state.value)
|
||||
}
|
||||
|
||||
// --- Cancellation (back to IDLE) ---
|
||||
|
||||
@Test
|
||||
fun `LISTENING to IDLE is valid (cancel)`() {
|
||||
sm.transition(VoiceState.LISTENING)
|
||||
assertTrue(sm.transition(VoiceState.IDLE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PROCESSING to IDLE is valid (error)`() {
|
||||
sm.transition(VoiceState.LISTENING)
|
||||
sm.transition(VoiceState.PROCESSING)
|
||||
assertTrue(sm.transition(VoiceState.IDLE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RECEIVING to IDLE is valid (error)`() {
|
||||
sm.transition(VoiceState.LISTENING)
|
||||
sm.transition(VoiceState.PROCESSING)
|
||||
sm.transition(VoiceState.RECEIVING)
|
||||
assertTrue(sm.transition(VoiceState.IDLE))
|
||||
}
|
||||
|
||||
// --- Invalid transitions ---
|
||||
|
||||
@Test
|
||||
fun `IDLE to PROCESSING is invalid`() {
|
||||
assertFalse(sm.transition(VoiceState.PROCESSING))
|
||||
assertEquals(VoiceState.IDLE, sm.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IDLE to SPEAKING is invalid`() {
|
||||
assertFalse(sm.transition(VoiceState.SPEAKING))
|
||||
assertEquals(VoiceState.IDLE, sm.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LISTENING to SPEAKING is invalid (skip)`() {
|
||||
sm.transition(VoiceState.LISTENING)
|
||||
assertFalse(sm.transition(VoiceState.SPEAKING))
|
||||
assertEquals(VoiceState.LISTENING, sm.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PROCESSING to SPEAKING is invalid (skip RECEIVING)`() {
|
||||
sm.transition(VoiceState.LISTENING)
|
||||
sm.transition(VoiceState.PROCESSING)
|
||||
assertFalse(sm.transition(VoiceState.SPEAKING))
|
||||
assertEquals(VoiceState.PROCESSING, sm.state.value)
|
||||
}
|
||||
|
||||
// --- Reset ---
|
||||
|
||||
@Test
|
||||
fun `reset returns to IDLE from any state`() {
|
||||
sm.transition(VoiceState.LISTENING)
|
||||
sm.transition(VoiceState.PROCESSING)
|
||||
sm.reset()
|
||||
assertEquals(VoiceState.IDLE, sm.state.value)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user