Skip to content
Merged
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
10 changes: 10 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ ADRs: [project_plans/stelekit/decisions/](project_plans/stelekit/decisions/)

---

## Mobile Voice Mode (Branch: stelekit-mobile-mode)

Voice capture pipeline: mic tap → AudioRecord + MediaCodec → Whisper STT → LLM formatter → daily journal insert. Full plan: [docs/tasks/mobile-voice-mode.md](docs/tasks/mobile-voice-mode.md)

- [x] **[VOICE-S1] Story 1 — Core pipeline (commonMain + Android, raw transcript)** — PR #2 open, CI passing. All T1.1–T1.7 complete: interfaces, ViewModel, WhisperSTT, AndroidAudioRecorder, App.kt wiring, VoiceCaptureButton. 13 ViewModel tests, MockEngine Whisper tests.
- [ ] **[VOICE-S2] Story 2 — LLM formatting + settings** — Next. Adds ClaudeLlmFormatterProvider, OpenAiLlmFormatterProvider, VoiceSettings (EncryptedSharedPreferences), Settings UI for API keys, ViewModel LLM integration. Start with T2.1 (ClaudeLlmFormatterProvider, 2h). See [docs/tasks/mobile-voice-mode.md#story-2](docs/tasks/mobile-voice-mode.md).
- [ ] **[VOICE-S3] Story 3 — iOS adapter + waveform feedback** — Blocked on Story 1 merge. IosAudioRecorder, IosSpeechToTextProvider (SFSpeechRecognizer), amplitude-reactive pulse.

---

## Active Remediation (Post-Review March 2026)

### P0: STABILITY & COMPATIBILITY
Expand Down
3 changes: 2 additions & 1 deletion androidApp/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- SAF (Storage Access Framework) grants are given at runtime via ACTION_OPEN_DOCUMENT_TREE
and stored as persistable URI permissions — no manifest storage permissions needed. -->

<application
android:allowBackup="true"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
Expand Down
15 changes: 14 additions & 1 deletion androidApp/src/main/kotlin/dev/stapler/stelekit/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,19 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dev.stapler.stelekit.db.DriverFactory
import dev.stapler.stelekit.domain.UrlFetcherAndroid
import dev.stapler.stelekit.platform.SteleKitContext
import dev.stapler.stelekit.platform.PlatformFileSystem
import dev.stapler.stelekit.ui.StelekitApp
import dev.stapler.stelekit.voice.AndroidAudioRecorder
import dev.stapler.stelekit.voice.VoiceSettings
import dev.stapler.stelekit.voice.buildVoicePipeline
import dev.stapler.stelekit.platform.PlatformSettings
import kotlinx.coroutines.CompletableDeferred

class MainActivity : ComponentActivity() {
Expand Down Expand Up @@ -99,10 +106,16 @@ class MainActivity : ComponentActivity() {
}
}
}
val audioRecorder = remember { AndroidAudioRecorder(this@MainActivity.applicationContext) }
val voiceSettings = remember { VoiceSettings(PlatformSettings()) }
var voicePipeline by remember { mutableStateOf(buildVoicePipeline(audioRecorder, voiceSettings)) }
StelekitApp(
fileSystem = fileSystem,
graphPath = fileSystem.getDefaultGraphPath(),
urlFetcher = UrlFetcherAndroid()
urlFetcher = UrlFetcherAndroid(),
voicePipeline = voicePipeline,
voiceSettings = voiceSettings,
onRebuildVoicePipeline = { voicePipeline = buildVoicePipeline(audioRecorder, voiceSettings) },
)
}
}
Expand Down
617 changes: 617 additions & 0 deletions docs/tasks/mobile-voice-mode.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions kmp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@ kotlin {

// JankStats — zero-allocation frame jank classification
implementation("androidx.metrics:metrics-performance:1.0.0-beta02")

// Encrypted SharedPreferences for API key storage
implementation("androidx.security:security-crypto:1.1.0-alpha06")
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package dev.stapler.stelekit.platform

import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey

object SteleKitContext {
private var _context: Context? = null
Expand All @@ -16,10 +19,20 @@ object SteleKitContext {
actual class PlatformSettings actual constructor() {
private val prefs: SharedPreferences by lazy {
try {
SteleKitContext.context.getSharedPreferences("stelekit_prefs", Context.MODE_PRIVATE)
val masterKey = MasterKey.Builder(SteleKitContext.context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
SteleKitContext.context,
"stelekit_secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
} catch (e: Exception) {
// Fallback for tests or if not initialized
throw IllegalStateException("Context not initialized", e)
// Fallback to plain prefs if keystore fails (e.g., corrupted keystore after device wipe)
Log.w("PlatformSettings", "EncryptedSharedPreferences unavailable, falling back to plain prefs", e)
SteleKitContext.context.getSharedPreferences("stelekit_prefs", Context.MODE_PRIVATE)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package dev.stapler.stelekit.ui

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.offset
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.AutoStories
Expand All @@ -12,8 +15,11 @@ import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import dev.stapler.stelekit.ui.LocalWindowSizeClass
import dev.stapler.stelekit.ui.isMobile

Expand All @@ -40,29 +46,62 @@ actual fun PlatformBottomBar(
currentScreen: Screen,
onNavigate: (Screen) -> Unit,
onSearch: () -> Unit,
isLeftHanded: Boolean
isLeftHanded: Boolean,
voiceCaptureButton: @Composable () -> Unit,
) {
if (!LocalWindowSizeClass.current.isMobile) return
// Hide the nav bar when the keyboard is open — the editing toolbar takes its place,
// and keeping the nav bar visible creates a gap between toolbar and keyboard.
val imeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0
if (imeVisible) return
val items = if (isLeftHanded) BottomNavItem.entries.reversed() else BottomNavItem.entries
NavigationBar {
items.forEach { item ->
NavigationBarItem(
selected = item.matchesScreen(currentScreen),
onClick = {
when (item) {
BottomNavItem.SEARCH -> onSearch()
BottomNavItem.JOURNALS -> onNavigate(Screen.Journals)
BottomNavItem.ALL_PAGES -> onNavigate(Screen.AllPages)
BottomNavItem.NOTIFICATIONS -> onNavigate(Screen.Notifications)
}
},
icon = { Icon(item.icon, contentDescription = null) },
label = { Text(item.label) }
)
// Split nav items 2 left + 2 right; center gap reserved for the FAB.
val leftItems = items.take(2)
val rightItems = items.drop(2)

Box {
NavigationBar {
leftItems.forEach { item ->
NavigationBarItem(
selected = item.matchesScreen(currentScreen),
onClick = {
when (item) {
BottomNavItem.SEARCH -> onSearch()
BottomNavItem.JOURNALS -> onNavigate(Screen.Journals)
BottomNavItem.ALL_PAGES -> onNavigate(Screen.AllPages)
BottomNavItem.NOTIFICATIONS -> onNavigate(Screen.Notifications)
}
},
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
)
}
// Center gap — same weight as one NavigationBarItem — gives the FAB clear space.
Spacer(modifier = Modifier.weight(1f))
rightItems.forEach { item ->
NavigationBarItem(
selected = item.matchesScreen(currentScreen),
onClick = {
when (item) {
BottomNavItem.SEARCH -> onSearch()
BottomNavItem.JOURNALS -> onNavigate(Screen.Journals)
BottomNavItem.ALL_PAGES -> onNavigate(Screen.AllPages)
BottomNavItem.NOTIFICATIONS -> onNavigate(Screen.Notifications)
}
},
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) },
)
}
}
// FAB centered in the gap; offset upward by half the standard FAB height (56dp / 2)
// so the FAB sits on top of the nav bar edge without obscuring any nav items.
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = (-28).dp),
) {
voiceCaptureButton()
}
}
}
Loading
Loading