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" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<application <application
android:allowBackup="true" android:allowBackup="false"
android:usesCleartextTraffic="false"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"

View file

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

View file

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

View file

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

View file

@ -13,9 +13,13 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okio.BufferedSink
import okio.source
import android.util.Log import android.util.Log
import java.io.IOException import java.io.IOException
import java.io.InputStream
import java.io.InputStreamReader import java.io.InputStreamReader
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -80,6 +84,7 @@ data class ConversationSummary(
val isCurrent: Boolean = false, val isCurrent: Boolean = false,
) )
@Serializable private data class AppDevicesResponse(val tokens: List<AppDevice> = emptyList()) @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) @Serializable private data class ChangePasswordRequest(val currentPassword: String, val newPassword: String)
/** Attachment on a persisted message (image, audio, video, pdf, document, code). */ /** Attachment on a persisted message (image, audio, video, pdf, document, code). */
@ -176,15 +181,19 @@ object KaizenApi {
private const val TAG = "KaizenApi" private const val TAG = "KaizenApi"
private val client = OkHttpClient.Builder() val baseClient: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS) .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) .readTimeout(30, TimeUnit.SECONDS)
.build() .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 json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val jsonMedia = "application/json".toMediaType() 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. */ /** GET /api/v1/models — the full model catalog for the picker. */
suspend fun fetchModels(baseUrl: String, token: String): FetchResult<List<KaizenModel>> = suspend fun fetchModels(baseUrl: String, token: String): FetchResult<List<KaizenModel>> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@ -258,7 +251,7 @@ object KaizenApi {
403 -> " (Zugriff verweigert)" 403 -> " (Zugriff verweigert)"
else -> "" else -> ""
} }
Log.w(TAG, "fetchModels: HTTP ${resp.code} from $baseUrl") Log.w(TAG, "fetchModels: HTTP ${resp.code}")
return@use FetchResult.Fail(reason) return@use FetchResult.Fail(reason)
} }
FetchResult.Ok(json.decodeFromString<ModelsResponse>(resp.body!!.string()).models) FetchResult.Ok(json.decodeFromString<ModelsResponse>(resp.body!!.string()).models)
@ -284,7 +277,7 @@ object KaizenApi {
403 -> " (Zugriff verweigert)" 403 -> " (Zugriff verweigert)"
else -> "" else -> ""
} }
Log.w(TAG, "fetchConversations: HTTP ${resp.code} from $baseUrl") Log.w(TAG, "fetchConversations: HTTP ${resp.code}")
return@use FetchResult.Fail(reason) return@use FetchResult.Fail(reason)
} }
FetchResult.Ok(json.decodeFromString<ConversationsResponse>(resp.body!!.string()).conversations) 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. */ /** DELETE /api/v1/conversations/[id]/messages — delete a single message. */
suspend fun deleteMessage(baseUrl: String, token: String, conversationId: String, messageId: String): Boolean = suspend fun deleteMessage(baseUrl: String, token: String, conversationId: String, messageId: String): Boolean =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val body = """{"messageId":"$messageId"}""".toRequestBody(jsonMedia) val body = json.encodeToString(DeleteMessageRequest(messageId)).toRequestBody(jsonMedia)
val req = Request.Builder() val req = Request.Builder()
.url("$baseUrl/api/v1/conversations/$conversationId/messages") .url("$baseUrl/api/v1/conversations/$conversationId/messages")
.delete(body) .delete(body)
@ -539,7 +532,7 @@ object KaizenApi {
.header("Authorization", "Bearer $token") .header("Authorization", "Bearer $token")
.build() .build()
val resp = client.newCall(req).execute() val resp = streamClient.newCall(req).execute()
if (!resp.isSuccessful) { if (!resp.isSuccessful) {
val code = resp.code val code = resp.code
resp.close() 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 SpeechRequest(val prompt: String, val kind: String = "speech", val voice: String? = null)
@Serializable private data class SpeechResponse(val url: String = "", val cost: Double? = 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) val baseUrl = normalizeBaseUrl(baseUrlInput)
return when (val result = KaizenApi.login(baseUrl, email, password, deviceName)) { return when (val result = KaizenApi.login(baseUrl, email, password, deviceName)) {
is LoginResult.Success -> { 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()) val cfg = ServerConfig(baseUrl = baseUrl, token = result.token, model = model, email = email.trim())
store.save(cfg) store.save(cfg)
config = cfg config = cfg