Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions SETUP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Setup: Claude subscription on the glasses

Goal: "Hey Meta" is answered by your Claude Pro/Max plan through Visor and a small bridge running in Termux. Why it works and what was measured: [findings.md](findings.md).

You need: an Android phone with this fork of Visor installed and your glasses already set up in it, a Claude Pro or Max account, and about 20 minutes. Claude Code does not list Android as a supported platform, so this is experimental, and your plan's usage limits apply.

## 1. Termux

1. Install Termux from F-Droid or GitHub, not the Play Store (that build is obsolete).
2. In Termux:

```sh
pkg update
pkg install proot-distro
proot-distro install ubuntu
proot-distro login ubuntu
```

From here on, "in Ubuntu" means that last prompt (`root@localhost`).

## 2. Node and Claude Code (in Ubuntu)

Claude Code needs a recent Node (20 or newer). Check with `node -v` after the install; if Ubuntu's package is older, install a newer one (for example with [nvm](https://github.com/nvm-sh/nvm)).

```sh
apt update && apt install -y nodejs npm curl
npm install -g @anthropic-ai/claude-code
claude
```

`claude` asks how to log in: choose the Claude subscription. It prints a link; open it in the phone's browser, sign in, and paste the code it gives back into Termux. Then check it works:

```sh
claude -p "Reply with OK"
```

Log in inside Ubuntu, not in plain Termux: the bridge runs there and uses that login.

## 3. The bridge (in Ubuntu)

```sh
cd /root
curl -O https://raw.githubusercontent.com/henriqueleote/visor/main/bridge.js
node bridge.js
```

It prints `Claude voice bridge on http://127.0.0.1:8080`. From a second Termux session, `curl http://127.0.0.1:8080/v1/models` should answer. Stop it with Ctrl+C.

The file must be at `/root/bridge.js` in a distro called `ubuntu`: the app's Start button runs exactly that.

## 4. Visor

1. Settings > Meta AI > Provider > **Claude subscription**. It fills in server `http://127.0.0.1:8080/v1` and model `claude-code`; leave the key blank.
2. Make sure the bridge is running (step 3, or the Start button below) and ask a question through the glasses.

## 5. Start and stop from the app (optional)

Without this, you start the bridge by hand in Termux each time, and it stops when its command ends (a proot kills what it started, so it must run as the foreground command of its own session).

1. In Termux (outside Ubuntu): `mkdir -p ~/.termux && echo "allow-external-apps = true" >> ~/.termux/termux.properties`, then `termux-reload-settings`. Close Termux fully and open it again.
2. In Visor, on the Claude subscription tab, tap **Allow** on the Bridge row and grant the Termux permission.
3. Tap **Start**. The row shows "Running in Termux." when the bridge answers.
4. Turn off battery optimisation for Termux, or Android will kill it in the background.

## 6. Glasses settings that matter

- Follow-ups without the wake word: Settings > Meta AI > **Respond without "Hey Meta"**.
- A custom wake word ("Hey Jarvis") needs the US English language pack on the glasses.
- Music by voice: start something in Spotify once so it has a media session, then pick it in Visor's media settings.

## If it does not work

See the troubleshooting table in [findings.md](findings.md). The first checks: the Bridge row says Running, `claude -p "Reply with OK"` works in Ubuntu, and the provider is **Claude subscription**, not **Claude API**.

## Not verified

These steps were written from the working setup, not from a clean install: the Node version, the browser sign-in flow and the `curl` download were not re-tested from scratch. If a step differs on your phone, say so and fix it here.
3 changes: 3 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,16 @@
<!-- The phone's speech recogniser requires it, though Visor hands it the glasses' microphone, never the phone's. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- Lets the Claude subscription settings start and stop the bridge in Termux, which must allow external apps. -->
<uses-permission android:name="com.termux.permission.RUN_COMMAND" />

<uses-feature android:name="android.hardware.bluetooth" android:required="true" />
<uses-feature android:name="android.software.companion_device_setup" android:required="false" />

<!-- The settings the phone app fills in: the default SMS app and the media apps on this phone. Also the map and
music apps the assistant opens. -->
<queries>
<package android:name="com.termux" />
<intent>
<action android:name="android.intent.action.SENDTO" />
<data android:scheme="smsto" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ fun MetaAiScreen(onSettings: () -> Unit, onMemories: () -> Unit, onSession: (Str
val attention = rememberUpdatedState(glasses.device?.assistantAttention)
val speech = rememberSpeech(turns, { attention.value }, light)
var phoneListening by remember { mutableStateOf(false) }
val configured = config.apiKey.isNotBlank() || config.provider == Provider.OPENAI_COMPATIBLE
val configured = config.apiKey.isNotBlank() || config.provider != Provider.CLAUDE
val now = rememberNow()
val newest = turns.lastOrNull()
val waiting = turns.takeLast(4).any { it.answer.isEmpty() && it.problem.isEmpty() && System.currentTimeMillis() - it.at < WAITING_MS }
Expand Down
22 changes: 22 additions & 0 deletions app/src/main/kotlin/com/macedonga/visor/phone/PhoneActions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,14 @@ class PhoneActions @Inject constructor(@ApplicationContext private val context:
return PhoneDone(label(c.packageName))
}

override suspend fun openApp(app: String): PhoneDone {
val pkg = launchable(app) ?: return PhoneDone("", "there is no app called $app on the phone")
val label = label(pkg)
note("Opening $label")
val launch = pm.getLaunchIntentForPackage(pkg) ?: return PhoneDone(label, "$label has no screen to open")
return open(launch, OPEN_ID, context.getString(R.string.phone_open, label), label)
}

override fun nowPlaying(): NowPlaying {
val sessions = sessions() ?: return NowPlaying(unknown = "Visor has no notification access, so it cannot see what plays")
val c = sessions.firstOrNull { it.playing() } ?: sessions.firstOrNull() ?: return NowPlaying(unknown = "nothing is playing")
Expand Down Expand Up @@ -263,6 +271,19 @@ class PhoneActions @Inject constructor(@ApplicationContext private val context:
.filter { it.packageName != context.packageName && pm.getLaunchIntentForPackage(it.packageName) != null }
}.getOrNull()

/** The installed app with a launcher called [name]: the same label, else the closest one that contains it. */
private fun launchable(name: String): String? {
val want = simple(name)
if (want.isEmpty()) return null
val apps = pm.queryIntentActivities(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER), 0)
.map { it.activityInfo.packageName }.filter { it != context.packageName }.toSet()
.associateWith { simple(label(it)) }
// An app called X must not open for "Zorbax": the name said may contain the app's only from four letters up.
return apps.entries.firstOrNull { it.value == want }?.key
?: apps.entries.filter { it.value.isNotEmpty() && (it.value.contains(want) || it.value.length >= 4 && want.contains(it.value)) }
.maxByOrNull { it.value.length }?.key
}

/** The installed app called [name], among the ones that play media. */
private fun named(name: String, sessions: List<MediaController>?): String? {
val want = simple(name)
Expand Down Expand Up @@ -350,6 +371,7 @@ class PhoneActions @Inject constructor(@ApplicationContext private val context:
const val CHANNEL = "assistant_actions"
const val DIRECTIONS_ID = 11
const val PLAY_ID = 12
const val OPEN_ID = 13
const val LAST = "last_player"
const val PLAY_WAIT_MS = 6_000L
const val POLL_MS = 250L
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,11 @@ fun AssistantSettingsScreen(open: (NavKey) -> Unit, model: AssistantViewModel =
val askHearing = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { hearing = it }

var check by remember { mutableStateOf<KeyCheck>(KeyCheck.None) }
LaunchedEffect(config.provider, config.apiKey, config.baseUrl) {
var bridgeTick by remember { mutableStateOf(0) }
var bridgeNote by remember { mutableStateOf<String?>(null) }
var termux by remember { mutableStateOf(context.checkSelfPermission(TermuxBridge.PERMISSION) == PackageManager.PERMISSION_GRANTED) }
val askTermux = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { termux = it }
LaunchedEffect(config.provider, config.apiKey, config.baseUrl, bridgeTick) {
if (config.provider == Provider.CLAUDE && config.apiKey.isBlank()) {
check = KeyCheck.None
return@LaunchedEffect
Expand Down Expand Up @@ -125,14 +129,45 @@ fun AssistantSettingsScreen(open: (NavKey) -> Unit, model: AssistantViewModel =
{
PillsRow(
stringResource(R.string.ai_provider),
listOf(Provider.CLAUDE to "Claude", Provider.OPENAI_COMPATIBLE to stringResource(R.string.ai_openai_compatible)),
listOf(
Provider.CLAUDE to "Claude API",
Provider.CLAUDE_SUBSCRIPTION to stringResource(R.string.ai_claude_subscription),
Provider.OPENAI_COMPATIBLE to stringResource(R.string.ai_openai_compatible),
),
config.provider,
onPick = { p -> model.store.update { it.copy(provider = p) } },
onPick = { p ->
model.store.update {
// The bridge's own address and model, unless the address already points at this phone.
if (p == Provider.CLAUDE_SUBSCRIPTION) it.copy(
provider = p, model = "claude-code",
baseUrl = it.baseUrl.takeIf { u -> u.startsWith("http://127.0.0.1") } ?: "http://127.0.0.1:8080/v1",
) else it.copy(provider = p)
}
},
)
},
if (!claude) row {
ValueRow(stringResource(R.string.ai_base_url), config.baseUrl, onClick = { sheet = "url" })
} else null,
if (config.provider == Provider.CLAUDE_SUBSCRIPTION) row {
val up = check is KeyCheck.Works
RowFrame(
stringResource(R.string.ai_bridge), onClick = null,
sub = (bridgeNote ?: stringResource(if (up) R.string.ai_bridge_up else R.string.ai_bridge_down)) +
"\n" + stringResource(R.string.ai_bridge_note),
) {
ActionPill(
stringResource(if (!termux) R.string.access_allow else if (up) R.string.ai_bridge_stop else R.string.ai_bridge_start),
{
if (!termux) askTermux.launch(TermuxBridge.PERMISSION) else {
bridgeNote = TermuxBridge.run(context, !up)
scope.launch { delay(6_000); bridgeTick++ }
}
},
modifier = Modifier.padding(start = 12.dp),
)
}
} else null,
{
val (sub, problem) = when (val c = check) {
KeyCheck.None -> (if (claude) stringResource(R.string.ai_key_needed) else stringResource(R.string.ai_key_optional)) to null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -609,7 +609,7 @@ private fun summary(topic: Topic, model: SettingsViewModel, state: SettingsState

@Composable
internal fun assistantWords(config: AssistantConfig): String {
val configured = config.apiKey.isNotBlank() || config.provider == Provider.OPENAI_COMPATIBLE
val configured = config.apiKey.isNotBlank() || config.provider != Provider.CLAUDE
return when {
!config.enabled -> stringResource(R.string.sum_ai_off)
!configured -> stringResource(R.string.sum_ai_unset)
Expand Down
31 changes: 31 additions & 0 deletions app/src/main/kotlin/com/macedonga/visor/settings/TermuxBridge.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.macedonga.visor.settings

import android.content.ComponentName
import android.content.Context
import android.content.Intent

/** Starts or stops the Claude bridge through Termux's RUN_COMMAND service; Termux needs allow-external-apps on. */
object TermuxBridge {
const val PERMISSION = "com.termux.permission.RUN_COMMAND"
private const val FILES = "/data/data/com.termux/files"

/**
* Null when the request went to Termux, else why it did not. Start runs node as the command itself, because
* anything the proot leaves running is killed when its command ends; stop is a pkill from Termux's side.
*/
fun run(context: Context, start: Boolean): String? = runCatching {
val (path, args) = if (start) {
"$FILES/usr/bin/proot-distro" to arrayOf("login", "ubuntu", "--", "bash", "-c", "cd /root && exec node bridge.js >> bridge.log 2>&1")
} else {
"$FILES/usr/bin/pkill" to arrayOf("-f", "node bridge.js")
}
val intent = Intent("com.termux.RUN_COMMAND")
.setComponent(ComponentName("com.termux", "com.termux.app.RunCommandService"))
.putExtra("com.termux.RUN_COMMAND_PATH", path)
.putExtra("com.termux.RUN_COMMAND_ARGUMENTS", args)
.putExtra("com.termux.RUN_COMMAND_WORKDIR", "$FILES/home")
.putExtra("com.termux.RUN_COMMAND_BACKGROUND", true)
checkNotNull(context.startService(intent)) { "Termux is not installed" }
null
}.getOrElse { it.message ?: it.javaClass.simpleName }
}
8 changes: 8 additions & 0 deletions app/src/main/res/values-it/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,13 @@
<string name="ai_answer_sub">Spento, l\'assistente degli occhiali non ha un telefono dietro.</string>
<string name="ai_section_model">Modello</string>
<string name="ai_provider">Fornitore</string>
<string name="ai_claude_subscription">Abbonamento Claude</string>
<string name="ai_bridge">Bridge</string>
<string name="ai_bridge_start">Avvia</string>
<string name="ai_bridge_stop">Ferma</string>
<string name="ai_bridge_up">Attivo in Termux.</string>
<string name="ai_bridge_down">Non attivo.</string>
<string name="ai_bridge_note">L\'abbonamento Claude funziona solo mentre il bridge è attivo. Per avviarlo da qui serve Termux con allow-external-apps attivo.</string>
<string name="ai_openai_compatible">Compatibile OpenAI</string>
<string name="ai_base_url">Server</string>
<string name="ai_key">Chiave API</string>
Expand Down Expand Up @@ -928,6 +935,7 @@
<string name="hct_retry">Riprova</string>
<string name="phone_directions">Indicazioni per %1$s</string>
<string name="phone_play">Riproduci %1$s</string>
<string name="phone_open">Apri %1$s</string>
<string name="live_settings_glasses_host">Wi-Fi creato dagli occhiali</string>
<string name="live_problem_vpn">Una VPN tiene Visor fuori dalla rete degli occhiali. Escludi Visor dalla VPN.</string>
<string name="live_drops">Lo streaming si interrompe spesso</string>
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/res/values-ru/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,13 @@
<string name="ai_answer_sub">Если выключено, собственный ассистент очков работает сам по себе, без телефона.</string>
<string name="ai_section_model">Модель</string>
<string name="ai_provider">Провайдер</string>
<string name="ai_claude_subscription">Подписка Claude</string>
<string name="ai_bridge">Мост</string>
<string name="ai_bridge_start">Запустить</string>
<string name="ai_bridge_stop">Остановить</string>
<string name="ai_bridge_up">Работает в Termux.</string>
<string name="ai_bridge_down">Не запущен.</string>
<string name="ai_bridge_note">Подписка Claude работает, только пока работает мост. Чтобы запускать его отсюда, в Termux должен быть включён allow-external-apps.</string>
<string name="ai_openai_compatible">Совместимый с OpenAI</string>
<string name="ai_base_url">Адрес</string>
<string name="ai_key">API-ключ</string>
Expand Down Expand Up @@ -929,6 +936,7 @@
<string name="hct_retry">Повторить</string>
<string name="phone_directions">Маршрут до %1$s</string>
<string name="phone_play">Воспроизвести %1$s</string>
<string name="phone_open">Открыть %1$s</string>
<string name="live_settings_glasses_host">Wi-Fi создают очки</string>
<string name="live_problem_vpn">VPN не пускает Visor в сеть очков. Исключите Visor из VPN.</string>
<string name="live_drops">Трансляция часто обрывается</string>
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,13 @@
<string name="ai_answer_sub">Off, the glasses\' own assistant has no phone behind it.</string>
<string name="ai_section_model">Model</string>
<string name="ai_provider">Provider</string>
<string name="ai_claude_subscription">Claude subscription</string>
<string name="ai_bridge">Bridge</string>
<string name="ai_bridge_start">Start</string>
<string name="ai_bridge_stop">Stop</string>
<string name="ai_bridge_up">Running in Termux.</string>
<string name="ai_bridge_down">Not running.</string>
<string name="ai_bridge_note">The Claude subscription only works while the bridge is running. Starting it from here needs Termux with allow-external-apps on.</string>
<string name="ai_openai_compatible">OpenAI-compatible</string>
<string name="ai_base_url">Server</string>
<string name="ai_key">API key</string>
Expand Down Expand Up @@ -930,6 +937,7 @@
<string name="hct_retry">Try again</string>
<string name="phone_directions">Directions to %1$s</string>
<string name="phone_play">Play %1$s</string>
<string name="phone_open">Open %1$s</string>
<string name="live_settings_glasses_host">Glasses host the Wi-Fi</string>
<string name="live_problem_vpn">A VPN keeps Visor off the glasses\' network. Exclude Visor from it.</string>
<string name="live_drops">The stream keeps dropping</string>
Expand Down
Loading