- 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:
+10
-4
@@ -1,7 +1,9 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
id("com.google.gms.google-services")
|
id("org.jetbrains.kotlin.plugin.serialization")
|
||||||
|
// Firebase deferred to Sprint 2
|
||||||
|
// id("com.google.gms.google-services")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -38,9 +40,9 @@ dependencies {
|
|||||||
implementation("androidx.car.app:app:1.7.0")
|
implementation("androidx.car.app:app:1.7.0")
|
||||||
implementation("androidx.car.app:app-projected:1.7.0")
|
implementation("androidx.car.app:app-projected:1.7.0")
|
||||||
|
|
||||||
// Firebase Cloud Messaging
|
// Firebase Cloud Messaging — Sprint 2
|
||||||
implementation(platform("com.google.firebase:firebase-bom:33.9.0"))
|
// implementation(platform("com.google.firebase:firebase-bom:33.9.0"))
|
||||||
implementation("com.google.firebase:firebase-messaging-ktx")
|
// implementation("com.google.firebase:firebase-messaging-ktx")
|
||||||
|
|
||||||
// HTTP client for Matrix REST API
|
// HTTP client for Matrix REST API
|
||||||
implementation("io.ktor:ktor-client-okhttp:3.0.3")
|
implementation("io.ktor:ktor-client-okhttp:3.0.3")
|
||||||
@@ -63,4 +65,8 @@ dependencies {
|
|||||||
// Core
|
// Core
|
||||||
implementation("androidx.core:core-ktx:1.15.0")
|
implementation("androidx.core:core-ktx:1.15.0")
|
||||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||||
|
|
||||||
|
// Testing
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,14 +29,14 @@
|
|||||||
android:name="com.google.android.gms.car.application"
|
android:name="com.google.android.gms.car.application"
|
||||||
android:resource="@xml/automotive_app_desc" />
|
android:resource="@xml/automotive_app_desc" />
|
||||||
|
|
||||||
<!-- FCM Service -->
|
<!-- FCM Service — Sprint 2 (needs Firebase project) -->
|
||||||
<service
|
<!-- <service
|
||||||
android:name=".push.ClippyFCMService"
|
android:name=".push.ClippyFCMService"
|
||||||
android:exported="false">
|
android:exported="false">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service> -->
|
||||||
|
|
||||||
<!-- Phone UI -->
|
<!-- Phone UI -->
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
@@ -1,45 +1,9 @@
|
|||||||
package farm.hobbs.clippyauto.push
|
package farm.hobbs.clippyauto.push
|
||||||
|
|
||||||
import com.google.firebase.messaging.FirebaseMessagingService
|
// Firebase FCM Service — Sprint 2
|
||||||
import com.google.firebase.messaging.RemoteMessage
|
// Requires google-services.json and Firebase project setup.
|
||||||
import java.security.MessageDigest
|
// For now, only the dedup + bus infrastructure is active.
|
||||||
|
// The actual FirebaseMessagingService subclass will be added in Sprint 2.
|
||||||
/**
|
|
||||||
* 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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple dedup cache — keeps last N response hashes.
|
* 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application") version "8.7.3" apply false
|
id("com.android.application") version "8.7.3" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.1.10" apply false
|
id("org.jetbrains.kotlin.android") version "2.1.10" apply false
|
||||||
id("com.google.gms.google-services") version "4.4.2" apply false
|
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.10" apply false
|
||||||
|
// Firebase deferred to Sprint 2 (needs google-services.json)
|
||||||
|
// id("com.google.gms.google-services") version "4.4.2" apply false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
+2
-1
@@ -6,7 +6,8 @@ pluginManagement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencyResolution {
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
|||||||
Reference in New Issue
Block a user