Skip to content

Commit bf0e6f3

Browse files
committed
feat(intent): add watch adapter and publish state to user-intent API
1 parent 076f244 commit bf0e6f3

File tree

3 files changed

+271
-3
lines changed

3 files changed

+271
-3
lines changed

admin/server.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -280,15 +280,18 @@ app.post('/api/push/settings', (req, res) => {
280280
}
281281
if (rag_mode) updates.rag_mode = rag_mode;
282282

283-
const settings = { ...current, ...updates };
284-
// Poller settings (Server-side)
283+
// Poller settings (server-side) + pass key to watch for intent adapter
285284
const state = loadState();
286-
if (antfarm_api_key) state.antfarm_api_key = antfarm_api_key;
285+
if (antfarm_api_key) {
286+
state.antfarm_api_key = antfarm_api_key;
287+
updates.antfarm_api_key = antfarm_api_key;
288+
}
287289
if (antfarm_rooms) state.antfarm_rooms = antfarm_rooms;
288290
state.poller_dry_run = !!poller_dry_run;
289291
state.poller_kill_switch = !!poller_kill_switch;
290292
saveState(state);
291293

294+
const settings = { ...current, ...updates };
292295
pushPrefsToWatch(settings);
293296
res.json({ ok: true, message: 'Settings pushed — restart ClawWatch on the watch' });
294297
} catch (e) {

app/src/main/java/com/thinkoff/clawwatch/MainActivity.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import com.thinkoff.clawwatch.databinding.ActivityMainBinding
3030
import kotlinx.coroutines.Job
3131
import kotlinx.coroutines.delay
3232
import kotlinx.coroutines.launch
33+
import java.util.Locale
3334
import kotlin.math.abs
3435

3536
/**
@@ -63,6 +64,7 @@ class MainActivity : AppCompatActivity() {
6364
private lateinit var voiceEngine: VoiceEngine
6465
private lateinit var dayPhaseManager: DayPhaseManager
6566
private lateinit var vitalsReader: VitalsReader
67+
private lateinit var intentAdapter: WatchIntentAdapter
6668
private val prefs by lazy { SecurePrefs.watch(this) }
6769

6870
private enum class State { SETUP, IDLE, LISTENING, THINKING, SEARCHING, SPEAKING, ERROR }
@@ -179,6 +181,7 @@ class MainActivity : AppCompatActivity() {
179181
voiceEngine = VoiceEngine(this)
180182
dayPhaseManager = DayPhaseManager(this)
181183
vitalsReader = VitalsReader(this)
184+
intentAdapter = WatchIntentAdapter(this, prefs, lifecycleScope)
182185

183186
val prefs = getSharedPreferences("claw_prefs", 0)
184187
currentAvatarIndex = prefs.getInt("avatar_idx", 0)
@@ -202,6 +205,12 @@ class MainActivity : AppCompatActivity() {
202205
applyDayPhaseAppearance(dayPhaseManager.snapshotNow())
203206
ensureNotificationPermission()
204207
handleAlertOpenIntent(intent)
208+
intentAdapter.start(
209+
initialState = state.name.lowercase(Locale.US),
210+
screenActive = false,
211+
batteryPct = getBatteryPercentage(),
212+
lowBattery = isLowBattery()
213+
)
205214

206215
lifecycleScope.launch { initialise() }
207216
}
@@ -1016,6 +1025,7 @@ class MainActivity : AppCompatActivity() {
10161025
binding.mainPanel.visibility = if (s != State.SETUP) View.VISIBLE else View.GONE
10171026
applyLiveTextVisibility()
10181027
updateAvatarDrawable(s)
1028+
binding.thinkingIndicator.visibility = if (s == State.THINKING || s == State.SEARCHING) View.VISIBLE else View.GONE
10191029
binding.fab.contentDescription = when (s) {
10201030
State.IDLE -> "Tap to talk"
10211031
State.LISTENING -> "Tap to stop"
@@ -1034,6 +1044,16 @@ class MainActivity : AppCompatActivity() {
10341044
State.SETUP -> ""
10351045
}
10361046
}
1047+
val keepOn = when (s) {
1048+
State.LISTENING, State.THINKING, State.SEARCHING, State.SPEAKING -> true
1049+
else -> false
1050+
}
1051+
intentAdapter.onStateChanged(
1052+
state = s.name.lowercase(Locale.US),
1053+
screenActive = keepOn,
1054+
batteryPct = getBatteryPercentage(),
1055+
lowBattery = isLowBattery()
1056+
)
10371057
}
10381058

10391059

@@ -1068,6 +1088,7 @@ class MainActivity : AppCompatActivity() {
10681088
avatarAnimator?.cancel()
10691089
stopSpeakingPreview()
10701090
queryJob?.cancel()
1091+
intentAdapter.stop()
10711092
voiceEngine.release()
10721093
}
10731094
}
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
package com.thinkoff.clawwatch
2+
3+
import android.content.Context
4+
import android.content.SharedPreferences
5+
import android.provider.Settings
6+
import android.util.Log
7+
import androidx.lifecycle.LifecycleCoroutineScope
8+
import kotlinx.coroutines.Dispatchers
9+
import kotlinx.coroutines.Job
10+
import kotlinx.coroutines.delay
11+
import kotlinx.coroutines.isActive
12+
import kotlinx.coroutines.launch
13+
import org.json.JSONObject
14+
import java.io.OutputStreamWriter
15+
import java.net.HttpURLConnection
16+
import java.net.URL
17+
import java.util.Locale
18+
19+
/**
20+
* Watch-side adapter for user-intent-kit style device updates.
21+
* Publishes watch state to /intent/{userId}/{deviceId} on state changes and heartbeat.
22+
*/
23+
class WatchIntentAdapter(
24+
private val context: Context,
25+
private val prefs: SharedPreferences,
26+
private val scope: LifecycleCoroutineScope
27+
) {
28+
companion object {
29+
private const val TAG = "WatchIntentAdapter"
30+
private const val DEFAULT_BASE_URL = "https://antfarm.world/api/v1"
31+
private const val PREF_ANTFARM_API_KEY = "antfarm_api_key"
32+
private const val PREF_INTENT_BASE_URL = "intent_base_url"
33+
private const val PREF_INTENT_USER_ID = "intent_user_id"
34+
private const val PREF_INTENT_DEVICE_ID = "intent_device_id"
35+
private const val HEARTBEAT_INTERVAL_MS = 30_000L
36+
}
37+
38+
@Volatile private var cachedUserId: String? = null
39+
@Volatile private var cachedDeviceId: String? = null
40+
@Volatile private var currentState: String = "idle"
41+
@Volatile private var currentScreenActive: Boolean = false
42+
@Volatile private var currentBatteryPct: Int = 100
43+
@Volatile private var currentLowBattery: Boolean = false
44+
private var heartbeatJob: Job? = null
45+
46+
fun start(
47+
initialState: String,
48+
screenActive: Boolean,
49+
batteryPct: Int,
50+
lowBattery: Boolean
51+
) {
52+
currentState = initialState
53+
currentScreenActive = screenActive
54+
currentBatteryPct = batteryPct
55+
currentLowBattery = lowBattery
56+
if (!isConfigured()) return
57+
if (heartbeatJob?.isActive == true) return
58+
59+
heartbeatJob = scope.launch(Dispatchers.IO) {
60+
resolveIdentityIfNeeded()
61+
publishState(includeHeartbeat = false)
62+
while (isActive) {
63+
delay(HEARTBEAT_INTERVAL_MS)
64+
publishState(includeHeartbeat = true)
65+
}
66+
}
67+
}
68+
69+
fun stop() {
70+
heartbeatJob?.cancel()
71+
heartbeatJob = null
72+
}
73+
74+
fun onStateChanged(
75+
state: String,
76+
screenActive: Boolean,
77+
batteryPct: Int,
78+
lowBattery: Boolean
79+
) {
80+
currentState = state
81+
currentScreenActive = screenActive
82+
currentBatteryPct = batteryPct
83+
currentLowBattery = lowBattery
84+
if (!isConfigured()) return
85+
86+
scope.launch(Dispatchers.IO) {
87+
resolveIdentityIfNeeded()
88+
publishState(includeHeartbeat = false)
89+
}
90+
}
91+
92+
private fun isConfigured(): Boolean = apiKey().isNotBlank()
93+
private fun prefString(key: String): String? {
94+
val secure = prefs.getString(key, null)?.trim()
95+
if (!secure.isNullOrBlank()) return secure
96+
val legacy = context.getSharedPreferences("clawwatch_prefs", Context.MODE_PRIVATE)
97+
.getString(key, null)
98+
?.trim()
99+
return if (legacy.isNullOrBlank()) null else legacy
100+
}
101+
102+
private fun baseUrl(): String =
103+
(prefString(PREF_INTENT_BASE_URL) ?: DEFAULT_BASE_URL)
104+
.trim()
105+
.trimEnd('/')
106+
107+
private fun apiKey(): String = prefString(PREF_ANTFARM_API_KEY).orEmpty()
108+
109+
private fun resolveIdentityIfNeeded() {
110+
if (cachedDeviceId.isNullOrBlank()) {
111+
val fromPrefs = prefString(PREF_INTENT_DEVICE_ID)
112+
cachedDeviceId = if (!fromPrefs.isNullOrBlank()) fromPrefs else buildDefaultDeviceId()
113+
if (fromPrefs.isNullOrBlank() && !cachedDeviceId.isNullOrBlank()) {
114+
prefs.edit().putString(PREF_INTENT_DEVICE_ID, cachedDeviceId).apply()
115+
}
116+
}
117+
118+
if (cachedUserId.isNullOrBlank()) {
119+
val fromPrefs = prefString(PREF_INTENT_USER_ID)
120+
cachedUserId = fromPrefs
121+
if (cachedUserId.isNullOrBlank()) {
122+
val fetched = fetchUserIdFromApi()
123+
if (!fetched.isNullOrBlank()) {
124+
cachedUserId = fetched
125+
prefs.edit().putString(PREF_INTENT_USER_ID, fetched).apply()
126+
}
127+
}
128+
}
129+
}
130+
131+
private fun buildDefaultDeviceId(): String {
132+
val androidId = try {
133+
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
134+
} catch (_: Exception) {
135+
null
136+
}
137+
val suffix = androidId?.takeLast(8)?.lowercase(Locale.US) ?: "watch"
138+
return "clawwatch-$suffix"
139+
}
140+
141+
private fun fetchUserIdFromApi(): String? {
142+
val key = apiKey()
143+
if (key.isBlank()) return null
144+
145+
return try {
146+
val json = request(
147+
method = "GET",
148+
path = "/users/me",
149+
body = null
150+
) ?: return null
151+
152+
extractUserId(json)
153+
} catch (e: Exception) {
154+
Log.w(TAG, "Failed to resolve /users/me for intent adapter: ${e.message}")
155+
null
156+
}
157+
}
158+
159+
private fun extractUserId(json: JSONObject): String? {
160+
val direct = listOf("user_id", "id", "username", "handle", "slug")
161+
for (key in direct) {
162+
val value = json.optString(key, "").trim()
163+
if (value.isNotBlank()) return value.trimStart('@')
164+
}
165+
166+
val nestedUser = json.optJSONObject("user")
167+
if (nestedUser != null) {
168+
for (key in direct) {
169+
val value = nestedUser.optString(key, "").trim()
170+
if (value.isNotBlank()) return value.trimStart('@')
171+
}
172+
}
173+
return null
174+
}
175+
176+
private fun publishState(includeHeartbeat: Boolean) {
177+
val userId = cachedUserId ?: return
178+
val deviceId = cachedDeviceId ?: return
179+
180+
val fields = JSONObject().apply {
181+
put("context", currentState)
182+
put("screen_active", currentScreenActive)
183+
put("wrist_raise", false)
184+
put("device_type", "watch")
185+
put("active_app", "clawwatch")
186+
put("battery_pct", currentBatteryPct)
187+
put("low_battery", currentLowBattery)
188+
if (includeHeartbeat) put("heartbeat", true)
189+
}
190+
191+
try {
192+
request(
193+
method = "PATCH",
194+
path = "/intent/$userId/$deviceId",
195+
body = fields
196+
)
197+
} catch (e: Exception) {
198+
Log.w(TAG, "Intent publish failed: ${e.message}")
199+
}
200+
}
201+
202+
private fun request(method: String, path: String, body: JSONObject?): JSONObject? {
203+
val url = URL("${baseUrl()}$path")
204+
val conn = (url.openConnection() as HttpURLConnection).apply {
205+
requestMethod = method
206+
connectTimeout = 10_000
207+
readTimeout = 10_000
208+
setRequestProperty("X-API-Key", apiKey())
209+
setRequestProperty("Content-Type", "application/json")
210+
doInput = true
211+
if (body != null && method != "GET" && method != "DELETE") {
212+
doOutput = true
213+
}
214+
}
215+
216+
if (body != null && method != "GET" && method != "DELETE") {
217+
OutputStreamWriter(conn.outputStream).use { it.write(body.toString()) }
218+
}
219+
220+
val code = conn.responseCode
221+
val raw = try {
222+
if (code in 200..299) {
223+
conn.inputStream.bufferedReader().use { it.readText() }
224+
} else {
225+
conn.errorStream?.bufferedReader()?.use { it.readText() }.orEmpty()
226+
}
227+
} catch (_: Exception) {
228+
""
229+
} finally {
230+
conn.disconnect()
231+
}
232+
233+
if (code !in 200..299) {
234+
throw IllegalStateException("HTTP $code $method $path ${raw.take(200)}")
235+
}
236+
237+
if (raw.isBlank()) return null
238+
return try {
239+
JSONObject(raw)
240+
} catch (_: Exception) {
241+
null
242+
}
243+
}
244+
}

0 commit comments

Comments
 (0)