diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 52150ae..e9a318d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,6 +8,9 @@
+
+
+
Unit = {},
onDelete: ((String) -> Unit)? = null,
onRegenerate: ((String) -> Unit)? = null,
+ onReadAloud: ((String) -> Unit)? = null,
+ isPlayingTts: Boolean = false,
) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
@@ -461,6 +463,14 @@ fun MessageRow(
copied = false
}
}
+ onReadAloud?.let { readAloud ->
+ MessageActionButton(
+ icon = KaizenIcons.Volume2,
+ tint = if (isPlayingTts) accent.primary else cs.onSurfaceVariant.copy(alpha = 0.35f),
+ ) {
+ haptics.tick(); readAloud(message.content)
+ }
+ }
onRegenerate?.let { regen ->
MessageActionButton(KaizenIcons.RefreshCw, cs.onSurfaceVariant.copy(alpha = 0.35f)) {
haptics.tick(); regen(message.wireId)
@@ -527,6 +537,8 @@ fun ChatInput(
enabled: Boolean,
isStreaming: Boolean = false,
onStop: () -> Unit = {},
+ isRecording: Boolean = false,
+ onMicClick: () -> Unit = {},
pendingFiles: List = emptyList(),
onAddClick: () -> Unit = {},
onRemoveFile: (String) -> Unit = {},
@@ -584,10 +596,16 @@ fun ChatInput(
}
Spacer(Modifier.width(8.dp))
Box(
- Modifier.size(38.dp).clip(KaizenShapes.circle).background(iconBg),
+ Modifier.size(38.dp).clip(KaizenShapes.circle)
+ .background(if (isRecording) Color(0xFFEF4444).copy(alpha = if (isDark) 0.85f else 0.80f) else iconBg)
+ .clickable(onClick = onMicClick),
contentAlignment = Alignment.Center,
) {
- Icon(KaizenIcons.Mic, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(19.dp))
+ if (isRecording) {
+ Icon(KaizenIcons.Square, stringResource(R.string.chat_recording), tint = Color.White, modifier = Modifier.size(14.dp))
+ } else {
+ Icon(KaizenIcons.Mic, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(19.dp))
+ }
}
Spacer(Modifier.weight(1f))
diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt
index 3506414..b8eab89 100644
--- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt
+++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt
@@ -82,10 +82,17 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.LifecycleResumeEffect
+import android.Manifest
+import android.content.pm.PackageManager
+import android.media.MediaPlayer
+import android.media.MediaRecorder
+import android.widget.Toast
+import androidx.core.content.ContextCompat
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import java.io.ByteArrayOutputStream
+import java.io.File
// Screen enumeration for modular state-driven navigation (Zero Technical Debt)
enum class AppScreen { Chat, Settings }
@@ -111,6 +118,15 @@ fun ChatScreen(
var chatModel by remember { mutableStateOf(null) }
var streamJob by remember { mutableStateOf(null) }
+ // Audio recording state
+ var isRecording by remember { mutableStateOf(false) }
+ val recorderRef = remember { mutableStateOf(null) }
+ val audioFileRef = remember { mutableStateOf(null) }
+
+ // TTS playback state
+ var playingTtsForId by remember { mutableStateOf(null) }
+ val ttsPlayerRef = remember { mutableStateOf(null) }
+
// Navigation State
var currentScreen by remember { mutableStateOf(AppScreen.Chat) }
@@ -191,6 +207,67 @@ fun ChatScreen(
uploadPickedFile("photo_${System.currentTimeMillis()}.jpg", "image/jpeg", stream.toByteArray())
}
+ val micPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ if (granted) startRecording(context, recorderRef, audioFileRef) { isRecording = true }
+ else Toast.makeText(context, context.getString(R.string.chat_mic_permission), Toast.LENGTH_SHORT).show()
+ }
+
+ fun toggleRecording() {
+ if (isRecording) {
+ stopRecording(recorderRef, audioFileRef) { name, mime, bytes ->
+ isRecording = false
+ uploadPickedFile(name, mime, bytes)
+ }
+ } else {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
+ startRecording(context, recorderRef, audioFileRef) { isRecording = true }
+ } else {
+ micPermission.launch(Manifest.permission.RECORD_AUDIO)
+ }
+ }
+ }
+
+ fun readAloud(content: String, messageId: Long) {
+ val cfg = session.config ?: return
+ if (playingTtsForId == messageId) {
+ ttsPlayerRef.value?.release()
+ ttsPlayerRef.value = null
+ playingTtsForId = null
+ return
+ }
+ ttsPlayerRef.value?.release()
+ playingTtsForId = messageId
+ scope.launch {
+ val url = KaizenApi.generateSpeech(cfg.baseUrl, cfg.token, content)
+ if (url == null) {
+ playingTtsForId = null
+ Toast.makeText(context, context.getString(R.string.chat_tts_error), Toast.LENGTH_SHORT).show()
+ return@launch
+ }
+ try {
+ val player = MediaPlayer().apply {
+ setDataSource(url)
+ prepareAsync()
+ setOnPreparedListener { start() }
+ setOnCompletionListener {
+ release()
+ ttsPlayerRef.value = null
+ playingTtsForId = null
+ }
+ setOnErrorListener { _, _, _ ->
+ release()
+ ttsPlayerRef.value = null
+ playingTtsForId = null
+ true
+ }
+ }
+ ttsPlayerRef.value = player
+ } catch (_: Exception) {
+ playingTtsForId = null
+ }
+ }
+ }
+
LaunchedEffect(session.config?.baseUrl, session.config?.token) {
val cfg = session.config ?: return@LaunchedEffect
loadError = null
@@ -659,6 +736,10 @@ fun ChatScreen(
send(userContent)
}
} else null,
+ onReadAloud = if (message.role == Role.Assistant && !message.streaming && message.content.isNotBlank()) { content ->
+ readAloud(content, message.id)
+ } else null,
+ isPlayingTts = playingTtsForId == message.id,
)
}
}
@@ -858,6 +939,8 @@ fun ChatScreen(
enabled = !isStreaming,
isStreaming = isStreaming,
onStop = { streamJob?.cancel() },
+ isRecording = isRecording,
+ onMicClick = { toggleRecording() },
pendingFiles = pendingFiles,
onAddClick = { showAttachMenu = !showAttachMenu },
onRemoveFile = { id -> pendingFiles.removeAll { it.id == id } },
@@ -950,3 +1033,53 @@ private fun chatErrorText(e: Throwable, ctx: android.content.Context): String =
e is ChatHttpException -> ctx.getString(R.string.error_server, e.code)
else -> ctx.getString(R.string.error_no_connection)
}
+
+@Suppress("DEPRECATION")
+private fun startRecording(
+ context: android.content.Context,
+ recorderRef: androidx.compose.runtime.MutableState,
+ audioFileRef: androidx.compose.runtime.MutableState,
+ onStarted: () -> Unit,
+) {
+ try {
+ val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.m4a")
+ val recorder = MediaRecorder(context).apply {
+ setAudioSource(MediaRecorder.AudioSource.MIC)
+ setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
+ setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
+ setAudioSamplingRate(44100)
+ setAudioEncodingBitRate(128000)
+ setOutputFile(file.absolutePath)
+ prepare()
+ start()
+ }
+ recorderRef.value = recorder
+ audioFileRef.value = file
+ onStarted()
+ } catch (_: Exception) {
+ recorderRef.value = null
+ audioFileRef.value = null
+ }
+}
+
+private fun stopRecording(
+ recorderRef: androidx.compose.runtime.MutableState,
+ audioFileRef: androidx.compose.runtime.MutableState,
+ onRecorded: (String, String, ByteArray) -> Unit,
+) {
+ val recorder = recorderRef.value ?: return
+ val file = audioFileRef.value ?: return
+ try {
+ recorder.stop()
+ recorder.release()
+ } catch (_: Exception) {
+ recorder.release()
+ }
+ recorderRef.value = null
+ audioFileRef.value = null
+ if (file.exists() && file.length() > 0) {
+ val bytes = file.readBytes()
+ file.delete()
+ onRecorded("voice_${System.currentTimeMillis()}.m4a", "audio/mp4", bytes)
+ }
+}
diff --git a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt
index 92255e9..587c3f0 100644
--- a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt
+++ b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt
@@ -572,6 +572,35 @@ object KaizenApi {
}
}
+ @Serializable private data class SpeechRequest(val prompt: String, val kind: String = "speech", val voice: String? = null)
+ @Serializable private data class SpeechResponse(val url: String = "", val cost: Double? = null)
+
+ /** POST /api/v1/audio-gen — generate speech from text via Gemini-TTS. Returns the audio URL or null. */
+ suspend fun generateSpeech(baseUrl: String, token: String, text: String, voice: String? = null): String? =
+ withContext(Dispatchers.IO) {
+ val body = json.encodeToString(SpeechRequest(
+ prompt = text.take(2000),
+ voice = voice,
+ )).toRequestBody(jsonMedia)
+ val req = Request.Builder()
+ .url("$baseUrl/api/v1/audio-gen")
+ .post(body)
+ .header("Authorization", "Bearer $token")
+ .build()
+ try {
+ client.newCall(req).execute().use { resp ->
+ if (!resp.isSuccessful) {
+ Log.w(TAG, "generateSpeech: HTTP ${resp.code}")
+ return@use null
+ }
+ json.decodeFromString(resp.body!!.string()).url.takeIf { it.isNotBlank() }
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "generateSpeech: ${e.javaClass.simpleName}: ${e.message}")
+ null
+ }
+ }
+
suspend fun prewarm(baseUrl: String) = withContext(Dispatchers.IO) {
try {
val req = Request.Builder().url("$baseUrl/api/v1/meta").build()
diff --git a/app/src/main/java/dev/kaizen/app/ui/icon/KaizenIcons.kt b/app/src/main/java/dev/kaizen/app/ui/icon/KaizenIcons.kt
index bff6107..107ee9b 100644
--- a/app/src/main/java/dev/kaizen/app/ui/icon/KaizenIcons.kt
+++ b/app/src/main/java/dev/kaizen/app/ui/icon/KaizenIcons.kt
@@ -91,6 +91,9 @@ object KaizenIcons {
val ArrowDown: ImageVector get() = Lucide.ArrowDown
val ArrowUp: ImageVector get() = Lucide.ArrowUp
+ // ── Audio ──────────────────────────────────────────────────────────────
+ val Volume2: ImageVector get() = Lucide.Volume2
+
// ── Custom (brand overrides) ────────────────────────────────────────────
// Add hand-drawn SVGs here as ImageVector literals when ready.
// Example:
diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml
index fbcf14d..7049add 100644
--- a/app/src/main/res/values-en/strings.xml
+++ b/app/src/main/res/values-en/strings.xml
@@ -19,6 +19,11 @@
Error
Open menu
Stop response
+ Recording …
+ Microphone access required
+ Read aloud
+ Read aloud failed
+ Read aloud not available (Vertex required)
Take Photo
Photo & Video
Choose File
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index cfb8fbf..5d682c5 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -21,6 +21,11 @@
Fehler
Menü öffnen
Antwort stoppen
+ Aufnahme läuft …
+ Mikrofonzugriff wird benötigt
+ Vorlesen
+ Vorlesen fehlgeschlagen
+ Vorlesen nicht verfügbar (Vertex benötigt)
Foto aufnehmen
Foto & Video
Datei auswählen