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
Executable
+9
View File
@@ -0,0 +1,9 @@
.gradle/
build/
app/build/
*.iml
.idea/
local.properties
.kotlin/
*.jks
*.apk
Executable
+17
View File
@@ -0,0 +1,17 @@
# Hey Claude
A voice assistant for Android that runs entirely on the phone. Say "Hey Claude," wait for the beep, then say what you want.
- "Open Termux" or "launch CompanyCam" opens an app
- "Directions to Riverbend" or "nearest gas station" hands off to Google Maps
- "Call Christina" opens the dialer with the number filled in
- "Text Greg I'm running late", or "Text Greg" and say the message after the second beep
- Anything else, or nothing at all, opens the Claude app
Speech recognition is done offline with Vosk (vosk-model-small-en-us-0.15, downloaded once on first launch). Nothing is recorded, saved or sent anywhere. A second recognizer double checks the wake word against the last few seconds of audio so the TV doesn't set it off.
Built for a Galaxy Z Fold8. Android 10 or newer, arm64 only.
## Building
Open in Android Studio or run `gradlew assembleRelease`. Release builds are signed only if `app/heyclaude.jks` is present, which it isn't in this repo.
+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>
+4
View File
@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.12.3" apply false
id("org.jetbrains.kotlin.android") version "2.1.21" apply false
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
BIN
View File
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored Executable
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "HeyClaude"
include(":app")