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:
parent
392e582084
commit
3b73e15376
7 changed files with 139 additions and 84 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 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)
|
||||
val galleryPicker = rememberLauncherForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris ->
|
||||
for (uri in uris) uploadPickedUri(uri)
|
||||
}
|
||||
|
||||
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue