diff --git a/docs/platforms/linux.md b/docs/platforms/linux.md index e8d84695..ed7038b1 100644 --- a/docs/platforms/linux.md +++ b/docs/platforms/linux.md @@ -18,14 +18,25 @@ across platforms, see the screen, a live panel while you dictate, searchable transcript history saved on this computer, and a status tray icon. - **Works across desktops**: GNOME, KDE Plasma, Hyprland, Sway and X11 window managers. +- **Android companion discovery**: when Avahi and a network-reachable SSH server are available, + Talkies advertises this computer so the Android companion can find it without manual host entry. - **`talkies doctor`** checks audio, microphone, hotkey, typing, clipboard, tray and model, and tells you how to fix anything that isn't ready. Ollama and LM Studio cleanup only accept `localhost`, `127.0.0.0/8` or `::1` endpoints. Remote endpoints are rejected and HTTP redirects are disabled, so transcripts never leave the machine. -Besides the model downloads you ask for, the only network request Talkies makes is the update -check described in [Updates](#updates), which sends no user data and can be turned off. +Besides the model downloads you ask for, Talkies makes no outbound Internet requests except the +update check described in [Updates](#updates), which sends no user data and can be turned off. When +enabled, companion discovery is a local-network DNS-SD announcement only. + +For Android companion discovery, install and run Avahi (`avahi-daemon` and +`avahi-publish-service`) and enable an SSH server on the desktop. Talkies publishes the SSH service +only while its daemon is running and only when `ss` reports a non-loopback SSH listener. It advertises +the service as `_ssh._tcp` with a `talkies=1` marker, allowing the Android companion to prefill the +host and port. The default SSH port is 22; set `TALKIES_SSH_PORT` in the Talkies daemon environment +when sshd listens on a different port. Discovery is optional and does not affect Talkies when Avahi, +`ss`, or SSH is unavailable. ## Install diff --git a/docs/platforms/mobile.md b/docs/platforms/mobile.md index c52ae4b9..1e65e9d4 100644 --- a/docs/platforms/mobile.md +++ b/docs/platforms/mobile.md @@ -25,9 +25,12 @@ A runtime is skipped when its model is missing or fails verification, or when it ### Cleanup, keyboard, and supported devices Talkies also includes an Android remote companion for Linux development sessions. Add a host name, -address, SSH port, and username, then connect with a password that is requested for each session and -never saved. New SSH host keys are shown for explicit trust-on-first-use approval and stored in the -app-private `known_hosts` file; changed keys fail closed. The embedded ConnectBot/libvterm terminal +address, SSH port, and username. SSH passwords are requested on first connection, then can be +remembered with an explicit opt-in. Saved passwords are encrypted with an Android Keystore key, +bound to the host profile, and stored outside device backup; reconnects use them without another +prompt. Removing a host also removes its saved password. New SSH host keys are shown for explicit +trust-on-first-use approval and stored in the app-private `known_hosts` file; changed keys fail +closed. The embedded ConnectBot/libvterm terminal supports interactive shells, terminal resizing and colors, and reconnecting to long-lived sessions such as Herdr with `herdr session list` or `herdr session attach`. The local **Dictate** control transcribes on-device and types into the shell without Enter, so the user reviews before running it. @@ -80,7 +83,7 @@ cd mobile/android ./gradlew assembleRelease :inference:testReleaseUnitTest :app:testReleaseUnitTest ``` -Requires JDK 17, Android SDK API 36, CMake 3.31.6, and NDK 27.2. Android runtime support starts at API 28. Android CI builds and tests both debug and release variants, checks that the APKs contain the native runtimes and their license notices but omit model weights, and uploads the tested debug APK as a seven-day artifact. Core JVM tests cover transcript spacing, bounded PCM behavior, the shared cleanup prompt/output contract, and speech-engine selection and fallback. Inference module tests cover pinned model integrity and verified installation for Whisper tiny and Phonon-2, including its attribution files. App tests cover speech-engine status and download-size text, history text, and the keyboard's password and incognito exclusion. Core tests cover the history schema, row rules, UUIDs, search escaping, and keyset paging. `SqliteTranscriptHistoryStoreTest` runs the SQLite store on the emulator in a throwaway database: WAL mode, schema, paging, literal search, delete, and clear. `LocalPhononOfflineAcceptanceTest` runs real Phonon-2 recognition with networking disabled when `files/models/Phonon-2-/` and `files/fixtures/jfk.wav` are pre-provisioned, and is skipped otherwise. API 28 emulator acceptance verifies real Whisper transcription and S1-mini transcript cleanup with external networking disabled. The app includes the whisper.cpp, llama.cpp, CrispASR, and CrispASR ggml MIT license notices. It retains S1-mini's upstream Apache 2.0 files and Phonon-2's CC-BY-4.0 files beside their separately downloaded weights. +Requires JDK 17, Android SDK API 36, CMake 3.31.6, and NDK 27.2. Android runtime support starts at API 28. Android CI builds and tests both debug and release variants, checks that the APKs contain the native runtimes and their license notices but omit model weights, and uploads the tested debug APK as a seven-day artifact. Core JVM tests cover transcript spacing, bounded PCM behavior, the shared cleanup prompt/output contract, and speech-engine selection and fallback. Inference module tests cover pinned model integrity and verified installation for Whisper tiny and Phonon-2, including its attribution files. App tests cover speech-engine status and download-size text, history text, and the keyboard's password and incognito exclusion. The emulator's `AndroidSshCredentialStoreTest` verifies Android Keystore encryption, profile binding, and credential deletion. Core tests cover the history schema, row rules, UUIDs, search escaping, and keyset paging. `SqliteTranscriptHistoryStoreTest` runs the SQLite store on the emulator in a throwaway database: WAL mode, schema, paging, literal search, delete, and clear. `LocalPhononOfflineAcceptanceTest` runs real Phonon-2 recognition with networking disabled when `files/models/Phonon-2-/` and `files/fixtures/jfk.wav` are pre-provisioned, and is skipped otherwise. API 28 emulator acceptance verifies real Whisper transcription and S1-mini transcript cleanup with external networking disabled. The app includes the whisper.cpp, llama.cpp, CrispASR, and CrispASR ggml MIT license notices. It retains S1-mini's upstream Apache 2.0 files and Phonon-2's CC-BY-4.0 files beside their separately downloaded weights. ## Deprecated Flutter prototype diff --git a/linux/src/app/companion_discovery.zig b/linux/src/app/companion_discovery.zig new file mode 100644 index 00000000..c74a5ef7 --- /dev/null +++ b/linux/src/app/companion_discovery.zig @@ -0,0 +1,84 @@ +//! Optional DNS-SD advertisement for the Android Talkies companion. +//! +//! Android scans `_ssh._tcp` to find machines it can securely connect to. +//! Publish only when this machine has a non-loopback SSH listener, and let +//! Avahi's child lifetime match the Talkies daemon lifetime. Discovery is an +//! enhancement; missing Avahi/ss must never prevent dictation from starting. + +const std = @import("std"); +const utils = @import("core").utils; + +const default_ssh_port: u16 = 22; + +pub const Publisher = struct { + child: ?std.process.Child = null, + + pub fn start(allocator: std.mem.Allocator) Publisher { + const port = sshPort() orelse return .{}; + const ss_output = utils.runCapture(allocator, &.{ "ss", "-H", "-ltn" }) catch return .{}; + defer allocator.free(ss_output); + if (!hasReachableListener(ss_output, port)) return .{}; + + const port_text = std.fmt.allocPrintSentinel(allocator, "{d}", .{port}, 0) catch return .{}; + defer allocator.free(port_text); + const child = std.process.spawn(utils.io(), .{ + .argv = &.{ "avahi-publish-service", "-s", "Talkies", "_ssh._tcp", port_text, "talkies=1" }, + .stdin = .ignore, + .stdout = .ignore, + .stderr = .ignore, + }) catch |err| { + // Avahi is optional on many Linux desktops. + if (err != error.FileNotFound) utils.logError("Companion discovery unavailable: {}", .{err}); + return .{}; + }; + utils.log("Advertising the SSH host for the Talkies companion on port {d}", .{port}); + return .{ .child = child }; + } + + pub fn stop(self: *Publisher) void { + if (self.child) |*child| { + child.kill(utils.io()); + self.child = null; + } + } +}; + +fn sshPort() ?u16 { + const value = utils.getEnv("TALKIES_SSH_PORT") orelse return default_ssh_port; + const port = std.fmt.parseInt(u16, value, 10) catch return null; + return if (port == 0) null else port; +} + +fn hasReachableListener(ss_output: []const u8, port: u16) bool { + var lines = std.mem.tokenizeScalar(u8, ss_output, '\n'); + while (lines.next()) |line| { + var fields = std.mem.tokenizeAny(u8, line, " \t"); + _ = fields.next() orelse continue; // state + _ = fields.next() orelse continue; // recv-q + _ = fields.next() orelse continue; // send-q + const local = fields.next() orelse continue; + const remote = fields.next() orelse continue; + _ = remote; + + var suffix_buf: [6]u8 = undefined; + const suffix = std.fmt.bufPrint(&suffix_buf, ":{d}", .{port}) catch continue; + if (!std.mem.endsWith(u8, local, suffix)) continue; + const address_with_brackets = local[0 .. local.len - suffix.len]; + const address = if (std.mem.startsWith(u8, address_with_brackets, "[") and + std.mem.endsWith(u8, address_with_brackets, "]")) + address_with_brackets[1 .. address_with_brackets.len - 1] + else + address_with_brackets; + if (std.mem.eql(u8, address, "127.0.0.1") or std.mem.eql(u8, address, "::1")) continue; + return true; + } + return false; +} + +test "detects only externally reachable SSH listeners" { + try std.testing.expect(hasReachableListener("LISTEN 0 128 0.0.0.0:22 0.0.0.0:*\n", 22)); + try std.testing.expect(hasReachableListener("LISTEN 0 128 [::]:2222 [::]:*\n", 2222)); + try std.testing.expect(!hasReachableListener("LISTEN 0 128 127.0.0.1:22 0.0.0.0:*\n", 22)); + try std.testing.expect(!hasReachableListener("LISTEN 0 128 [::1]:22 [::]:*\n", 22)); + try std.testing.expect(!hasReachableListener("LISTEN 0 128 0.0.0.0:2200 0.0.0.0:*\n", 22)); +} diff --git a/linux/src/app/daemon.zig b/linux/src/app/daemon.zig index 9b823fe3..0a1f0ab8 100644 --- a/linux/src/app/daemon.zig +++ b/linux/src/app/daemon.zig @@ -32,6 +32,7 @@ const yap_sessions = @import("yap_sessions.zig"); const yap_window = @import("yap_window.zig"); const history = @import("history.zig"); const updater = @import("updater.zig"); +const companion_discovery = @import("companion_discovery.zig"); const trigger_mod = @import("trigger.zig"); const build_options = @import("build_options"); @@ -516,12 +517,14 @@ pub const Daemon = struct { yap_sb: ?*yap_sandbox.Sandbox = null, yap_session_manager: ?*yap_sessions.SessionManager = null, yap_session_id: ?i64 = null, + companion_publisher: companion_discovery.Publisher = .{}, // ----------------------------------------------------------------- // Lifecycle // ----------------------------------------------------------------- fn deinit(self: *Daemon) void { + self.companion_publisher.stop(); if (self.capture) |cap| { cap.stop_requested.store(true, .release); cap.thread.join(); @@ -2351,6 +2354,9 @@ pub fn run(allocator: std.mem.Allocator) !?[]u8 { defer daemon.deinit(); try daemon.cfg.load(); + // The publisher is optional and process-scoped. Android can discover a + // reachable SSH daemon without manual host/port entry when Avahi is used. + daemon.companion_publisher = companion_discovery.Publisher.start(allocator); daemon.cleanup_service.threads = daemon.cfg.threads; // Opened even while saving is off, so the history page can show (and // delete) what was saved before. diff --git a/mobile/android/app/src/androidTest/java/com/talkies/android/AndroidSshCredentialStoreTest.kt b/mobile/android/app/src/androidTest/java/com/talkies/android/AndroidSshCredentialStoreTest.kt new file mode 100644 index 00000000..08ea5f71 --- /dev/null +++ b/mobile/android/app/src/androidTest/java/com/talkies/android/AndroidSshCredentialStoreTest.kt @@ -0,0 +1,67 @@ +package com.talkies.android + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.nio.charset.StandardCharsets +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +class AndroidSshCredentialStoreTest { + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun remembersPasswordEncryptedAndBindsItToItsHostProfile() { + val store = AndroidSshCredentialStore(context) + val profile = profile() + val password = "Correct horse 🔐 battery".toCharArray() + try { + val directory = File(context.noBackupFilesDir, "ssh-credentials") + val existing = directory.listFiles().orEmpty().map { it.absolutePath }.toSet() + store.save(profile, password) + val files = directory.listFiles() + assertNotNull(files) + val encryptedFile = requireNotNull(files).single { + it.extension == "credential" && it.absolutePath !in existing + } + assertFalse(String(encryptedFile.readBytes(), StandardCharsets.UTF_8).contains(String(password))) + assertArrayEquals(password, requireNotNull(store.load(profile))) + + val changedHost = profile.copy(host = "other.example") + assertNull(store.load(changedHost)) + assertNull(store.load(profile)) + } finally { + password.fill('\u0000') + store.delete(profile.id) + } + } + + @Test + fun deletingAHostCredentialMakesItUnavailable() { + val store = AndroidSshCredentialStore(context) + val profile = profile() + val password = "secret".toCharArray() + try { + store.save(profile, password) + store.delete(profile.id) + assertNull(store.load(profile)) + } finally { + password.fill('\u0000') + store.delete(profile.id) + } + } + + private fun profile() = SshHostProfile( + id = UUID.randomUUID().toString(), + name = "Test host", + host = "192.0.2.4", + port = 2222, + username = "tester", + ) +} diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 2112ab5f..ea9aba08 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + Unit, + onSelect: () -> Unit, + onAllowMicrophone: () -> Unit, +) { + Card( + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + Column( + Modifier.fillMaxWidth().padding(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + TalkiesGlyph(TalkiesGlyphKind.KEYBOARD, MaterialTheme.colorScheme.primary, Modifier.size(40.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("VOICE, WHERE YOU TYPE", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) + Text("Make Talkies your keyboard", style = MaterialTheme.typography.titleLarge) + } + Text(if (selected) "READY" else "SETUP", style = MaterialTheme.typography.labelSmall, color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.tertiary) + } + Text( + "Dictate directly into messages, notes, and terminals. Your audio is transcribed on this device.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + SetupStep( + number = "01", + title = "Enable Talkies", + detail = if (enabled) "Keyboard permission is enabled" else "Turn on Talkies voice typing in Android settings", + done = enabled, + action = if (enabled) "Enabled" else "Open settings", + onClick = onEnable, + ) + SetupStep( + number = "02", + title = "Choose Talkies", + detail = if (selected) "Ready in any text field" else if (enabled) "Switch to Talkies from the keyboard picker" else "Enable Talkies first", + done = selected, + action = if (selected) "Selected" else "Choose keyboard", + enabled = enabled && !selected, + onClick = onSelect, + ) + SetupStep( + number = "03", + title = "Allow voice input", + detail = if (microphoneAllowed) "Microphone is ready for Talkies" else "Grant access once to dictate from any text field", + done = microphoneAllowed, + action = if (microphoneAllowed) "Allowed" else "Allow mic", + enabled = selected && !microphoneAllowed, + onClick = onAllowMicrophone, + ) + if (selected) { + Text("Tip: use the keyboard switcher to return to your usual keyboard at any time.", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +@Composable +private fun SetupStep( + number: String, + title: String, + detail: String, + done: Boolean, + action: String, + enabled: Boolean = true, + onClick: () -> Unit, +) { + Row( + Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(MaterialTheme.colorScheme.surfaceVariant).padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + if (done) "✓" else number, + modifier = Modifier.clip(CircleShape).background(if (done) MaterialTheme.colorScheme.primaryContainer else Color(0xFF273653)).padding(horizontal = 10.dp, vertical = 7.dp), + style = MaterialTheme.typography.labelMedium, + color = if (done) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.primary, + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall) + Text(detail, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (done) { + Text("DONE", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary) + } else if (number == "01") { + Button(enabled = enabled, onClick = onClick) { Text(action) } + } else { + OutlinedButton(enabled = enabled, onClick = onClick) { Text(action) } + } + } +} diff --git a/mobile/android/app/src/main/java/com/talkies/android/MainActivity.kt b/mobile/android/app/src/main/java/com/talkies/android/MainActivity.kt index 3e045df8..bbc33bc8 100644 --- a/mobile/android/app/src/main/java/com/talkies/android/MainActivity.kt +++ b/mobile/android/app/src/main/java/com/talkies/android/MainActivity.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.background import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog @@ -40,6 +41,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -104,6 +109,7 @@ class MainActivity : ComponentActivity() { private var showHistory by mutableStateOf(false) private var showRemoteCompanion by mutableStateOf(false) private var confirmClearHistory by mutableStateOf(false) + private var pendingMicrophoneAction by mutableStateOf<(() -> Unit)?>(null) private var remoteDictationSink: ((String) -> Unit)? = null private var remoteDictationInProgress = false @@ -115,6 +121,7 @@ class MainActivity : ComponentActivity() { ) cleanupCleaner = S1MiniCleaner(cleanupModelStore) val preferences = getSharedPreferences(SpeechSettings.PREFERENCES, MODE_PRIVATE) + val permissionPreferences = getSharedPreferences("permission-flow", MODE_PRIVATE) enginePreference = SpeechSettings.enginePreference(preferences) cleanupEnabled = preferences.getBoolean("s1-mini-enabled", false) cleanupStyle = enumValueOrDefault(preferences.getString("s1-mini-style", null), TranscriptStyle.SEMI_FORMAL) @@ -122,12 +129,69 @@ class MainActivity : ComponentActivity() { cleanupContext = enumValueOrDefault(preferences.getString("s1-mini-context", null), TranscriptContext.GENERAL) historyEnabled = TranscriptHistory.isEnabled(preferences) setContent { - MaterialTheme { + TalkiesTheme { val context = LocalContext.current + var showMicrophoneExplanation by remember { mutableStateOf(false) } + var showMicrophoneSettings by remember { mutableStateOf(false) } + var keyboardEnabled by remember { mutableStateOf(false) } + var keyboardSelected by remember { mutableStateOf(false) } + var microphoneAllowed by remember { + mutableStateOf(checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) + } + var microphonePermissionAsked by remember { + mutableStateOf(permissionPreferences.getBoolean("microphone-asked", false)) + } + fun refreshKeyboardStatus() { + val inputMethods = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + keyboardEnabled = inputMethods.enabledInputMethodList.any { + it.packageName == packageName && it.serviceName.endsWith("TalkiesInputMethodService") + } + val currentMethod = android.provider.Settings.Secure.getString( + contentResolver, + android.provider.Settings.Secure.DEFAULT_INPUT_METHOD, + ) + keyboardSelected = keyboardEnabled && currentMethod?.endsWith("TalkiesInputMethodService") == true + microphoneAllowed = checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + } + LaunchedEffect(Unit) { refreshKeyboardStatus() } + DisposableEffect(lifecycle) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) refreshKeyboardStatus() + if (event == Lifecycle.Event.ON_RESUME && + checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + ) { + pendingMicrophoneAction?.let { continuation -> + pendingMicrophoneAction = null + showMicrophoneSettings = false + continuation() + } + } + } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } val requestPermission = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> - if (granted) startRecording() else status = "Microphone permission is needed to dictate." + if (granted) { + val continuation = pendingMicrophoneAction + pendingMicrophoneAction = null + continuation?.invoke() + } + else { + status = "Microphone access is needed for dictation. You can allow it in Android Settings." + if (!shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO) && microphonePermissionAsked) { + showMicrophoneSettings = true + } else pendingMicrophoneAction = null + } + } + fun requestMicrophone(continuation: () -> Unit) { + if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { + continuation() + } else { + pendingMicrophoneAction = continuation + showMicrophoneExplanation = true + } } Surface(modifier = Modifier.fillMaxSize()) { if (showHistory) { @@ -138,6 +202,8 @@ class MainActivity : ComponentActivity() { SshCompanionScreen( context = context, onBack = { + pendingMicrophoneAction = null + showMicrophoneExplanation = false if (recording) { audioCapture.cancel(); recording = false } remoteDictationSink = null if (!transcribing) remoteDictationInProgress = false @@ -148,9 +214,11 @@ class MainActivity : ComponentActivity() { status = "Finish the current dictation or download a speech model before recording again." return@SshCompanionScreen } - remoteDictationSink = sink - remoteDictationInProgress = true - startRecording() + requestMicrophone { + remoteDictationSink = sink + remoteDictationInProgress = true + startRecording() + } }, ) return@Surface @@ -159,28 +227,38 @@ class MainActivity : ComponentActivity() { modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text("Talkies", style = MaterialTheme.typography.headlineLarge) - Text("Private, on-device dictation", style = MaterialTheme.typography.titleMedium) - Text(status, style = MaterialTheme.typography.bodyMedium) + androidx.compose.foundation.layout.Box( + Modifier.fillMaxWidth() + .background( + androidx.compose.ui.graphics.Brush.linearGradient( + listOf(androidx.compose.ui.graphics.Color(0xFF103D3A), androidx.compose.ui.graphics.Color(0xFF202448), androidx.compose.ui.graphics.Color(0xFF171526)), + ), + shape = androidx.compose.foundation.shape.RoundedCornerShape(26.dp), + ) + .padding(22.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("TALKIES / LOCAL INTELLIGENCE", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + Text("Your voice.\nYour machine.", style = MaterialTheme.typography.headlineLarge) + Text("Private dictation, flowing into every app.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(status, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.tertiary) + } + } OutlinedButton(onClick = { showRemoteCompanion = true }) { Text("Remote companion · SSH / Herdr") } - Text("Type by voice in any app", style = MaterialTheme.typography.titleMedium) - Text( - "Enable Talkies voice typing as a keyboard, grant microphone access here, then select Talkies from your keyboard switcher. Dictation and optional S1-mini cleanup run on-device and insert into the focused field.", - style = MaterialTheme.typography.bodySmall + KeyboardSetupCard( + enabled = keyboardEnabled, + selected = keyboardSelected, + microphoneAllowed = microphoneAllowed, + onEnable = { startActivity(Intent(android.provider.Settings.ACTION_INPUT_METHOD_SETTINGS)) }, + onSelect = { + (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager).showInputMethodPicker() + }, + onAllowMicrophone = { requestMicrophone {} }, ) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - OutlinedButton(onClick = { - startActivity(Intent(android.provider.Settings.ACTION_INPUT_METHOD_SETTINGS)) - }) { Text("Enable keyboard") } - OutlinedButton(onClick = { - (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager) - .showInputMethodPicker() - }) { Text("Select keyboard") } - } Text("Speech engine", style = MaterialTheme.typography.titleMedium) Text( @@ -339,9 +417,7 @@ class MainActivity : ComponentActivity() { Button( enabled = dictationEngines.isNotEmpty() && !recording && !transcribing && !anyDownloadBusy(), onClick = { - if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { - startRecording() - } else requestPermission.launch(Manifest.permission.RECORD_AUDIO) + requestMicrophone { startRecording() } } ) { Text("Record") } OutlinedButton(enabled = recording, onClick = { stopRecording() }) { Text("Stop") } @@ -407,6 +483,48 @@ class MainActivity : ComponentActivity() { ) } } + if (showMicrophoneExplanation) { + AlertDialog( + onDismissRequest = { showMicrophoneExplanation = false; pendingMicrophoneAction = null }, + title = { Text("Microphone access") }, + text = { + Text( + "Talkies records only after you tap a dictation action. Audio stays in memory " + + "on this device and is used by your selected local speech model. Android " + + "needs microphone permission before recording can begin.", + ) + }, + confirmButton = { + TextButton(onClick = { + showMicrophoneExplanation = false + microphonePermissionAsked = true + permissionPreferences.edit().putBoolean("microphone-asked", true).apply() + requestPermission.launch(Manifest.permission.RECORD_AUDIO) + }) { Text("Continue") } + }, + dismissButton = { + TextButton(onClick = { showMicrophoneExplanation = false; pendingMicrophoneAction = null }) { + Text("Not now") + } + }, + ) + } + if (showMicrophoneSettings) { + AlertDialog( + onDismissRequest = { showMicrophoneSettings = false }, + title = { Text("Allow microphone in Settings") }, + text = { Text("Android has blocked another permission prompt. Open Talkies app settings and allow Microphone, then return and try again.") }, + confirmButton = { + TextButton(onClick = { + showMicrophoneSettings = false + startActivity(Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = android.net.Uri.fromParts("package", packageName, null) + }) + }) { Text("Open Settings") } + }, + dismissButton = { TextButton(onClick = { showMicrophoneSettings = false }) { Text("Cancel") } }, + ) + } } } refreshModelStatus(updateStatus = true) diff --git a/mobile/android/app/src/main/java/com/talkies/android/SshCompanion.kt b/mobile/android/app/src/main/java/com/talkies/android/SshCompanion.kt index c847477b..27c87c58 100644 --- a/mobile/android/app/src/main/java/com/talkies/android/SshCompanion.kt +++ b/mobile/android/app/src/main/java/com/talkies/android/SshCompanion.kt @@ -16,7 +16,7 @@ import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit import java.nio.CharBuffer -/** Local, non-secret connection profiles. Passwords are requested for each connection, never saved. */ +/** Local connection profiles. SSH passwords live separately in Android Keystore-backed storage. */ internal class AndroidSshHostProfileStore(context: Context) : SshHostProfileStore { private val preferences = context.getSharedPreferences("ssh-hosts", Context.MODE_PRIVATE) diff --git a/mobile/android/app/src/main/java/com/talkies/android/SshCompanionScreen.kt b/mobile/android/app/src/main/java/com/talkies/android/SshCompanionScreen.kt index 1ae505c3..31a5b5b8 100644 --- a/mobile/android/app/src/main/java/com/talkies/android/SshCompanionScreen.kt +++ b/mobile/android/app/src/main/java/com/talkies/android/SshCompanionScreen.kt @@ -1,18 +1,22 @@ package com.talkies.android -import android.Manifest import android.content.Context -import android.content.pm.PackageManager import android.os.Handler import android.os.Looper -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.Alignment import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.verticalScroll @@ -20,15 +24,22 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.darkColorScheme +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -37,6 +48,9 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.unit.dp import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.ImeAction @@ -47,6 +61,7 @@ import org.connectbot.terminal.TerminalEmulatorFactory import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.delay import java.util.UUID import java.util.concurrent.CompletableFuture @@ -58,9 +73,12 @@ private val terminalAccent = Color(0xFF6FFFE9) internal fun SshCompanionScreen(context: Context, onBack: () -> Unit, onDictate: ((String) -> Unit) -> Unit) { val appContext = context.applicationContext val store = remember(appContext) { AndroidSshHostProfileStore(appContext) } + val credentialStore = remember(appContext) { AndroidSshCredentialStore(appContext) } val scope = rememberCoroutineScope() val mainHandler = remember { Handler(Looper.getMainLooper()) } var profiles by remember { mutableStateOf(store.list()) } + var discoveredHosts by remember { mutableStateOf(emptyList()) } + var scanningHosts by remember { mutableStateOf(false) } var editing by remember { mutableStateOf(false) } var editId by remember { mutableStateOf(null) } var name by remember { mutableStateOf("") } @@ -70,6 +88,8 @@ internal fun SshCompanionScreen(context: Context, onBack: () -> Unit, onDictate: var formError by remember { mutableStateOf(null) } var pendingProfile by remember { mutableStateOf(null) } var password by remember { mutableStateOf("") } + var rememberPassword by remember { mutableStateOf(false) } + var resolvingCredential by remember { mutableStateOf(false) } var connecting by remember { mutableStateOf(false) } var connectionError by remember { mutableStateOf(null) } var cmuxSocketPassword by remember { mutableStateOf("") } @@ -79,7 +99,6 @@ internal fun SshCompanionScreen(context: Context, onBack: () -> Unit, onDictate: var activeProfile by remember { mutableStateOf(null) } var terminal by remember { mutableStateOf(null) } var connected by remember { mutableStateOf(false) } - var dictateAfterPermission by remember { mutableStateOf(false) } var agents by remember { mutableStateOf>(emptyList()) } var herdrSessions by remember { mutableStateOf(listOf(HerdrSession("default", true, true))) } var selectedHerdrSession by remember { mutableStateOf("default") } @@ -91,6 +110,20 @@ internal fun SshCompanionScreen(context: Context, onBack: () -> Unit, onDictate: var agentError by remember { mutableStateOf(null) } var confirmPrompt by remember { mutableStateOf(false) } var dictationForAgent by remember { mutableStateOf(true) } + val hostDiscovery = remember(appContext) { SshHostDiscovery(appContext) } + DisposableEffect(hostDiscovery) { onDispose { hostDiscovery.close() } } + fun scanHosts() { + scanningHosts = true + hostDiscovery.start { discoveredHosts = it } + } + LaunchedEffect(hostDiscovery) { scanHosts() } + LaunchedEffect(scanningHosts) { + if (scanningHosts) { + delay(8_000) + hostDiscovery.stop() + scanningHosts = false + } + } val terminalEmulator = remember { TerminalEmulatorFactory.create( looper = Looper.getMainLooper(), @@ -121,20 +154,6 @@ internal fun SshCompanionScreen(context: Context, onBack: () -> Unit, onDictate: terminalForCleanup?.close() } } - val requestMicrophone = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (granted && dictateAfterPermission) { - onDictate { text -> - val safeText = TerminalInputPolicy.stage(text) - if (safeText.isNotEmpty()) { - if (dictationForAgent && selectedAgent != null) { - promptDraft = listOf(promptDraft.trim(), safeText).filter(String::isNotEmpty).joinToString(" ") - } else insertWithoutSubmit(terminalEmulator, safeText) - } - } - } - dictateAfterPermission = false - } - suspend fun refreshAgents(session: SshTerminalSession) { if (agentBusy) return agentBusy = true @@ -156,8 +175,12 @@ internal fun SshCompanionScreen(context: Context, onBack: () -> Unit, onDictate: } } - fun connect(profile: SshHostProfile, secret: CharArray) { - if (connecting) return + fun connect(profile: SshHostProfile, secret: CharArray, savePassword: Boolean = false, storedPassword: Boolean = false) { + if (connecting) { + secret.fill('\u0000') + return + } + val passwordToStore = if (savePassword) secret.copyOf() else null connecting = true connectionError = null activeProfile = profile @@ -177,8 +200,14 @@ internal fun SshCompanionScreen(context: Context, onBack: () -> Unit, onDictate: mainHandler.post { hostTrust = HostTrustPrompt(message, answer) } } } + val saveError = passwordToStore?.let { savedPassword -> + withContext(Dispatchers.IO) { + runCatching { credentialStore.save(profile, savedPassword) }.exceptionOrNull() + } + } terminal = next connected = true + if (saveError != null) connectionError = "Connected, but the password could not be saved securely." agents = emptyList() selectedAgent = null agentOutput = "" @@ -200,12 +229,35 @@ internal fun SshCompanionScreen(context: Context, onBack: () -> Unit, onDictate: } catch (error: Exception) { connectionError = error.message?.take(400) ?: "SSH connection failed. Check the host and network." connected = false + if (storedPassword && error.message?.contains("auth fail", ignoreCase = true) == true) { + credentialStore.delete(profile.id) + pendingProfile = profile + password = "" + rememberPassword = true + } } finally { + passwordToStore?.fill('\u0000') connecting = false } } } + fun openProfile(profile: SshHostProfile) { + pendingProfile = profile + password = "" + rememberPassword = false + connectionError = null + resolvingCredential = true + scope.launch { + val saved = withContext(Dispatchers.IO) { runCatching { credentialStore.load(profile) }.getOrNull() } + resolvingCredential = false + if (saved != null) { + pendingProfile = null + connect(profile, saved, storedPassword = true) + } + } + } + fun disconnect() { terminal?.close() terminal = null @@ -245,57 +297,134 @@ internal fun SshCompanionScreen(context: Context, onBack: () -> Unit, onDictate: return } dictationForAgent = true - dictateAfterPermission = true - if (context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { - onDictate { text -> - val clean = TerminalInputPolicy.stage(text) - if (clean.isNotEmpty()) promptDraft = listOf(promptDraft.trim(), clean).filter(String::isNotEmpty).joinToString(" ") - } - dictateAfterPermission = false - } else requestMicrophone.launch(Manifest.permission.RECORD_AUDIO) + onDictate { text -> + val clean = TerminalInputPolicy.stage(text) + if (clean.isNotEmpty()) promptDraft = listOf(promptDraft.trim(), clean).filter(String::isNotEmpty).joinToString(" ") + } } fun dictateToShell() { dictationForAgent = false - dictateAfterPermission = true - if (context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { - onDictate { text -> - val clean = TerminalInputPolicy.stage(text) - if (clean.isNotEmpty()) insertWithoutSubmit(terminalEmulator, clean) - } - dictateAfterPermission = false - } else requestMicrophone.launch(Manifest.permission.RECORD_AUDIO) + onDictate { text -> + val clean = TerminalInputPolicy.stage(text) + if (clean.isNotEmpty()) insertWithoutSubmit(terminalEmulator, clean) + } } - MaterialTheme( - colorScheme = darkColorScheme( - primary = terminalAccent, - secondary = Color(0xFFB69CFF), - background = Color(0xFF070B14), - surface = Color(0xFF111827), - onPrimary = Color(0xFF071018), - onBackground = terminalForeground, - onSurface = terminalForeground, - error = Color(0xFFFF718B), - ), - ) { + TalkiesTheme { Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxSize()) { Column( - modifier = Modifier.fillMaxSize().padding(20.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.fillMaxSize() + .then(if (activeProfile == null) Modifier.verticalScroll(rememberScrollState()) else Modifier) + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Column { - Text("FIELD LINK", style = MaterialTheme.typography.labelLarge, color = terminalAccent) - Text("Remote companion", style = MaterialTheme.typography.headlineSmall) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("TALKIES / FIELD LINK", style = MaterialTheme.typography.labelMedium, color = terminalAccent) + Text("Remote workspace", style = MaterialTheme.typography.titleLarge) } - TextButton(onClick = { disconnect(); onBack() }) { Text("Back") } + TextButton(onClick = { disconnect(); onBack() }) { Text("Close") } } if (activeProfile == null) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text("SSH HOSTS", style = MaterialTheme.typography.titleMedium) - Button(onClick = { + Card( + shape = RoundedCornerShape(26.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + Box( + Modifier.fillMaxWidth() + .background( + Brush.linearGradient( + listOf(Color(0xFF103D3A), Color(0xFF202448), Color(0xFF171526)), + ), + ) + .padding(20.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("SECURE LINK / SSH", style = MaterialTheme.typography.labelLarge, color = terminalAccent) + LinkStateBadge( + label = if (scanningHosts) "SCANNING" else if (discoveredHosts.isEmpty()) "STANDBY" else "${discoveredHosts.size} FOUND", + active = scanningHosts || discoveredHosts.isNotEmpty(), + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Your work,\nwithin reach.", style = MaterialTheme.typography.headlineMedium) + Text( + "Nearby workstations, one secure link away.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = { + if (scanningHosts) { + hostDiscovery.stop() + scanningHosts = false + } else scanHosts() + }, + shape = RoundedCornerShape(16.dp), + ) { + Text(if (scanningHosts) "Stop scan" else "Find nearby machines") + } + } + DiscoveryRadar(scanning = scanningHosts, modifier = Modifier.size(96.dp)) + } + Text( + "Passwords stay encrypted on this device · saving is optional", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + if (discoveredHosts.isNotEmpty()) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("NEARBY MACHINES", style = MaterialTheme.typography.labelLarge, color = terminalAccent) + Text("${discoveredHosts.size} found", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + discoveredHosts.forEach { found -> + NearbyHostCard(host = found) { + val savedProfile = profiles.firstOrNull { + it.host.equals(found.host, ignoreCase = true) && it.port == found.port + } + val commonUsername = profiles.map { it.username }.distinct().singleOrNull() + val profile = savedProfile ?: found.toProfile(commonUsername) + if (profile != null) { + if (savedProfile == null) store.save(profile) + profiles = store.list() + openProfile(profile) + } else { + editId = null + name = found.name + host = found.host + port = found.port.toString() + username = "" + formError = "Enter the SSH username for this machine." + editing = true + } + } + } + } + } else if (!scanningHosts) { + Text( + "No SSH services advertised yet. You can still add a host manually; a saved host appears here next time.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("YOUR MACHINES", style = MaterialTheme.typography.titleMedium) + Text("Saved secure connections", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + OutlinedButton(onClick = { editId = null name = "" host = "" @@ -305,17 +434,11 @@ internal fun SshCompanionScreen(context: Context, onBack: () -> Unit, onDictate: editing = true }) { Text("Add host") } } - Text( - "Save destinations, then connect to a Linux shell and continue an existing Herdr session. " + - "Talkies never stores your password or sends dictated text to run automatically.", - style = MaterialTheme.typography.bodySmall, - ) - Text("Transport: SSH · Mosh is not configured in this build", style = MaterialTheme.typography.labelSmall) Column( - modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()), + modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - if (profiles.isEmpty()) { + if (profiles.isEmpty() && discoveredHosts.isEmpty()) { Card { Text( "No hosts yet. Add your Linux machine’s SSH address to get started.", @@ -324,53 +447,54 @@ internal fun SshCompanionScreen(context: Context, onBack: () -> Unit, onDictate: } } profiles.forEach { profile -> - Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { - Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(profile.name, style = MaterialTheme.typography.titleMedium) - Text("${profile.username}@${profile.host}:${profile.port}", style = MaterialTheme.typography.bodySmall) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { - pendingProfile = profile - password = "" - connectionError = null - }) { Text("Connect") } - OutlinedButton(onClick = { - editId = profile.id - name = profile.name - host = profile.host - port = profile.port.toString() - username = profile.username - formError = null - editing = true - }) { Text("Edit") } - TextButton(onClick = { - store.delete(profile.id) - profiles = store.list() - }) { Text("Remove") } - } - } - } + SavedHostCard( + profile = profile, + onConnect = { + openProfile(profile) + }, + onEdit = { + editId = profile.id + name = profile.name + host = profile.host + port = profile.port.toString() + username = profile.username + formError = null + editing = true + }, + onRemove = { + credentialStore.delete(profile.id) + store.delete(profile.id) + profiles = store.list() + }, + ) } } } else { val profile = requireNotNull(activeProfile) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Column { - Text(profile.name, style = MaterialTheme.typography.titleMedium) - Text( - if (connected) "CONNECTED · HOST VERIFIED" else "DISCONNECTED", - style = MaterialTheme.typography.labelSmall, - color = if (connected) terminalAccent else MaterialTheme.colorScheme.error, + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(profile.name, style = MaterialTheme.typography.titleLarge) + Text("${profile.username}@${profile.host}:${profile.port}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + LinkStateBadge( + label = when { connecting -> "LINKING"; connected -> "VERIFIED"; else -> "OFFLINE" }, + active = connected || connecting, ) + if (connected) TextButton(onClick = { disconnect() }) { Text("Disconnect") } } - OutlinedButton(onClick = { disconnect() }) { Text("Disconnect") } } if (!connected) { Button( enabled = !connecting, - onClick = { pendingProfile = profile; password = "" }, + onClick = { openProfile(profile) }, ) { Text(if (connecting) "Reconnecting…" else "Reconnect") } } + Text("WORKSPACE CONTROLS", style = MaterialTheme.typography.labelLarge, color = terminalAccent) Row(modifier = Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { OutlinedButton(enabled = connected, onClick = { send("herdr session list\r") }) { Text("Herdr sessions") } OutlinedButton(enabled = connected, onClick = { insertWithoutSubmit(terminalEmulator, "herdr session attach ") }) { @@ -383,8 +507,8 @@ internal fun SshCompanionScreen(context: Context, onBack: () -> Unit, onDictate: } Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Column { - Text("AGENT INBOX", style = MaterialTheme.typography.titleMedium) - Text("Herdr · cmux · live state", style = MaterialTheme.typography.labelSmall, color = terminalAccent) + Text("AGENT SIGNALS", style = MaterialTheme.typography.titleMedium) + Text("Herdr + cmux · live host scan", style = MaterialTheme.typography.labelSmall, color = terminalAccent) } Row(modifier = Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) { OutlinedButton(enabled = connected, onClick = { cmuxAuthDraft = cmuxSocketPassword; showCmuxAuth = true }) { @@ -602,32 +726,39 @@ internal fun SshCompanionScreen(context: Context, onBack: () -> Unit, onDictate: pendingProfile?.let { profile -> AlertDialog( - onDismissRequest = { pendingProfile = null; password = "" }, + onDismissRequest = { pendingProfile = null; password = ""; rememberPassword = false }, title = { Text("Connect to ${profile.name}") }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Password is used only for this connection and is not saved.") + Text("Passwords are saved only when you choose to remember them. Saved passwords are encrypted with Android Keystore and excluded from device backup.") OutlinedTextField( value = password, onValueChange = { password = it }, label = { Text("SSH password") }, + enabled = !resolvingCredential && !connecting, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), singleLine = true, ) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = rememberPassword, onCheckedChange = { rememberPassword = it }) + Text("Remember password on this device") + } + if (resolvingCredential) Text("Checking for a saved password…", color = MaterialTheme.colorScheme.onSurfaceVariant) connectionError?.let { Text(it, color = MaterialTheme.colorScheme.error) } } }, confirmButton = { TextButton(enabled = !connecting && password.isNotEmpty(), onClick = { val secret = password.toCharArray() + val savePassword = rememberPassword password = "" - connect(profile, secret) + connect(profile, secret, savePassword = savePassword) }) { Text(if (connecting) "Connecting…" else "Connect") } }, - dismissButton = { TextButton(onClick = { pendingProfile = null; password = "" }) { Text("Cancel") } }, + dismissButton = { TextButton(onClick = { pendingProfile = null; password = ""; rememberPassword = false }) { Text("Cancel") } }, ) } @@ -659,4 +790,115 @@ private fun agentStateLabel(state: AgentLifeState): String = when (state) { AgentLifeState.UNKNOWN -> "STATE UNKNOWN" } +@Composable +private fun LinkStateBadge(label: String, active: Boolean) { + val tint = if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + Surface( + color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(50), + ) { + Row( + Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box(Modifier.size(6.dp).background(tint, RoundedCornerShape(50))) + Text(label, style = MaterialTheme.typography.labelSmall, color = tint) + } + } +} + +@Composable +private fun NearbyHostCard(host: DiscoveredSshHost, onConnect: () -> Unit) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + shape = RoundedCornerShape(20.dp), + ) { + Row( + Modifier.fillMaxWidth().padding(start = 14.dp, top = 12.dp, end = 10.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box(Modifier.size(42.dp).background(Color(0xFF17383B), RoundedCornerShape(14.dp)), contentAlignment = Alignment.Center) { + TalkiesGlyph(TalkiesGlyphKind.SERVER, terminalAccent, Modifier.size(26.dp)) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(host.name, style = MaterialTheme.typography.titleSmall, maxLines = 1) + Text( + "${host.username?.let { "$it@" } ?: ""}${host.host}:${host.port}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + Text("SSH SERVICE FOUND", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary) + } + Button(onClick = onConnect, shape = RoundedCornerShape(14.dp)) { Text("Connect") } + } + } +} + +@Composable +private fun SavedHostCard( + profile: SshHostProfile, + onConnect: () -> Unit, + onEdit: () -> Unit, + onRemove: () -> Unit, +) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + shape = RoundedCornerShape(20.dp), + ) { + Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box(Modifier.size(42.dp).background(Color(0xFF282448), RoundedCornerShape(14.dp)), contentAlignment = Alignment.Center) { + TalkiesGlyph(TalkiesGlyphKind.TERMINAL, MaterialTheme.colorScheme.secondary, Modifier.size(26.dp)) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(profile.name, style = MaterialTheme.typography.titleMedium) + Text("${profile.username}@${profile.host}:${profile.port}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + LinkStateBadge("SAVED", active = true) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Button(onClick = onConnect, shape = RoundedCornerShape(14.dp)) { Text("Connect securely") } + TextButton(onClick = onEdit) { Text("Edit") } + TextButton(onClick = onRemove) { Text("Remove") } + } + } + } +} + +@Composable +private fun DiscoveryRadar(scanning: Boolean, modifier: Modifier = Modifier) { + val transition = rememberInfiniteTransition(label = "discovery-radar") + val angle by transition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable(tween(2200, easing = LinearEasing), RepeatMode.Restart), + label = "radar-sweep", + ) + Canvas(modifier) { + val center = androidx.compose.ui.geometry.Offset(size.width / 2, size.height / 2) + val radius = size.minDimension * 0.44f + val lineColor = if (scanning) terminalAccent else Color(0xFF54778B) + listOf(0.38f, 0.68f, 1f).forEach { fraction -> + drawCircle(lineColor.copy(alpha = if (scanning) 0.34f else 0.2f), radius * fraction, center, style = Stroke(1.2.dp.toPx())) + } + drawLine(lineColor.copy(alpha = 0.28f), androidx.compose.ui.geometry.Offset(center.x - radius, center.y), androidx.compose.ui.geometry.Offset(center.x + radius, center.y), strokeWidth = 1.dp.toPx()) + drawLine(lineColor.copy(alpha = 0.28f), androidx.compose.ui.geometry.Offset(center.x, center.y - radius), androidx.compose.ui.geometry.Offset(center.x, center.y + radius), strokeWidth = 1.dp.toPx()) + rotate(angle, center) { + drawArc( + brush = Brush.sweepGradient(listOf(Color.Transparent, terminalAccent.copy(alpha = 0.6f), Color.Transparent), center), + startAngle = -35f, + sweepAngle = 70f, + useCenter = true, + topLeft = androidx.compose.ui.geometry.Offset(center.x - radius, center.y - radius), + size = androidx.compose.ui.geometry.Size(radius * 2, radius * 2), + ) + } + drawCircle(terminalAccent, radius = 3.dp.toPx(), center = center) + drawCircle(Color(0xFFB69CFF), radius = 2.dp.toPx(), center = androidx.compose.ui.geometry.Offset(center.x + radius * 0.4f, center.y - radius * 0.45f)) + } +} + private data class HostTrustPrompt(val message: String, val answer: CompletableFuture) diff --git a/mobile/android/app/src/main/java/com/talkies/android/SshHostDiscovery.kt b/mobile/android/app/src/main/java/com/talkies/android/SshHostDiscovery.kt new file mode 100644 index 00000000..9eacb4c5 --- /dev/null +++ b/mobile/android/app/src/main/java/com/talkies/android/SshHostDiscovery.kt @@ -0,0 +1,89 @@ +package com.talkies.android + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import java.util.concurrent.ConcurrentHashMap + +/** Finds SSH servers explicitly advertised on the local network via DNS-SD. */ +internal class SshHostDiscovery(context: Context) : AutoCloseable { + private val manager = context.applicationContext.getSystemService(Context.NSD_SERVICE) as NsdManager + private val resolved = ConcurrentHashMap() + @Volatile private var listener: NsdManager.DiscoveryListener? = null + @Volatile private var onUpdate: ((List) -> Unit)? = null + + @Suppress("DEPRECATION") // resolveService is supported back to the app's API 28 baseline. + fun start(onUpdate: (List) -> Unit) { + stop() + this.onUpdate = onUpdate + resolved.clear() + val next = object : NsdManager.DiscoveryListener { + override fun onDiscoveryStarted(serviceType: String) = Unit + override fun onServiceFound(serviceInfo: NsdServiceInfo) { + if (serviceInfo.serviceType.trimEnd('.') != SSH_SERVICE_TYPE.trimEnd('.')) return + manager.resolveService(serviceInfo, object : NsdManager.ResolveListener { + override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) = Unit + override fun onServiceResolved(info: NsdServiceInfo) { + val address = info.host?.hostAddress ?: return + val name = info.serviceName.trim().ifBlank { info.host.hostName ?: address } + val username = runCatching { + info.attributes["user"]?.toString(Charsets.UTF_8)?.trim()?.takeIf(String::isNotEmpty) + }.getOrNull() + val candidate = DiscoveredSshHost( + name = name, + host = address, + port = info.port, + username = username, + ) + resolved["${candidate.host}:${candidate.port}"] = candidate + this@SshHostDiscovery.onUpdate?.invoke(resolved.values.sortedBy { it.name.lowercase() }) + } + }) + } + override fun onServiceLost(serviceInfo: NsdServiceInfo) { + val key = resolved.entries.firstOrNull { it.value.name == serviceInfo.serviceName }?.key ?: return + resolved.remove(key) + this@SshHostDiscovery.onUpdate?.invoke(resolved.values.sortedBy { it.name.lowercase() }) + } + override fun onDiscoveryStopped(serviceType: String) = Unit + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { stop() } + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { stop() } + } + listener = next + runCatching { manager.discoverServices(SSH_SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, next) } + .onFailure { stop() } + } + + fun stop() { + val current = listener ?: return + listener = null + runCatching { manager.stopServiceDiscovery(current) } + onUpdate = null + } + + override fun close() = stop() + + companion object { + private const val SSH_SERVICE_TYPE = "_ssh._tcp." + } +} + +internal data class DiscoveredSshHost( + val name: String, + val host: String, + val port: Int, + val username: String? = null, +) { + fun toProfile(usernameFallback: String?): SshHostProfile? { + val user = username?.takeIf(String::isNotBlank) ?: usernameFallback?.takeIf(String::isNotBlank) ?: return null + return runCatching { + SshHostProfile( + id = java.util.UUID.randomUUID().toString(), + name = name.take(64).ifBlank { host }, + host = host, + port = port, + username = user, + ).validate() + }.getOrNull() + } +} diff --git a/mobile/android/app/src/main/java/com/talkies/android/TalkiesGlyph.kt b/mobile/android/app/src/main/java/com/talkies/android/TalkiesGlyph.kt new file mode 100644 index 00000000..52f1069e --- /dev/null +++ b/mobile/android/app/src/main/java/com/talkies/android/TalkiesGlyph.kt @@ -0,0 +1,55 @@ +package com.talkies.android + +import androidx.compose.foundation.Canvas +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp + +internal enum class TalkiesGlyphKind { SERVER, TERMINAL, KEYBOARD, MICROPHONE, HIDE } + +/** Small, dependency-free line icons drawn for the exact control they label. */ +@androidx.compose.runtime.Composable +internal fun TalkiesGlyph(kind: TalkiesGlyphKind, color: Color, modifier: Modifier = Modifier) { + Canvas(modifier) { + val stroke = size.minDimension * 0.075f + val w = size.width + val h = size.height + when (kind) { + TalkiesGlyphKind.SERVER -> { + drawRoundRect(color, Offset(w * .16f, h * .16f), Size(w * .68f, h * .28f), androidx.compose.ui.geometry.CornerRadius(stroke * 2), style = Stroke(stroke)) + drawRoundRect(color, Offset(w * .16f, h * .56f), Size(w * .68f, h * .28f), androidx.compose.ui.geometry.CornerRadius(stroke * 2), style = Stroke(stroke)) + drawCircle(color, stroke * 1.3f, Offset(w * .28f, h * .30f)) + drawCircle(color, stroke * 1.3f, Offset(w * .28f, h * .70f)) + drawLine(color.copy(alpha = .7f), Offset(w * .40f, h * .30f), Offset(w * .72f, h * .30f), stroke) + drawLine(color.copy(alpha = .7f), Offset(w * .40f, h * .70f), Offset(w * .72f, h * .70f), stroke) + } + TalkiesGlyphKind.TERMINAL -> { + drawRoundRect(color, Offset(w * .10f, h * .14f), Size(w * .80f, h * .72f), androidx.compose.ui.geometry.CornerRadius(stroke * 2), style = Stroke(stroke)) + drawLine(color, Offset(w * .25f, h * .40f), Offset(w * .40f, h * .50f), stroke, cap = StrokeCap.Round) + drawLine(color, Offset(w * .40f, h * .50f), Offset(w * .25f, h * .60f), stroke, cap = StrokeCap.Round) + drawLine(color, Offset(w * .49f, h * .61f), Offset(w * .67f, h * .61f), stroke, cap = StrokeCap.Round) + } + TalkiesGlyphKind.KEYBOARD -> { + drawRoundRect(color, Offset(w * .08f, h * .22f), Size(w * .84f, h * .56f), androidx.compose.ui.geometry.CornerRadius(stroke * 2), style = Stroke(stroke)) + for (row in 0..1) for (column in 0..4) { + drawRoundRect(color.copy(alpha = .88f), Offset(w * (.18f + column * .14f), h * (.34f + row * .18f)), Size(w * .07f, h * .08f), androidx.compose.ui.geometry.CornerRadius(stroke), style = Stroke(stroke * .8f)) + } + drawLine(color, Offset(w * .34f, h * .67f), Offset(w * .66f, h * .67f), stroke, cap = StrokeCap.Round) + } + TalkiesGlyphKind.MICROPHONE -> { + drawRoundRect(color, Offset(w * .37f, h * .12f), Size(w * .26f, h * .48f), androidx.compose.ui.geometry.CornerRadius(w * .14f)) + drawArc(color, 0f, 180f, false, Offset(w * .22f, h * .31f), Size(w * .56f, h * .48f), style = Stroke(stroke, cap = StrokeCap.Round)) + drawLine(color, Offset(w * .50f, h * .79f), Offset(w * .50f, h * .91f), stroke, cap = StrokeCap.Round) + drawLine(color, Offset(w * .36f, h * .91f), Offset(w * .64f, h * .91f), stroke, cap = StrokeCap.Round) + } + TalkiesGlyphKind.HIDE -> { + drawLine(color, Offset(w * .22f, h * .36f), Offset(w * .50f, h * .64f), stroke, cap = StrokeCap.Round) + drawLine(color, Offset(w * .50f, h * .64f), Offset(w * .78f, h * .36f), stroke, cap = StrokeCap.Round) + } + } + } +} diff --git a/mobile/android/app/src/main/java/com/talkies/android/TalkiesInputMethodService.kt b/mobile/android/app/src/main/java/com/talkies/android/TalkiesInputMethodService.kt index 4506a39d..a300c69c 100644 --- a/mobile/android/app/src/main/java/com/talkies/android/TalkiesInputMethodService.kt +++ b/mobile/android/app/src/main/java/com/talkies/android/TalkiesInputMethodService.kt @@ -6,9 +6,26 @@ import android.content.pm.PackageManager import android.inputmethodservice.InputMethodService import android.view.View import android.view.inputmethod.EditorInfo -import android.widget.Button -import android.widget.LinearLayout -import android.widget.TextView +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -25,15 +42,14 @@ class TalkiesInputMethodService : InputMethodService() { private lateinit var speechModels: LocalSpeechModels private lateinit var cleanupModelStore: S1MiniModelStore private lateinit var cleanupCleaner: TranscriptCleaner - private var recording = false - private var processing = false + private var recording by mutableStateOf(false) + private var processing by mutableStateOf(false) private var inputSession = 0 private var inputViewGeneration = 0 private var inputViewActive = false private var historyAllowedForField = false private var destination = DictationDestination.GENERAL - private var statusView: TextView? = null - private var recordButton: Button? = null + private var keyboardStatus by mutableStateOf("Ready for private, on-device dictation") override fun onCreate() { super.onCreate() @@ -45,20 +61,41 @@ class TalkiesInputMethodService : InputMethodService() { } override fun onCreateInputView(): View { - val root = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - setPadding(16, 12, 16, 12) - } - statusView = TextView(this).apply { - text = "Talkies offline voice typing" - gravity = android.view.Gravity.CENTER_VERTICAL - } - recordButton = Button(this).apply { - text = "Record" - setOnClickListener { onRecordButtonPressed() } + val root = ComposeView(this).apply { + setContent { + TalkiesTheme { + Surface(color = MaterialTheme.colorScheme.surface) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("TALKIES · PRIVATE VOICE", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary) + Text( + keyboardStatus, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Button( + enabled = !processing, + onClick = { onRecordButtonPressed() }, + ) { + Text(if (recording) "■ Stop" else "● Speak") + } + IconButton( + onClick = { requestHideSelf(0) }, + modifier = Modifier.size(44.dp).semantics { contentDescription = "Hide Talkies keyboard" }, + ) { + TalkiesGlyph(TalkiesGlyphKind.HIDE, MaterialTheme.colorScheme.secondary, Modifier.size(24.dp)) + } + } + } + } + } } - root.addView(statusView, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) - root.addView(recordButton) updateControls("Ready") return root } @@ -142,7 +179,6 @@ class TalkiesInputMethodService : InputMethodService() { updateControls(error.message ?: "Could not start microphone") } finally { processing = false - recordButton?.isEnabled = true } } } @@ -216,18 +252,12 @@ class TalkiesInputMethodService : InputMethodService() { } finally { samples?.fill(0f) processing = false - recordButton?.text = "Record" - recordButton?.isEnabled = true } } } private fun updateControls(message: String) { - statusView?.text = message - recordButton?.apply { - isEnabled = !processing - text = if (recording) "Stop" else "Record" - } + keyboardStatus = message } private fun readyStatus(): String = when (destination) { diff --git a/mobile/android/app/src/main/java/com/talkies/android/TalkiesTheme.kt b/mobile/android/app/src/main/java/com/talkies/android/TalkiesTheme.kt new file mode 100644 index 00000000..71a986ca --- /dev/null +++ b/mobile/android/app/src/main/java/com/talkies/android/TalkiesTheme.kt @@ -0,0 +1,31 @@ +package com.talkies.android + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +/** Material 3 palette that keeps Talkies' original neon field-console identity. */ +private val TalkiesColors = darkColorScheme( + primary = Color(0xFF6FFFE9), + onPrimary = Color(0xFF061713), + primaryContainer = Color(0xFF123B38), + onPrimaryContainer = Color(0xFFB9FFF4), + secondary = Color(0xFFB69CFF), + onSecondary = Color(0xFF21163E), + secondaryContainer = Color(0xFF35265A), + onSecondaryContainer = Color(0xFFE1D6FF), + tertiary = Color(0xFFFFB86B), + background = Color(0xFF070B14), + surface = Color(0xFF101827), + surfaceVariant = Color(0xFF19243A), + onBackground = Color(0xFFD9E7FF), + onSurface = Color(0xFFD9E7FF), + onSurfaceVariant = Color(0xFFA9B9D4), + error = Color(0xFFFF718B), +) + +@Composable +internal fun TalkiesTheme(content: @Composable () -> Unit) { + MaterialTheme(colorScheme = TalkiesColors, content = content) +} diff --git a/mobile/android/app/src/test/java/com/talkies/android/SshDiscoveryTest.kt b/mobile/android/app/src/test/java/com/talkies/android/SshDiscoveryTest.kt new file mode 100644 index 00000000..b2488287 --- /dev/null +++ b/mobile/android/app/src/test/java/com/talkies/android/SshDiscoveryTest.kt @@ -0,0 +1,30 @@ +package com.talkies.android + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SshDiscoveryTest { + @Test + fun createsProfileFromDiscoveredHostAndAdvertisedUsername() { + val profile = DiscoveredSshHost("Studio", "192.168.1.8", 2222, "fource") + .toProfile(usernameFallback = null) + requireNotNull(profile) + assertEquals("Studio", profile.name) + assertEquals("192.168.1.8", profile.host) + assertEquals(2222, profile.port) + assertEquals("fource", profile.username) + } + + @Test + fun reusesUsernameFromSavedProfileWhenHostDoesNotAdvertiseOne() { + val profile = DiscoveredSshHost("Build box", "build.local", 22) + .toProfile(usernameFallback = "dev") + assertEquals("dev", requireNotNull(profile).username) + } + + @Test + fun requiresUsernameOnlyWhenNeitherDiscoveryNorSavedHostsKnowIt() { + assertNull(DiscoveredSshHost("Build box", "build.local", 22).toProfile(null)) + } +}