Hey Claude 0.2.0

Offline voice assistant for Android: wake word, open apps, directions,
calls, texts, and hand-off to the Claude app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQRbidocjywGyo13fqxmUJ
This commit is contained in:
2026-10-13 21:28:31 +00:00
co-authored by Claude Opus 5.5
commit 473fde1b76
28 changed files with 2281 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "space.bloggin.heyclaude"
compileSdk = 35
defaultConfig {
applicationId = "space.bloggin.heyclaude"
minSdk = 29
targetSdk = 35
versionCode = 5
versionName = "0.2.0"
ndk {
abiFilters += listOf("arm64-v8a")
}
}
// The signing key stays on Elessar and is not in the repo. Without it, release
// builds come out unsigned and need signing before they'll install.
val keyFile = file("heyclaude.jks")
if (keyFile.exists()) {
signingConfigs {
create("release") {
storeFile = keyFile
storePassword = "heyclaude"
keyAlias = "heyclaude"
keyPassword = "heyclaude"
}
}
}
buildTypes {
release {
isMinifyEnabled = false
if (keyFile.exists()) signingConfig = signingConfigs.getByName("release")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
packaging {
jniLibs {
useLegacyPackaging = false
}
}
}
dependencies {
implementation("net.java.dev.jna:jna:5.17.0@aar")
implementation("com.alphacephei:vosk-android:0.3.75@aar")
}
+94
View File
@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-feature android:name="android.hardware.telephony" android:required="false" />
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
<intent>
<action android:name="android.intent.action.SEND" />
<data android:mimeType="text/plain" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="geo" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="google.navigation" />
</intent>
<intent>
<action android:name="android.intent.action.SENDTO" />
<data android:scheme="smsto" />
</intent>
<intent>
<action android:name="android.intent.action.DIAL" />
<data android:scheme="tel" />
</intent>
<package android:name="com.anthropic.claude" />
<package android:name="com.google.android.apps.maps" />
</queries>
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="Hey Claude"
android:roundIcon="@mipmap/ic_launcher"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".LaunchActivity"
android:excludeFromRecents="true"
android:exported="false"
android:launchMode="singleInstance"
android:showWhenLocked="true"
android:taskAffinity=""
android:theme="@style/Trampoline"
android:turnScreenOn="true" />
<service
android:name=".ListenService"
android:exported="false"
android:foregroundServiceType="microphone" />
<service
android:name=".ToggleTileService"
android:exported="true"
android:icon="@drawable/ic_mic"
android:label="Hey Claude"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
<meta-data
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
android:value="true" />
</service>
</application>
</manifest>
+90
View File
@@ -0,0 +1,90 @@
package space.bloggin.heyclaude
import android.content.Context
import android.content.Intent
import kotlin.math.max
import kotlin.math.min
/** Finds an installed app from loosely spoken words ("company cam" -> CompanyCam). */
object AppMatcher {
data class AppEntry(val label: String, val pkg: String, val activity: String) {
val compact = compact(label)
}
fun compact(s: String) = s.lowercase().replace(Regex("[^a-z0-9]"), "")
fun installed(ctx: Context): List<AppEntry> {
val pm = ctx.packageManager
val main = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
return pm.queryIntentActivities(main, 0)
.filter { it.activityInfo.packageName != ctx.packageName }
.map {
AppEntry(
it.loadLabel(pm).toString(),
it.activityInfo.packageName,
it.activityInfo.name
)
}
}
fun launchIntent(e: AppEntry): Intent =
Intent(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER)
.setClassName(e.pkg, e.activity)
fun find(ctx: Context, spoken: String): AppEntry? {
var target = compact(spoken)
if (target.isEmpty()) return null
val apps = installed(ctx)
if (apps.isEmpty()) return null
// An app whose name you said exactly always wins
apps.firstOrNull { it.compact == target }?.let { return it }
// Then nicknames: "terminal = Termux"
for ((said, app) in Prefs.aliases(ctx)) {
if (compact(said) == target) {
target = compact(app)
break
}
}
apps.firstOrNull { it.compact == target }?.let { return it }
var best: AppEntry? = null
var bestScore = 0.0
for (a in apps) {
val s = score(target, a.compact)
if (s > bestScore) {
bestScore = s
best = a
}
}
return if (bestScore >= 0.8) best else null
}
private fun score(said: String, label: String): Double {
if (label.isEmpty()) return 0.0
if (label.startsWith(said) && said.length >= 3) return 0.9 - (label.length - said.length) * 0.005
if (said.startsWith(label) && label.length >= 4) return 0.85
if (label.contains(said) && said.length >= 4) return 0.8
if (said.contains(label) && label.length >= 4) return 0.78
val d = levenshtein(said, label)
return 1.0 - d.toDouble() / max(said.length, label.length)
}
private fun levenshtein(a: String, b: String): Int {
val prev = IntArray(b.length + 1) { it }
val cur = IntArray(b.length + 1)
for (i in 1..a.length) {
cur[0] = i
for (j in 1..b.length) {
val cost = if (a[i - 1] == b[j - 1]) 0 else 1
cur[j] = min(min(cur[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost)
}
System.arraycopy(cur, 0, prev, 0, cur.size)
}
return prev[b.length]
}
}
+222
View File
@@ -0,0 +1,222 @@
package space.bloggin.heyclaude
import android.content.Context
import android.content.Intent
import android.net.Uri
/** Turns what you said into an action. Everything here is plain pattern matching on the phone. */
object CommandRouter {
const val CLAUDE_PKG = "com.anthropic.claude"
private const val MAPS_PKG = "com.google.android.apps.maps"
/**
* What happened. If [followUp] is set, the listener beeps again and passes the
* next thing you say to it (used for "text greg" -> "what's the message?").
*/
class Outcome(val description: String, val followUp: ((Context, String) -> String)? = null)
private val WAKE_PREFIX = Regex("^(?:(?:hey|hay|okay|ok)\\s+(?:claude|cloud|clyde|clod)\\s*)+")
private val POLITE = Regex("^(?:please|can you|could you|would you|will you)\\s+")
private val DIRECTIONS = Regex(
"^(?:give me |get me |show me |i need |i want )?" +
"(?:directions?|start navigation|navigate|navigation|take me|drive me|drive|route me|route|head)" +
"(?:\\s+(?:to|for|over to|back to))?\\s+(.+)$"
)
private val HOW_GET = Regex("^how (?:do|can) i get to\\s+(.+)$")
private val MAP_SEARCH = Regex("^(?:find|show me|search for|where is|where's|look up)\\s+(.+?)\\s+(?:near me|nearby|close by|on the map|on maps)$")
private val NEAREST = Regex("^(?:find |show me )?(?:the )?(?:nearest|closest)\\s+(.+)$")
private val OPEN = Regex("^(?:open|open up|launch|start|pull up|bring up)\\s+(?:the\\s+|my\\s+)?(.+?)(?:\\s+app)?$")
private val CALL = Regex("^(?:call|called|calling|cole|coal|kohl|dial|phone|ring|give)\\s+(.+?)(?:(?:'s|s)?\\s+(?:on\\s+|at\\s+)?(?:his|her|their)?\\s*(mobile|cell|work|office|home)(?:\\s+(?:phone|number))?)?(?:\\s+a call)?$")
private val TEXT = Regex("^(?:send a text message to|send a text to|send a message to|send text to|shoot a text to|text message|message|texting|texts|text|tex|techs)\\s+(.+)$")
private val BODY_LEAD = Regex("^(?:saying|that says|that|and say|and tell (?:him|her|them)|to say|say)\\s+")
private val ASK_CLAUDE = Regex("^(?:ask claude|ask cloud|claude|cloud|ask)\\s+(.+)$")
private val COMMAND_START = Regex("^(?:please |can you |could you )?(?:call|called|calling|cole|coal|kohl|dial|phone|ring|give|text|texting|texts|tex|techs|message|send|shoot|open|launch|start|run|pull up|bring up|directions?|navigate|navigation|take me|drive|route|head|how do i get|how can i get|find|show me|search for|where is|where's|look up|nearest|closest)\\b")
private val JUST_CLAUDE = Regex("^(?:open claude|open cloud|claude|cloud|clyde)$")
// Words that show up as leftovers of "hey claude" when the recognizer catches part of it
private val WAKE_NOISE = setOf(
"hey", "hay", "he", "hi", "a", "uh", "um", "eh", "ah", "okay", "ok", "the", "and", "i",
"claude", "cloud", "clouds", "claude's", "cloud's", "clyde", "clod", "clawed", "claw", "loud", "ude", "lord", "plod"
)
private val WAKE_WORDS = setOf("claude", "cloud", "clouds", "claude's", "cloud's", "clyde", "clod", "clawed")
fun clean(raw: String): String {
var t = raw.lowercase().trim()
t = WAKE_PREFIX.replace(t, "").trim()
// If the wake word shows up mid-text with a command after it ("so anyway hey claude
// open termux"), keep only what comes after the last wake word.
run {
val w = t.split(Regex("\\s+")).filter { it.isNotEmpty() }
val last = w.indexOfLast { it in WAKE_WORDS }
if (last >= 0 && last < w.size - 1) {
val rest = w.drop(last + 1).joinToString(" ")
if (COMMAND_START.containsMatchIn(rest)) t = rest
}
}
// If a command word appears within the first few words and everything before it is
// wake word noise ("a claude open termux", "he clouds call christina"), drop the noise.
val words = t.split(Regex("\\s+")).filter { it.isNotEmpty() }
for (i in 1..minOf(5, words.size - 1)) {
val before = words.take(i)
val rest = words.drop(i).joinToString(" ")
if (before.all { it in WAKE_NOISE } && COMMAND_START.containsMatchIn(rest)) {
t = rest
break
}
}
// A bare leftover with nothing after it ("a claude", "hey cloud") means you said nothing.
val left = t.split(Regex("\\s+")).filter { it.isNotEmpty() }
if (left.isNotEmpty() && left.all { it in WAKE_NOISE } && left.any { it in WAKE_WORDS }) t = ""
t = POLITE.replace(t, "").trim()
return t
}
/** [voice] is false when typed into the test box, so there's no spoken follow-up. */
fun handle(ctx: Context, raw: String, voice: Boolean = true): Outcome {
val text = clean(raw)
if (text.isEmpty() || JUST_CLAUDE.matches(text)) {
return Outcome(openClaude(ctx, null))
}
NEAREST.matchEntire(text)?.let {
return Outcome(mapSearch(ctx, "nearest " + it.groupValues[1]))
}
MAP_SEARCH.matchEntire(text)?.let {
return Outcome(mapSearch(ctx, it.groupValues[1] + " near me"))
}
HOW_GET.matchEntire(text)?.let {
return Outcome(navigate(ctx, it.groupValues[1]))
}
DIRECTIONS.matchEntire(text)?.let {
val dest = it.groupValues[1].trim()
if (dest.isNotEmpty()) return Outcome(navigate(ctx, dest))
}
TEXT.matchEntire(text)?.let {
return text(ctx, it.groupValues[1], voice)
}
CALL.matchEntire(text)?.let {
return Outcome(call(ctx, it.groupValues[1].trim(), it.groupValues[2].ifEmpty { null }))
}
OPEN.matchEntire(text)?.let {
val name = it.groupValues[1].trim()
if (name == "claude" || name == "cloud") return Outcome(openClaude(ctx, null))
val app = AppMatcher.find(ctx, name)
return if (app != null) {
LaunchActivity.go(ctx, AppMatcher.launchIntent(app))
Outcome("Opened ${app.label}")
} else {
Sounds.error(ctx)
Outcome("No app matched \"$name\". You can add a nickname for it.")
}
}
ASK_CLAUDE.matchEntire(text)?.let {
return Outcome(openClaude(ctx, it.groupValues[1]))
}
return Outcome(openClaude(ctx, text))
}
// ---------- calls and texts ----------
private fun call(ctx: Context, who: String, kind: String?): String {
ContactMatcher.spokenNumber(who)?.let { return Comms.call(ctx, it, it) }
if (!ContactMatcher.hasPermission(ctx)) {
Sounds.error(ctx)
return "Contacts permission is off, turn it on in Setup"
}
val c = ContactMatcher.find(ctx, who)
if (c == null) {
Sounds.error(ctx)
return "No contact matched \"$who\". You can add a nickname for them."
}
val num = ContactMatcher.pickNumber(c, kind, forText = false)
if (num == null) {
Sounds.error(ctx)
return "${c.name} has no phone number"
}
return Comms.call(ctx, num, c.name)
}
private fun text(ctx: Context, rest: String, voice: Boolean): Outcome {
if (!ContactMatcher.hasPermission(ctx)) {
Sounds.error(ctx)
return Outcome("Contacts permission is off, turn it on in Setup")
}
val words = rest.trim().split(Regex("\\s+"))
val m = ContactMatcher.findPrefix(ctx, words)
if (m == null) {
Sounds.error(ctx)
return Outcome("No contact matched \"$rest\". You can add a nickname for them.")
}
val num = ContactMatcher.pickNumber(m.contact, null, forText = true)
if (num == null) {
Sounds.error(ctx)
return Outcome("${m.contact.name} has no phone number")
}
val body = BODY_LEAD.replace(words.drop(m.wordsUsed).joinToString(" "), "").trim()
if (body.isNotEmpty() || !voice) {
return Outcome(Comms.text(ctx, num, m.contact.name, body))
}
val who = m.contact.name
return Outcome("Texting $who, say the message") { c, said ->
val msg = BODY_LEAD.replace(said.trim().lowercase(), "").trim()
if (msg.isEmpty() || msg == "cancel" || msg == "never mind" || msg == "nevermind") {
"Text to $who cancelled"
} else {
Comms.text(c, num, who, msg)
}
}
}
// ---------- maps ----------
private fun navigate(ctx: Context, dest: String): String {
val nav = Intent(Intent.ACTION_VIEW, Uri.parse("google.navigation:q=" + Uri.encode(dest)))
.setPackage(MAPS_PKG)
if (nav.resolveActivity(ctx.packageManager) != null) {
LaunchActivity.go(ctx, nav)
return "Directions to \"$dest\""
}
return mapSearch(ctx, dest)
}
private fun mapSearch(ctx: Context, query: String): String {
val geo = Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=" + Uri.encode(query)))
if (geo.resolveActivity(ctx.packageManager) == null) {
Sounds.error(ctx)
return "No maps app found"
}
LaunchActivity.go(ctx, geo)
return "Map search for \"$query\""
}
// ---------- Claude ----------
fun openClaude(ctx: Context, text: String?): String {
val pm = ctx.packageManager
if (text != null && text.isNotBlank() && Prefs.passText(ctx)) {
val share = Intent(Intent.ACTION_SEND)
.setType("text/plain")
.putExtra(Intent.EXTRA_TEXT, text)
.setPackage(CLAUDE_PKG)
if (share.resolveActivity(pm) != null) {
LaunchActivity.go(ctx, share)
return "Sent to Claude: \"$text\""
}
}
val launch = pm.getLaunchIntentForPackage(CLAUDE_PKG)
if (launch == null) {
Sounds.error(ctx)
return "The Claude app isn't installed"
}
LaunchActivity.go(ctx, launch)
return if (text.isNullOrBlank()) "Opened Claude" else "Opened Claude (you said \"$text\")"
}
}
+61
View File
@@ -0,0 +1,61 @@
package space.bloggin.heyclaude
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.telephony.SmsManager
/** Calling and texting. */
object Comms {
fun call(ctx: Context, number: String, who: String): String {
val uri = Uri.fromParts("tel", number, null)
val direct = Prefs.callDirect(ctx) &&
ctx.checkSelfPermission(Manifest.permission.CALL_PHONE) == PackageManager.PERMISSION_GRANTED
return if (direct) {
LaunchActivity.go(ctx, Intent(Intent.ACTION_CALL, uri))
"Calling $who ($number)"
} else {
LaunchActivity.go(ctx, Intent(Intent.ACTION_DIAL, uri))
"Dialer opened for $who ($number)"
}
}
fun text(ctx: Context, number: String, who: String, rawBody: String): String {
val body = tidy(rawBody)
val direct = Prefs.textDirect(ctx) && body.isNotEmpty() &&
ctx.checkSelfPermission(Manifest.permission.SEND_SMS) == PackageManager.PERMISSION_GRANTED
if (direct) {
return try {
val sms = ctx.getSystemService(SmsManager::class.java)
val parts = sms.divideMessage(body)
if (parts.size > 1) sms.sendMultipartTextMessage(number, null, parts, null, null)
else sms.sendTextMessage(number, null, body, null, null)
"Texted $who: \"$body\""
} catch (e: Exception) {
Sounds.error(ctx)
compose(ctx, number, body)
"Couldn't send directly (${e.message}), opened it in Messages instead"
}
}
compose(ctx, number, body)
return if (body.isEmpty()) "Opened a text to $who" else "Text to $who ready: \"$body\""
}
private fun compose(ctx: Context, number: String, body: String) {
val i = Intent(Intent.ACTION_SENDTO, Uri.parse("smsto:" + Uri.encode(number)))
if (body.isNotEmpty()) i.putExtra("sms_body", body)
LaunchActivity.go(ctx, i)
}
/** The recognizer gives plain lowercase words, so add a capital and fix "i". */
private fun tidy(s: String): String {
var t = s.trim().replace(Regex("\\s+"), " ")
if (t.isEmpty()) return t
t = t.replace(Regex("\\bi\\b"), "I")
.replace(Regex("\\bi'(m|ll|ve|d)\\b")) { "I'" + it.groupValues[1] }
return t.substring(0, 1).uppercase() + t.substring(1)
}
}
+149
View File
@@ -0,0 +1,149 @@
package space.bloggin.heyclaude
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.provider.ContactsContract.CommonDataKinds.Phone
import kotlin.math.max
import kotlin.math.min
/** Finds a contact and phone number from spoken words ("greg", "greg's cell"). */
object ContactMatcher {
data class Number(val number: String, val type: Int, val primary: Boolean)
data class Contact(val name: String, val numbers: List<Number>) {
val compact = compact(name)
val first = compact(name.trim().split(Regex("\\s+")).firstOrNull() ?: "")
val last = compact(name.trim().split(Regex("\\s+")).let { if (it.size > 1) it.last() else "" })
}
data class Match(val contact: Contact, val score: Double, val wordsUsed: Int)
private const val THRESHOLD = 0.75
fun compact(s: String) = s.lowercase().replace(Regex("[^a-z0-9]"), "")
fun hasPermission(ctx: Context) =
ctx.checkSelfPermission(Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED
fun all(ctx: Context): List<Contact> {
if (!hasPermission(ctx)) return emptyList()
val byName = LinkedHashMap<String, MutableList<Number>>()
ctx.contentResolver.query(
Phone.CONTENT_URI,
arrayOf(Phone.DISPLAY_NAME, Phone.NUMBER, Phone.TYPE, Phone.IS_SUPER_PRIMARY),
null, null, null
)?.use { c ->
while (c.moveToNext()) {
val name = c.getString(0) ?: continue
val num = c.getString(1) ?: continue
val list = byName.getOrPut(name) { mutableListOf() }
val digits = num.filter { it.isDigit() }.takeLast(10)
if (list.none { it.number.filter { ch -> ch.isDigit() }.takeLast(10) == digits }) {
list.add(Number(num, c.getInt(2), c.getInt(3) != 0))
}
}
}
return byName.map { Contact(it.key, it.value) }
}
private fun resolveAlias(ctx: Context, spoken: String): String {
val s = compact(spoken)
for ((said, target) in Prefs.aliases(ctx)) {
if (compact(said) == s) return target
}
return spoken
}
private fun score(said: String, c: Contact): Double {
if (said.isEmpty()) return 0.0
if (c.compact == said) return 1.0
if (c.first == said) return 0.95
if (c.last.isNotEmpty() && c.last == said) return 0.85
if (said.length >= 4 && c.compact.startsWith(said)) return 0.85
val full = ratio(said, c.compact)
val first = if (c.first.isNotEmpty()) ratio(said, c.first) * 0.97 else 0.0
return max(full, first)
}
/** Best contact for the whole phrase. */
fun find(ctx: Context, spoken: String): Contact? {
val said = compact(resolveAlias(ctx, spoken))
val list = all(ctx)
val best = list.maxByOrNull { score(said, it) } ?: return null
return if (score(said, best) >= THRESHOLD) best else null
}
/**
* For "text greg smith i'm running late": tries the first 1 to 4 words as the name
* and returns the best match plus how many words were the name.
*/
fun findPrefix(ctx: Context, words: List<String>): Match? {
val list = all(ctx)
if (list.isEmpty()) return null
var best: Match? = null
for (k in min(4, words.size) downTo 1) {
val phrase = words.take(k).joinToString(" ")
val said = compact(resolveAlias(ctx, phrase))
for (c in list) {
val s = score(said, c)
if (s >= THRESHOLD && (best == null || s > best.score + 0.01)) {
best = Match(c, s, k)
}
}
}
return best
}
/** Pick a number. kind = "mobile", "work", "home" or null for best guess. */
fun pickNumber(c: Contact, kind: String?, forText: Boolean): String? {
if (c.numbers.isEmpty()) return null
val wanted = when (kind) {
"mobile", "cell" -> Phone.TYPE_MOBILE
"work", "office" -> Phone.TYPE_WORK
"home" -> Phone.TYPE_HOME
else -> null
}
if (wanted != null) c.numbers.firstOrNull { it.type == wanted }?.let { return it.number }
if (forText) c.numbers.firstOrNull { it.type == Phone.TYPE_MOBILE }?.let { return it.number }
c.numbers.firstOrNull { it.primary }?.let { return it.number }
c.numbers.firstOrNull { it.type == Phone.TYPE_MOBILE }?.let { return it.number }
return c.numbers.first().number
}
private fun ratio(a: String, b: String): Double {
if (a.isEmpty() || b.isEmpty()) return 0.0
val prev = IntArray(b.length + 1) { it }
val cur = IntArray(b.length + 1)
for (i in 1..a.length) {
cur[0] = i
for (j in 1..b.length) {
val cost = if (a[i - 1] == b[j - 1]) 0 else 1
cur[j] = min(min(cur[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost)
}
System.arraycopy(cur, 0, prev, 0, cur.size)
}
return 1.0 - prev[b.length].toDouble() / max(a.length, b.length)
}
private val DIGIT_WORDS = mapOf(
"zero" to '0', "oh" to '0', "o" to '0', "one" to '1', "two" to '2', "to" to '2', "too" to '2',
"three" to '3', "four" to '4', "for" to '4', "five" to '5', "six" to '6', "seven" to '7',
"eight" to '8', "nine" to '9'
)
/** "two six zero five five five one two one two" -> "2605551212". Null if it isn't a number. */
fun spokenNumber(text: String): String? {
val tokens = text.lowercase().split(Regex("[\\s-]+")).filter { it.isNotEmpty() }
if (tokens.size < 3) return null
val sb = StringBuilder()
for (t in tokens) {
when {
t.all { it.isDigit() } -> sb.append(t)
DIGIT_WORDS.containsKey(t) -> sb.append(DIGIT_WORDS[t])
else -> return null
}
}
return if (sb.length >= 3) sb.toString() else null
}
}
+50
View File
@@ -0,0 +1,50 @@
package space.bloggin.heyclaude
import android.os.Handler
import android.os.Looper
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/** Small in-memory log shown on the main screen so you can see what it heard and did. */
object EventLog {
private const val MAX = 40
private val lines = ArrayDeque<String>()
private val listeners = mutableListOf<() -> Unit>()
private val main = Handler(Looper.getMainLooper())
private val fmt = SimpleDateFormat("h:mm:ss a", Locale.US)
@Volatile
var status: String = "Stopped"
private set
fun add(msg: String) {
val line = "${fmt.format(Date())} $msg"
main.post {
lines.addFirst(line)
while (lines.size > MAX) lines.removeLast()
notifyListeners()
}
}
fun setStatus(s: String) {
main.post {
status = s
notifyListeners()
}
}
fun text(): String = lines.joinToString("\n")
fun listen(l: () -> Unit) {
listeners.add(l)
}
fun unlisten(l: () -> Unit) {
listeners.remove(l)
}
private fun notifyListeners() {
listeners.toList().forEach { it() }
}
}
@@ -0,0 +1,77 @@
package space.bloggin.heyclaude
import android.app.Activity
import android.app.KeyguardManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.widget.Toast
/**
* Invisible go-between. The listening service can't open apps directly from the background,
* so it opens this, which wakes the screen, asks you to unlock if needed, opens the target
* and disappears. Also used by the Quick Settings tile to start listening.
*/
class LaunchActivity : Activity() {
companion object {
private const val EXTRA_TARGET = "target"
const val ACTION_START_LISTENING = "space.bloggin.heyclaude.START_LISTENING"
fun go(ctx: Context, target: Intent) {
val i = Intent(ctx, LaunchActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_ANIMATION)
.putExtra(EXTRA_TARGET, target)
try {
ctx.startActivity(i)
} catch (e: Exception) {
EventLog.add("Couldn't open: ${e.message}")
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setShowWhenLocked(true)
setTurnScreenOn(true)
if (intent?.action == ACTION_START_LISTENING) {
ListenService.start(this)
finish()
return
}
val target: Intent? = if (Build.VERSION.SDK_INT >= 33) {
intent?.getParcelableExtra(EXTRA_TARGET, Intent::class.java)
} else {
@Suppress("DEPRECATION")
intent?.getParcelableExtra(EXTRA_TARGET)
}
if (target == null) {
finish()
return
}
val km = getSystemService(KeyguardManager::class.java)
if (km != null && km.isKeyguardLocked) {
km.requestDismissKeyguard(this, object : KeyguardManager.KeyguardDismissCallback() {
override fun onDismissSucceeded() = open(target)
override fun onDismissCancelled() = finish()
override fun onDismissError() = finish()
})
} else {
open(target)
}
}
private fun open(target: Intent) {
try {
startActivity(target.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
} catch (e: Exception) {
Toast.makeText(this, "Hey Claude couldn't open that", Toast.LENGTH_SHORT).show()
EventLog.add("Couldn't open: ${e.message}")
}
finish()
}
}
+436
View File
@@ -0,0 +1,436 @@
package space.bloggin.heyclaude
import android.Manifest
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.SystemClock
import android.service.quicksettings.TileService
import org.json.JSONObject
import org.vosk.Model
import org.vosk.Recognizer
import kotlin.math.max
/**
* Always-on listener. Everything runs on the phone:
* 1. A tiny Vosk recognizer limited to a few phrases listens for "hey claude".
* 2. When it hears it, a full offline recognizer catches what you say next.
* 3. The words go to CommandRouter, which opens an app, starts directions, or opens Claude.
*/
class ListenService : Service() {
companion object {
private const val ACTION_STOP = "space.bloggin.heyclaude.STOP"
private const val CHANNEL = "listening"
private const val NOTIF_ID = 7
private const val RATE = 16000
private val WAKE_GRAMMAR =
"[\"hey claude\", \"hey cloud\", \"hey clyde\", \"okay claude\", \"[unk]\"]"
private val WAKE_HIT = Regex("\\b(?:hey|okay) (?:claude|cloud|clyde)\\b")
@Volatile
var running = false
private set
fun start(ctx: Context) {
ctx.startForegroundService(Intent(ctx, ListenService::class.java))
}
fun stop(ctx: Context) {
val svc = instance
if (svc != null) {
svc.main.post {
svc.shutdown()
svc.stopSelf()
}
} else {
try {
ctx.startService(Intent(ctx, ListenService::class.java).setAction(ACTION_STOP))
} catch (_: Exception) {
}
}
}
private var instance: ListenService? = null
}
override fun onCreate() {
super.onCreate()
instance = this
}
private val main = Handler(Looper.getMainLooper())
private var worker: Thread? = null
@Volatile
private var keepGoing = false
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
shutdown()
stopSelf()
return START_NOT_STICKY
}
try {
startForeground(NOTIF_ID, buildNotification("Listening for \"Hey Claude\""),
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE)
} catch (e: Exception) {
EventLog.add("Couldn't start listening: ${e.message}")
EventLog.setStatus("Stopped")
stopSelf()
return START_NOT_STICKY
}
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
EventLog.add("Microphone permission is missing")
shutdown()
stopSelf()
return START_NOT_STICKY
}
if (worker == null && !keepGoing) {
keepGoing = true
running = true
refreshTile()
if (!ModelManager.isReady(this)) EventLog.setStatus("Getting speech model ready...")
ModelManager.get(this) { model ->
if (model == null) {
shutdown()
stopSelf()
} else if (keepGoing && worker == null) {
begin(model)
}
}
}
return START_NOT_STICKY
}
private fun begin(model: Model) {
worker = Thread({ loop(model) }, "hey-claude-listener").also { it.start() }
EventLog.setStatus("Listening for \"Hey Claude\"")
EventLog.add("Listening")
}
private fun shutdown() {
keepGoing = false
worker?.let {
try {
it.join(1500)
} catch (_: InterruptedException) {
}
}
worker = null
running = false
EventLog.setStatus("Stopped")
refreshTile()
}
override fun onDestroy() {
if (keepGoing) shutdown()
if (instance === this) instance = null
super.onDestroy()
}
private fun refreshTile() {
try {
TileService.requestListeningState(this, ComponentName(this, ToggleTileService::class.java))
} catch (_: Exception) {
}
}
private fun text(json: String, key: String): String =
try {
JSONObject(json).optString(key, "").trim()
} catch (_: Exception) {
""
}
@SuppressLint("MissingPermission")
private fun loop(model: Model) {
val minBuf = AudioRecord.getMinBufferSize(RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
val record = try {
AudioRecord(
MediaRecorder.AudioSource.VOICE_RECOGNITION, RATE,
AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, max(minBuf * 2, 8192)
)
} catch (e: Exception) {
EventLog.add("Microphone error: ${e.message}")
main.post { shutdown(); stopSelf() }
return
}
if (record.state != AudioRecord.STATE_INITIALIZED) {
EventLog.add("Microphone isn't available")
record.release()
main.post { shutdown(); stopSelf() }
return
}
val wake = Recognizer(model, RATE.toFloat(), WAKE_GRAMMAR)
val command = Recognizer(model, RATE.toFloat())
val buf = ShortArray(1600) // 0.1 second chunks
var awake = false
var dictating = false // true while taking down a text message
var awakeAt = 0L
var lastSpeechAt = 0L
var heardWords = false
val said = StringBuilder()
var ignoreUntil = 0L
// The last ~3 seconds of audio before the wake word fired. It gets replayed into the
// command recognizer so the start of "hey claude call christina" isn't clipped when the
// wake word fires mid-sentence. Any "hey claude" in it is stripped by CommandRouter.
val preRoll = ArrayDeque<ShortArray>()
val preRollChunks = 30
// When the phrase-limited detector thinks it heard "hey claude", wait this long for the
// rest of the word, then double check the same audio with the full recognizer.
var candidateAt = 0L
val verifyDelay = 400L
val verifyWords = setOf("claude", "cloud", "clouds", "claude's", "cloud's", "clyde", "clod", "clawed", "claud")
fun resetForWords(now: Long, forMessage: Boolean) {
wake.reset()
awake = true
dictating = forMessage
awakeAt = now
lastSpeechAt = now
heardWords = false
preRoll.clear()
Sounds.wake(this)
}
/** Replays the recent audio through the full recognizer. True if it also hears the wake word. */
fun verifyAndWake(now: Long): Boolean {
command.reset()
said.setLength(0)
for (chunk in preRoll) {
if (command.acceptWaveForm(chunk, chunk.size)) {
val t = text(command.result, "text")
if (t.isNotEmpty()) {
if (said.isNotEmpty()) said.append(' ')
said.append(t)
}
}
}
val check = (said.toString() + " " + text(command.partialResult, "partial")).trim().lowercase()
val ok = check.split(Regex("\\s+")).any { it in verifyWords }
if (!ok) {
command.reset()
said.setLength(0)
wake.reset()
if (check.isNotEmpty()) EventLog.add("Ignored: \"" + check.take(70) + "\"")
return false
}
resetForWords(now, forMessage = false)
return true
}
fun startMessage(now: Long) {
command.reset()
said.setLength(0)
resetForWords(now, forMessage = true)
}
record.startRecording()
try {
while (keepGoing) {
val n = record.read(buf, 0, buf.size)
if (n <= 0) {
SystemClock.sleep(20)
continue
}
val now = SystemClock.elapsedRealtime()
if (followUpArmed) {
followUpArmed = false
ignoreUntil = 0
candidateAt = 0
startMessage(now)
continue
}
if (now < ignoreUntil) continue
if (!awake) {
preRoll.addLast(buf.copyOf(n))
while (preRoll.size > preRollChunks) preRoll.removeFirst()
if (candidateAt > 0) {
if (now - candidateAt >= verifyDelay) {
candidateAt = 0
if (verifyAndWake(now)) {
updateNotification("Go ahead...")
EventLog.setStatus("Heard \"Hey Claude\", go ahead")
}
}
continue
}
val finished = wake.acceptWaveForm(buf, n)
val heard = if (finished) {
text(wake.result, "text")
} else if (!Prefs.strictWake(this)) {
text(wake.partialResult, "partial")
} else ""
if (WAKE_HIT.containsMatchIn(heard)) {
candidateAt = now
wake.reset()
}
} else {
val elapsed = now - awakeAt
// Pauses allowed mid-sentence before it decides you're done
val gap = if (dictating) 2000 else 1200
val silenceLimit = if (dictating) 6000 else 3500
val maxTime = if (dictating) 25000 else 12000
if (command.acceptWaveForm(buf, n)) {
val t = text(command.result, "text")
if (t.isNotEmpty()) {
if (said.isNotEmpty()) said.append(' ')
said.append(t)
heardWords = true
lastSpeechAt = now
}
} else if (text(command.partialResult, "partial").isNotEmpty()) {
heardWords = true
lastSpeechAt = now
}
var done = false
val onlyWake = CommandRouter.clean(said.toString()).isEmpty() &&
CommandRouter.clean(text(command.partialResult, "partial")).isEmpty()
if (onlyWake && elapsed > silenceLimit) done = true // said nothing
else if (!onlyWake && now - lastSpeechAt > gap) done = true // finished talking
else if (elapsed > maxTime) { // talked too long
val t = text(command.finalResult, "text")
if (t.isNotEmpty()) {
if (said.isNotEmpty()) said.append(' ')
said.append(t)
}
done = true
}
if (done) {
awake = false
dictating = false
command.reset()
wake.reset()
ignoreUntil = now + 2500
val words = said.toString().trim()
main.post { act(words) }
}
}
}
} catch (e: Exception) {
EventLog.add("Listener stopped: ${e.message}")
} finally {
try {
record.stop()
} catch (_: Exception) {
}
record.release()
wake.close()
command.close()
}
}
@Volatile
private var pendingFollowUp: ((android.content.Context, String) -> String)? = null
@Volatile
private var followUpArmed = false
private fun act(words: String) {
Sounds.done(this)
EventLog.add(if (words.isEmpty()) "Heard: (nothing)" else "Heard: \"$words\"")
val follow = pendingFollowUp
if (follow != null) {
pendingFollowUp = null
val r = try {
follow(this, words)
} catch (e: Exception) {
"Error: ${e.message}"
}
EventLog.add(r)
backToListening()
return
}
val outcome = try {
CommandRouter.handle(this, words)
} catch (e: Exception) {
CommandRouter.Outcome("Error: ${e.message}")
}
EventLog.add(outcome.description)
if (outcome.followUp != null) {
pendingFollowUp = outcome.followUp
followUpArmed = true
updateNotification("Say the message...")
EventLog.setStatus("Say the message")
return
}
backToListening()
}
private fun backToListening() {
updateNotification("Listening for \"Hey Claude\"")
EventLog.setStatus("Listening for \"Hey Claude\"")
}
private fun buildNotification(text: String): Notification {
val nm = getSystemService(NotificationManager::class.java)
if (nm.getNotificationChannel(CHANNEL) == null) {
nm.createNotificationChannel(
NotificationChannel(CHANNEL, "Listening", NotificationManager.IMPORTANCE_LOW).apply {
description = "Shows while Hey Claude is listening for the wake word"
setShowBadge(false)
}
)
}
val open = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val stop = PendingIntent.getService(
this, 1, Intent(this, ListenService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return Notification.Builder(this, CHANNEL)
.setSmallIcon(R.drawable.ic_mic)
.setContentTitle("Hey Claude")
.setContentText(text)
.setOngoing(true)
.setContentIntent(open)
.addAction(Notification.Action.Builder(null, "Stop", stop).build())
.setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE)
.build()
}
private fun updateNotification(text: String) {
main.post {
if (keepGoing) {
getSystemService(NotificationManager::class.java).notify(NOTIF_ID, buildNotification(text))
}
}
}
}
+301
View File
@@ -0,0 +1,301 @@
package space.bloggin.heyclaude
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Typeface
import android.net.Uri
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.text.InputType
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.view.WindowInsets
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.Switch
import android.widget.TextView
import android.widget.Toast
class MainActivity : Activity() {
private lateinit var statusText: TextView
private lateinit var toggleBtn: Button
private lateinit var logText: TextView
private val setupRows = mutableListOf<SetupRow>()
private val onLog: () -> Unit = { refreshStatus() }
private class SetupRow(val ok: () -> Boolean, val state: TextView, val button: Button)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(buildUi())
}
override fun onResume() {
super.onResume()
EventLog.listen(onLog)
refreshStatus()
refreshSetup()
}
override fun onPause() {
EventLog.unlisten(onLog)
super.onPause()
}
// ---------- UI ----------
private fun dp(v: Int) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v.toFloat(), resources.displayMetrics).toInt()
private fun header(root: LinearLayout, text: String) {
root.addView(TextView(this).apply {
this.text = text
setTypeface(typeface, Typeface.BOLD)
textSize = 17f
setPadding(0, dp(22), 0, dp(6))
})
}
private fun note(root: LinearLayout, text: String) {
root.addView(TextView(this).apply {
this.text = text
textSize = 14f
alpha = 0.8f
setPadding(0, 0, 0, dp(6))
})
}
private fun buildUi(): View {
val scroll = ScrollView(this)
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(20), dp(16), dp(20), dp(32))
}
scroll.addView(root)
scroll.setOnApplyWindowInsetsListener { v, insets ->
val bars = insets.getInsets(WindowInsets.Type.systemBars() or WindowInsets.Type.ime())
v.setPadding(bars.left, bars.top, bars.right, bars.bottom)
insets
}
root.addView(TextView(this).apply {
text = "Hey Claude"
textSize = 28f
setTypeface(typeface, Typeface.BOLD)
})
statusText = TextView(this).apply {
textSize = 16f
setPadding(0, dp(6), 0, dp(12))
}
root.addView(statusText)
toggleBtn = Button(this).apply {
textSize = 16f
setOnClickListener { toggle() }
}
root.addView(toggleBtn, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT))
header(root, "What to say")
note(root,
"Say \"Hey Claude\", wait for the beep, then:\n\n" +
"• \"Open Termux\" or \"launch CompanyCam\"\n" +
"• \"Directions to Riverbend\" or \"navigate to Menards\"\n" +
"• \"Nearest gas station\" or \"find coffee near me\"\n" +
"• Anything else goes to the Claude app\n" +
"• Say nothing and it just opens Claude")
header(root, "Setup")
note(root, "Everything needs a green check for it to work with the screen off.")
addSetupRow(root, "Speech model (one time download)",
{ ModelManager.isReady(this) },
{
if (!ModelManager.downloading) {
ModelManager.prefetch(this) { refreshSetup() }
}
})
addSetupRow(root, "Microphone",
{ checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED },
{ requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), 1) })
addSetupRow(root, "Notifications",
{ checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED },
{ requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 2) })
addSetupRow(root, "Open apps from background\n(Display over other apps)",
{ Settings.canDrawOverlays(this) },
{
startActivity(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:$packageName")))
})
addSetupRow(root, "Battery: unrestricted",
{ getSystemService(PowerManager::class.java).isIgnoringBatteryOptimizations(packageName) },
{
try {
@Suppress("BatteryLife")
startActivity(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse("package:$packageName")))
} catch (_: Exception) {
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName")))
}
})
addSetupRow(root, "Contacts (for calls and texts)",
{ checkSelfPermission(Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED },
{ requestPermissions(arrayOf(Manifest.permission.READ_CONTACTS), 3) })
header(root, "Options")
addSwitch(root, "Pass what I say into Claude", Prefs.passText(this)) { Prefs.setPassText(this, it) }
note(root, "When on, your words are handed to the Claude app so you don't have to repeat them. Turn off if it acts strange and it'll just open Claude.")
addSwitch(root, "Stricter wake word", Prefs.strictWake(this)) { Prefs.setStrictWake(this, it) }
note(root, "Turn on if it triggers when you didn't say it. Slightly slower to respond.")
addSwitch(root, "Beep", Prefs.beep(this)) { Prefs.setBeep(this, it) }
addSwitch(root, "Call right away", Prefs.callDirect(this)) {
Prefs.setCallDirect(this, it)
if (it && checkSelfPermission(Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.CALL_PHONE), 4)
}
}
note(root, "Off: opens the dialer with the number filled in so you can check it first. On: dials immediately.")
addSwitch(root, "Send texts right away", Prefs.textDirect(this)) {
Prefs.setTextDirect(this, it)
if (it && checkSelfPermission(Manifest.permission.SEND_SMS) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.SEND_SMS), 5)
}
}
note(root, "Off: opens the text in Messages so you can read it and tap send. On: sends it immediately. The recognizer does mishear words, so leave this off until you trust it.")
header(root, "Try a command")
note(root, "Type what you'd say after \"Hey Claude\" to test it without talking.")
val testRow = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL }
val testBox = EditText(this).apply {
hint = "directions to riverbend"
inputType = InputType.TYPE_CLASS_TEXT
isSingleLine = true
}
testRow.addView(testBox, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
testRow.addView(Button(this).apply {
text = "Run"
setOnClickListener {
val said = testBox.text.toString()
EventLog.add("Typed: \"$said\"")
EventLog.add(CommandRouter.handle(this@MainActivity, said, voice = false).description)
}
})
root.addView(testRow)
header(root, "App nicknames")
note(root, "If it mishears an app or contact name, add a line: what you say = app or contact name")
val aliasBox = EditText(this).apply {
setText(Prefs.aliasText(this@MainActivity))
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
minLines = 4
gravity = Gravity.TOP or Gravity.START
typeface = Typeface.MONOSPACE
textSize = 14f
}
root.addView(aliasBox, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT))
val aliasBtns = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL }
aliasBtns.addView(Button(this).apply {
text = "Save"
setOnClickListener {
Prefs.setAliasText(this@MainActivity, aliasBox.text.toString())
Toast.makeText(this@MainActivity, "Saved", Toast.LENGTH_SHORT).show()
}
})
aliasBtns.addView(Button(this).apply {
text = "List my apps"
setOnClickListener {
val names = AppMatcher.installed(this@MainActivity).map { it.label }.distinct().sortedBy { it.lowercase() }
EventLog.add("Installed apps: " + names.joinToString(", "))
}
})
root.addView(aliasBtns)
header(root, "What it heard")
logText = TextView(this).apply {
typeface = Typeface.MONOSPACE
textSize = 12.5f
setTextIsSelectable(true)
}
root.addView(logText)
return scroll
}
private fun addSetupRow(root: LinearLayout, label: String, ok: () -> Boolean, fix: () -> Unit) {
val row = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(0, dp(4), 0, dp(4))
}
val state = TextView(this).apply {
textSize = 18f
minWidth = dp(30)
}
val name = TextView(this).apply {
text = label
textSize = 15f
}
val btn = Button(this).apply {
text = "Fix"
setOnClickListener { fix() }
}
row.addView(state)
row.addView(name, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
row.addView(btn)
root.addView(row)
setupRows.add(SetupRow(ok, state, btn))
}
private fun addSwitch(root: LinearLayout, label: String, value: Boolean, onChange: (Boolean) -> Unit) {
root.addView(Switch(this).apply {
text = label
textSize = 15f
isChecked = value
setPadding(0, dp(8), 0, dp(4))
setOnCheckedChangeListener { _, b -> onChange(b) }
})
}
// ---------- behavior ----------
private fun toggle() {
if (ListenService.running) {
ListenService.stop(this)
} else {
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), 1)
return
}
if (!Settings.canDrawOverlays(this)) {
Toast.makeText(this, "Also turn on \"Open apps from background\" below, or it can't open things for you.", Toast.LENGTH_LONG).show()
}
ListenService.start(this)
}
toggleBtn.postDelayed({ refreshStatus() }, 400)
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
refreshSetup()
}
private fun refreshStatus() {
if (!::statusText.isInitialized) return
statusText.text = EventLog.status
toggleBtn.text = if (ListenService.running) "Stop listening" else "Start listening"
logText.text = EventLog.text().ifEmpty { "Nothing yet." }
}
private fun refreshSetup() {
for (r in setupRows) {
val good = r.ok()
r.state.text = if (good) "✅" else "⚠️"
r.button.visibility = if (good) View.INVISIBLE else View.VISIBLE
}
}
}
+134
View File
@@ -0,0 +1,134 @@
package space.bloggin.heyclaude
import android.content.Context
import android.os.Handler
import android.os.Looper
import org.vosk.Model
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.util.zip.ZipInputStream
/**
* The offline speech model (about 40 MB) is downloaded once, then everything
* runs on the phone with no internet needed.
*/
object ModelManager {
private const val NAME = "vosk-model-small-en-us-0.15"
private const val URL_ZIP = "https://alphacephei.com/vosk/models/$NAME.zip"
private val main = Handler(Looper.getMainLooper())
@Volatile
var downloading = false
private set
@Volatile
private var model: Model? = null
private fun dir(ctx: Context) = File(ctx.filesDir, NAME)
fun isReady(ctx: Context) = File(dir(ctx), ".complete").exists()
/** Loads (and downloads first if needed) on a background thread, then calls back on the main thread. */
fun get(ctx: Context, done: (Model?) -> Unit) {
model?.let { done(it); return }
val app = ctx.applicationContext
Thread {
try {
if (!isReady(app)) download(app)
EventLog.add("Loading speech model")
val m = Model(dir(app).absolutePath)
model = m
main.post { done(m) }
} catch (e: Exception) {
EventLog.add("Speech model problem: ${e.message}")
main.post { done(null) }
}
}.start()
}
/** Download only, for the setup screen. */
fun prefetch(ctx: Context, done: () -> Unit) {
val app = ctx.applicationContext
Thread {
try {
if (!isReady(app)) download(app)
} catch (e: Exception) {
EventLog.add("Download failed: ${e.message}")
}
main.post { done() }
}.start()
}
@Synchronized
private fun download(ctx: Context) {
if (isReady(ctx)) return
downloading = true
try {
EventLog.setStatus("Downloading speech model (one time, about 40 MB)...")
EventLog.add("Downloading speech model")
val zip = File(ctx.cacheDir, "$NAME.zip")
val conn = URL(URL_ZIP).openConnection() as HttpURLConnection
conn.connectTimeout = 20000
conn.readTimeout = 30000
conn.instanceFollowRedirects = true
if (conn.responseCode !in 200..299) throw Exception("server said ${conn.responseCode}")
val total = conn.contentLengthLong
var got = 0L
var lastPct = -1
conn.inputStream.use { input ->
FileOutputStream(zip).use { out ->
val buf = ByteArray(64 * 1024)
while (true) {
val n = input.read(buf)
if (n < 0) break
out.write(buf, 0, n)
got += n
if (total > 0) {
val pct = (got * 100 / total).toInt()
if (pct / 10 != lastPct / 10) {
lastPct = pct
EventLog.setStatus("Downloading speech model... $pct%")
}
}
}
}
}
EventLog.setStatus("Unpacking speech model...")
val tmp = File(ctx.filesDir, "$NAME.tmp")
tmp.deleteRecursively()
tmp.mkdirs()
val tmpRoot = tmp.canonicalPath + File.separator
ZipInputStream(zip.inputStream().buffered()).use { zin ->
while (true) {
val e = zin.nextEntry ?: break
// Strip the top folder from the zip
val rel = e.name.substringAfter('/', "")
if (rel.isEmpty()) continue
val f = File(tmp, rel)
if (!f.canonicalPath.startsWith(tmpRoot)) continue
if (e.isDirectory) {
f.mkdirs()
} else {
f.parentFile?.mkdirs()
FileOutputStream(f).use { zin.copyTo(it) }
}
}
}
zip.delete()
if (!File(tmp, "am/final.mdl").exists()) throw Exception("model files missing after unpack")
val target = dir(ctx)
target.deleteRecursively()
if (!tmp.renameTo(target)) throw Exception("couldn't move model into place")
File(target, ".complete").writeText("ok")
EventLog.add("Speech model ready")
EventLog.setStatus("Stopped")
} finally {
downloading = false
}
}
}
+53
View File
@@ -0,0 +1,53 @@
package space.bloggin.heyclaude
import android.content.Context
object Prefs {
private const val FILE = "heyclaude"
private fun sp(ctx: Context) = ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE)
/** Hand the spoken words to Claude (as shared text) instead of just opening the app. */
fun passText(ctx: Context) = sp(ctx).getBoolean("passTextV2", false)
fun setPassText(ctx: Context, v: Boolean) = sp(ctx).edit().putBoolean("passTextV2", v).apply()
/** Only trigger on a finished "hey claude" phrase, not a partial guess. Fewer false triggers, a bit slower. */
fun strictWake(ctx: Context) = sp(ctx).getBoolean("strictWake", false)
fun setStrictWake(ctx: Context, v: Boolean) = sp(ctx).edit().putBoolean("strictWake", v).apply()
/** Beep when it hears the wake word. */
fun beep(ctx: Context) = sp(ctx).getBoolean("beep", true)
fun setBeep(ctx: Context, v: Boolean) = sp(ctx).edit().putBoolean("beep", v).apply()
/** Place calls immediately instead of showing the dialer first. */
fun callDirect(ctx: Context) = sp(ctx).getBoolean("callDirect", false)
fun setCallDirect(ctx: Context, v: Boolean) = sp(ctx).edit().putBoolean("callDirect", v).apply()
/** Send texts immediately instead of opening them in Messages first. */
fun textDirect(ctx: Context) = sp(ctx).getBoolean("textDirect", false)
fun setTextDirect(ctx: Context, v: Boolean) = sp(ctx).edit().putBoolean("textDirect", v).apply()
/** Raw alias text, one per line: "what you say = app name". */
fun aliasText(ctx: Context): String = sp(ctx).getString("aliases", DEFAULT_ALIASES) ?: ""
fun setAliasText(ctx: Context, v: String) = sp(ctx).edit().putString("aliases", v).apply()
fun aliases(ctx: Context): List<Pair<String, String>> =
aliasText(ctx).lines()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") && it.contains("=") }
.map {
val i = it.indexOf('=')
it.substring(0, i).trim() to it.substring(i + 1).trim()
}
.filter { it.first.isNotEmpty() && it.second.isNotEmpty() }
private val DEFAULT_ALIASES = """
# what you say = app or contact name (one per line)
terminal = Termux
term ux = Termux
company cam = CompanyCam
irc = wIRC
w irc = wIRC
maps = Maps
""".trimIndent()
}
+49
View File
@@ -0,0 +1,49 @@
package space.bloggin.heyclaude
import android.content.Context
import android.media.AudioManager
import android.media.ToneGenerator
import android.os.Handler
import android.os.Looper
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
object Sounds {
private val main = Handler(Looper.getMainLooper())
private fun tone(type: Int, ms: Int) {
try {
val tg = ToneGenerator(AudioManager.STREAM_NOTIFICATION, 85)
tg.startTone(type, ms)
main.postDelayed({ tg.release() }, ms + 300L)
} catch (_: Exception) {
}
}
private fun buzz(ctx: Context, ms: Long) {
try {
val v = (ctx.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
?: @Suppress("DEPRECATION") (ctx.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator)
v.vibrate(VibrationEffect.createOneShot(ms, VibrationEffect.DEFAULT_AMPLITUDE))
} catch (_: Exception) {
}
}
/** "I'm listening" */
fun wake(ctx: Context) {
if (Prefs.beep(ctx)) tone(ToneGenerator.TONE_PROP_BEEP, 140)
buzz(ctx, 40)
}
/** Got it */
fun done(ctx: Context) {
if (Prefs.beep(ctx)) tone(ToneGenerator.TONE_PROP_ACK, 120)
}
/** Didn't work */
fun error(ctx: Context) {
if (Prefs.beep(ctx)) tone(ToneGenerator.TONE_PROP_NACK, 250)
buzz(ctx, 120)
}
}
@@ -0,0 +1,47 @@
package space.bloggin.heyclaude
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
/** Quick Settings tile to turn listening on and off. */
class ToggleTileService : TileService() {
override fun onStartListening() {
super.onStartListening()
refresh()
}
override fun onClick() {
super.onClick()
if (ListenService.running) {
ListenService.stop(this)
Handler(Looper.getMainLooper()).postDelayed({ refresh() }, 600)
} else {
// Starting the microphone has to come from something on screen,
// so this goes through the invisible LaunchActivity.
val i = Intent(this, LaunchActivity::class.java)
.setAction(LaunchActivity.ACTION_START_LISTENING)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (Build.VERSION.SDK_INT >= 34) {
val pi = PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_IMMUTABLE)
startActivityAndCollapse(pi)
} else {
@Suppress("DEPRECATION")
startActivityAndCollapse(i)
}
}
}
private fun refresh() {
val t = qsTile ?: return
t.state = if (ListenService.running) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
t.label = "Hey Claude"
if (Build.VERSION.SDK_INT >= 29) t.subtitle = if (ListenService.running) "Listening" else "Off"
t.updateTile()
}
}
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:translateX="30"
android:translateY="30">
<path
android:fillColor="#FFF4EDE4"
android:pathData="M24,28c3.32,0 6,-2.68 6,-6V10c0,-3.32 -2.68,-6 -6,-6s-6,2.68 -6,6v12c0,3.32 2.68,6 6,6zM34.6,22c0,6 -5.08,10.2 -10.6,10.2S13.4,28 13.4,22H10c0,6.82 5.44,12.46 12,13.44V42h4v-6.56c6.56,-0.96 12,-6.6 12,-13.44h-3.4z" />
</group>
<path
android:fillColor="#00000000"
android:strokeColor="#FFF4EDE4"
android:strokeWidth="2.5"
android:strokeLineCap="round"
android:pathData="M28,44a26,26 0,0 0,0 20M80,44a26,26 0,0 1,0 20M22,38a36,36 0,0 0,0 32M86,38a36,36 0,0 1,0 32" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11H5c0,3.41 2.72,6.23 6,6.72V21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z" />
</vector>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/icon_bg" />
<foreground android:drawable="@drawable/ic_launcher_fg" />
<monochrome android:drawable="@drawable/ic_launcher_fg" />
</adaptive-icon>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="icon_bg">#FF2B2724</color>
</resources>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="@android:style/Theme.DeviceDefault.DayNight">
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
</style>
<style name="Trampoline" parent="@android:style/Theme.Translucent.NoTitleBar">
<item name="android:windowAnimationStyle">@null</item>
<item name="android:windowNoDisplay">false</item>
</style>
</resources>