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:
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
app/build/
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
|
local.properties
|
||||||
|
.kotlin/
|
||||||
|
*.jks
|
||||||
|
*.apk
|
||||||
@@ -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.
|
||||||
Executable
+62
@@ -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")
|
||||||
|
}
|
||||||
Executable
+94
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+20
@@ -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>
|
||||||
Executable
+10
@@ -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
@@ -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>
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="icon_bg">#FF2B2724</color>
|
||||||
|
</resources>
|
||||||
Executable
+12
@@ -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>
|
||||||
Executable
+4
@@ -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
|
||||||
|
}
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
BIN
Binary file not shown.
+7
@@ -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
|
||||||
@@ -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" "$@"
|
||||||
+94
@@ -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
|
||||||
Executable
+18
@@ -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")
|
||||||
Reference in New Issue
Block a user