Security hardening + performance optimization pass

Security:
- Fix JSON injection in deleteMessage (raw string → serialized DTO)
- Separate OkHttpClient for streaming (30s timeout on normal requests,
  infinite only for chat stream)
- Disable backup (EncryptedSharedPrefs without KeyStore → crash on restore)
- Block cleartext traffic explicitly
- Strip server URLs from log output

Performance:
- Share OkHttp connection pool across KaizenApi + LiveClient
- Eliminate duplicate fetchMe/fetchDefaultModel call on login
- Parallelize prefetchMissing (was sequential per conversation)
- Stream file uploads directly from ContentResolver (no full readBytes)
- Cache assistant message index during streaming (O(1) vs O(n) per chunk)
- Build chat history before adding assistant placeholder (no filter needed)

Bugs:
- Fix send() race condition (isStreaming set before async work)
- Move voice recording file read to IO dispatcher
This commit is contained in:
Bruno Deanoz 2026-06-24 09:38:54 +02:00
parent 392e582084
commit 3b73e15376
7 changed files with 139 additions and 84 deletions

View file

@ -16,7 +16,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<application
android:allowBackup="true"
android:allowBackup="false"
android:usesCleartextTraffic="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"

View file

@ -774,7 +774,7 @@ data class PendingFile(
val id: String = java.util.UUID.randomUUID().toString(),
val name: String,
val mimeType: String,
val bytes: ByteArray,
val bytes: ByteArray = ByteArray(0),
val uploaded: Attachment? = null,
val error: String? = null,
val uploading: Boolean = true,

View file

@ -375,24 +375,43 @@ fun ChatScreen(
}
}
val galleryPicker = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris ->
fun uploadPickedUri(uri: android.net.Uri) {
val cfg = session.config ?: return
val cr = context.contentResolver
for (uri in uris) {
val mimeType = cr.getType(uri) ?: "image/jpeg"
val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "image_${System.currentTimeMillis()}"
val bytes = cr.openInputStream(uri)?.use { it.readBytes() } ?: continue
uploadPickedFile(fileName, mimeType, bytes)
val mimeType = cr.getType(uri) ?: "application/octet-stream"
val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "file_${System.currentTimeMillis()}"
val size = try { cr.openFileDescriptor(uri, "r")?.use { it.statSize } ?: -1L } catch (_: Exception) { -1L }
val pf = PendingFile(name = fileName, mimeType = mimeType)
pendingFiles.add(pf)
scope.launch {
val inputStream = cr.openInputStream(uri)
if (inputStream == null) {
val idx = pendingFiles.indexOfFirst { it.id == pf.id }
if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(error = "Cannot read file", uploading = false)
return@launch
}
val r = inputStream.use { stream ->
KaizenApi.uploadStream(cfg.baseUrl, cfg.token, fileName, mimeType, stream, size)
}
when (r) {
is FetchResult.Ok -> {
val idx = pendingFiles.indexOfFirst { it.id == pf.id }
if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(uploaded = r.data, uploading = false)
}
is FetchResult.Fail -> {
val idx = pendingFiles.indexOfFirst { it.id == pf.id }
if (idx >= 0) pendingFiles[idx] = pendingFiles[idx].copy(error = r.reason, uploading = false)
}
}
}
}
val galleryPicker = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris ->
for (uri in uris) uploadPickedUri(uri)
}
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
val cr = context.contentResolver
for (uri in uris) {
val mimeType = cr.getType(uri) ?: "application/octet-stream"
val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "file"
val bytes = cr.openInputStream(uri)?.use { it.readBytes() } ?: continue
uploadPickedFile(fileName, mimeType, bytes)
}
for (uri in uris) uploadPickedUri(uri)
}
val cameraPicker = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap ->
@ -409,10 +428,13 @@ fun ChatScreen(
fun toggleRecording() {
if (isRecording) {
stopRecording(recorderRef, audioFileRef) { name, mime, bytes ->
stopRecording(recorderRef, audioFileRef) { name, mime, file ->
isRecording = false
val cfg = session.config ?: return@stopRecording
scope.launch {
val bytes = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
file.readBytes().also { file.delete() }
}
when (val r = KaizenApi.uploadFile(cfg.baseUrl, cfg.token, name, mime, bytes)) {
is FetchResult.Ok -> {
val att = r.data
@ -421,14 +443,12 @@ fun ChatScreen(
messages.add(userMsg)
val assistantId = nextId++
val assistantWireId = java.util.UUID.randomUUID().toString()
val history = messages.map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) }
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId))
val voiceAssistantIdx = messages.lastIndex
isStreaming = true
haptics.click()
val history = messages
.filter { it.id != assistantId }
.map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) }
val convIdDeferred = if (conversationId == null) {
scope.async { KaizenApi.createConversation(cfg.baseUrl, cfg.token) }
} else null
@ -441,13 +461,12 @@ fun ChatScreen(
cfg.baseUrl, cfg.token, cfg.model, history,
attachments = listOf(att),
).collect { state ->
val idx = messages.indexOfLast { it.id == assistantId }
if (idx < 0) return@collect
if (voiceAssistantIdx >= messages.size) return@collect
if (!sawContent && state.content.isNotEmpty()) {
sawContent = true; haptics.responseStart()
}
messages[idx] = messages[idx].copy(
thinking = if (sawContent) false else messages[idx].thinking,
messages[voiceAssistantIdx] = messages[voiceAssistantIdx].copy(
thinking = if (sawContent) false else messages[voiceAssistantIdx].thinking,
content = state.content,
reasoning = state.reasoning,
tools = state.tools,
@ -458,14 +477,12 @@ fun ChatScreen(
}
} catch (_: kotlinx.coroutines.CancellationException) {
} catch (_: Exception) {}
val assistantIdx = messages.indexOfLast { it.id == assistantId }
if (assistantIdx >= 0) messages[assistantIdx] = messages[assistantIdx].copy(streaming = false, thinking = false)
if (voiceAssistantIdx < messages.size) messages[voiceAssistantIdx] = messages[voiceAssistantIdx].copy(streaming = false, thinking = false)
isStreaming = false
haptics.responseEnd()
// Persist
val cid = convIdDeferred?.await() ?: conversationId ?: return@launch
if (conversationId == null) conversationId = cid
val assistantMsg = messages.lastOrNull { it.id == assistantId } ?: return@launch
val assistantMsg = if (voiceAssistantIdx < messages.size) messages[voiceAssistantIdx] else return@launch
val finalContent = assistantMsg.content
val finalReasoning = assistantMsg.reasoning.takeIf { it.isNotBlank() }
val finalSources = assistantMsg.sources.takeIf { it.isNotEmpty() }
@ -765,7 +782,8 @@ fun ChatScreen(
val uploadedAtts = if (branchAttachments.isNotEmpty()) branchAttachments else pendingFiles.mapNotNull { it.uploaded }
if (trimmed.isEmpty() && uploadedAtts.isEmpty()) return
if (isStreaming) return
val cfg = session.config ?: return
isStreaming = true
val cfg = session.config ?: run { isStreaming = false; return }
haptics.click()
val userParentId = editParentId ?: branchParentId ?: messages.lastOrNull()?.wireId
@ -776,14 +794,11 @@ fun ChatScreen(
if (branchAttachments.isEmpty()) pendingFiles.clear()
val assistantId = nextId++
val assistantWireId = java.util.UUID.randomUUID().toString()
val history = messages.map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) }
messages.add(Message(assistantId, Role.Assistant, "", streaming = true, thinking = true, wireId = assistantWireId))
isStreaming = true
val assistantIdx = messages.lastIndex
val wasNewConversation = conversationId == null
val history = messages
.filter { it.id != assistantId }
.map { WireMessage(if (it.role == Role.User) "user" else "assistant", it.content) }
streamJob = scope.launch {
haptics.thinkingStart()
var sawContent = false
@ -803,14 +818,13 @@ fun ChatScreen(
topP = samplingPrefs.topP.takeIf { it.enabled }?.value,
topK = samplingPrefs.topK.takeIf { it.enabled }?.value?.toInt(),
).collect { state ->
val idx = messages.indexOfLast { it.id == assistantId }
if (idx < 0) return@collect
if (assistantIdx >= messages.size) return@collect
if (!sawContent && state.content.isNotEmpty()) {
sawContent = true
haptics.responseStart()
}
messages[idx] = messages[idx].copy(
thinking = if (sawContent) false else messages[idx].thinking,
messages[assistantIdx] = messages[assistantIdx].copy(
thinking = if (sawContent) false else messages[assistantIdx].thinking,
content = state.content,
reasoning = state.reasoning,
tools = state.tools,
@ -821,12 +835,11 @@ fun ChatScreen(
usedTokens = state.inputTokens + state.outputTokens
}
}
val idx = messages.indexOfLast { it.id == assistantId }
val finalMsg = if (idx >= 0) messages[idx] else null
val finalMsg = if (assistantIdx < messages.size) messages[assistantIdx] else null
val finalContent = finalMsg?.content ?: ""
val finalReasoning = finalMsg?.reasoning?.takeIf { it.isNotBlank() }
val finalSources = finalMsg?.sources?.takeIf { it.isNotEmpty() }
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
if (assistantIdx < messages.size) messages[assistantIdx] = messages[assistantIdx].copy(streaming = false, thinking = false)
val convId = convIdDeferred?.await()?.also { cid ->
conversationId = cid
@ -897,16 +910,16 @@ fun ChatScreen(
}
}
} catch (e: Exception) {
val idx = messages.indexOfLast { it.id == assistantId }
val valid = assistantIdx < messages.size
if (e is kotlinx.coroutines.CancellationException) {
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
if (valid) messages[assistantIdx] = messages[assistantIdx].copy(streaming = false, thinking = false)
} else if (e is ChatHttpException && e.code == 401) {
if (idx >= 0) messages[idx] = messages[idx].copy(streaming = false, thinking = false)
if (valid) messages[assistantIdx] = messages[assistantIdx].copy(streaming = false, thinking = false)
session.logout()
} else if (idx >= 0) {
val existing = messages[idx].content
} else if (valid) {
val existing = messages[assistantIdx].content
val errorSuffix = "\n\n${chatErrorText(e, context)}"
messages[idx] = messages[idx].copy(
messages[assistantIdx] = messages[assistantIdx].copy(
streaming = false,
thinking = false,
content = if (existing.isNotBlank()) existing + errorSuffix else chatErrorText(e, context),
@ -1517,7 +1530,7 @@ private fun startRecording(
private fun stopRecording(
recorderRef: androidx.compose.runtime.MutableState<MediaRecorder?>,
audioFileRef: androidx.compose.runtime.MutableState<File?>,
onRecorded: (String, String, ByteArray) -> Unit,
onRecorded: (String, String, File) -> Unit,
) {
val recorder = recorderRef.value ?: return
val file = audioFileRef.value ?: return
@ -1530,8 +1543,6 @@ private fun stopRecording(
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)
onRecorded("voice_${System.currentTimeMillis()}.m4a", "audio/mp4", file)
}
}

View file

@ -1,7 +1,9 @@
package dev.kaizen.app.db
import dev.kaizen.app.net.KaizenApi
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
class MessageRepository(private val dao: MessageDao) {
@ -24,11 +26,14 @@ class MessageRepository(private val dao: MessageDao) {
suspend fun prefetchMissing(baseUrl: String, token: String, conversationIds: List<String>) {
val cached = dao.cachedConversationIds().toSet()
for (id in conversationIds) {
if (id in cached) continue
try {
fetchAndCache(baseUrl, token, id)
} catch (_: Exception) { }
val missing = conversationIds.filter { it !in cached }
if (missing.isEmpty()) return
coroutineScope {
for (id in missing) {
launch {
try { fetchAndCache(baseUrl, token, id) } catch (_: Exception) { }
}
}
}
}
}

View file

@ -9,6 +9,7 @@ import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import dev.kaizen.app.net.KaizenApi
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
@ -57,10 +58,9 @@ class LiveClient(
val conversationId: String?,
)
private val client = OkHttpClient.Builder()
private val client = KaizenApi.baseClient.newBuilder()
.readTimeout(0, TimeUnit.MILLISECONDS)
.pingInterval(15, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.build()
// ─── Connect ──────────────────────────────────────────────────────────

View file

@ -13,9 +13,13 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okio.BufferedSink
import okio.source
import android.util.Log
import java.io.IOException
import java.io.InputStream
import java.io.InputStreamReader
import java.util.concurrent.TimeUnit
@ -80,6 +84,7 @@ data class ConversationSummary(
val isCurrent: Boolean = false,
)
@Serializable private data class AppDevicesResponse(val tokens: List<AppDevice> = emptyList())
@Serializable private data class DeleteMessageRequest(val messageId: String)
@Serializable private data class ChangePasswordRequest(val currentPassword: String, val newPassword: String)
/** Attachment on a persisted message (image, audio, video, pdf, document, code). */
@ -176,15 +181,19 @@ object KaizenApi {
private const val TAG = "KaizenApi"
private val client = OkHttpClient.Builder()
val baseClient: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS) // streaming chat — never time out a healthy stream
.build()
val imageClient: OkHttpClient = client.newBuilder()
.readTimeout(30, TimeUnit.SECONDS)
.build()
private val client = baseClient
private val streamClient: OkHttpClient = client.newBuilder()
.readTimeout(0, TimeUnit.SECONDS)
.build()
val imageClient: OkHttpClient = client
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val jsonMedia = "application/json".toMediaType()
@ -227,22 +236,6 @@ object KaizenApi {
}
}
suspend fun fetchDefaultModel(baseUrl: String, token: String): String? =
withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/api/v1/me")
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@use null
json.decodeFromString<MeResponse>(resp.body!!.string()).defaultModel
}
} catch (e: Exception) {
null
}
}
/** GET /api/v1/models — the full model catalog for the picker. */
suspend fun fetchModels(baseUrl: String, token: String): FetchResult<List<KaizenModel>> =
withContext(Dispatchers.IO) {
@ -258,7 +251,7 @@ object KaizenApi {
403 -> " (Zugriff verweigert)"
else -> ""
}
Log.w(TAG, "fetchModels: HTTP ${resp.code} from $baseUrl")
Log.w(TAG, "fetchModels: HTTP ${resp.code}")
return@use FetchResult.Fail(reason)
}
FetchResult.Ok(json.decodeFromString<ModelsResponse>(resp.body!!.string()).models)
@ -284,7 +277,7 @@ object KaizenApi {
403 -> " (Zugriff verweigert)"
else -> ""
}
Log.w(TAG, "fetchConversations: HTTP ${resp.code} from $baseUrl")
Log.w(TAG, "fetchConversations: HTTP ${resp.code}")
return@use FetchResult.Fail(reason)
}
FetchResult.Ok(json.decodeFromString<ConversationsResponse>(resp.body!!.string()).conversations)
@ -459,7 +452,7 @@ object KaizenApi {
/** DELETE /api/v1/conversations/[id]/messages — delete a single message. */
suspend fun deleteMessage(baseUrl: String, token: String, conversationId: String, messageId: String): Boolean =
withContext(Dispatchers.IO) {
val body = """{"messageId":"$messageId"}""".toRequestBody(jsonMedia)
val body = json.encodeToString(DeleteMessageRequest(messageId)).toRequestBody(jsonMedia)
val req = Request.Builder()
.url("$baseUrl/api/v1/conversations/$conversationId/messages")
.delete(body)
@ -539,7 +532,7 @@ object KaizenApi {
.header("Authorization", "Bearer $token")
.build()
val resp = client.newCall(req).execute()
val resp = streamClient.newCall(req).execute()
if (!resp.isSuccessful) {
val code = resp.code
resp.close()
@ -604,6 +597,50 @@ object KaizenApi {
}
}
suspend fun uploadStream(
baseUrl: String,
token: String,
fileName: String,
mimeType: String,
inputStream: InputStream,
contentLength: Long = -1L,
): FetchResult<Attachment> = withContext(Dispatchers.IO) {
val mediaType = mimeType.toMediaType()
val streamBody = object : RequestBody() {
override fun contentType() = mediaType
override fun contentLength() = contentLength
override fun writeTo(sink: BufferedSink) {
inputStream.source().use { source -> sink.writeAll(source) }
}
}
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("file", fileName, streamBody)
.build()
val req = Request.Builder()
.url("$baseUrl/api/v1/upload")
.post(body)
.header("Authorization", "Bearer $token")
.build()
try {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) {
val reason = "upload: HTTP ${resp.code}" + when (resp.code) {
401 -> " (Token ungültig)"
413 -> " (Datei zu groß)"
else -> ""
}
Log.w(TAG, "uploadFile: HTTP ${resp.code}")
return@use FetchResult.Fail(reason)
}
FetchResult.Ok(json.decodeFromString<Attachment>(resp.body!!.string()))
}
} catch (e: Exception) {
Log.w(TAG, "uploadStream: ${e.javaClass.simpleName}: ${e.message}")
FetchResult.Fail(diagnoseFetchError("upload", e))
}
}
@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)

View file

@ -39,7 +39,8 @@ class SessionViewModel(val store: SecureStore) {
val baseUrl = normalizeBaseUrl(baseUrlInput)
return when (val result = KaizenApi.login(baseUrl, email, password, deviceName)) {
is LoginResult.Success -> {
val model = KaizenApi.fetchDefaultModel(baseUrl, result.token) ?: DEFAULT_MODEL
val me = KaizenApi.fetchMe(baseUrl, result.token)
val model = me?.defaultModel ?: DEFAULT_MODEL
val cfg = ServerConfig(baseUrl = baseUrl, token = result.token, model = model, email = email.trim())
store.save(cfg)
config = cfg