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" />
|
<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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ──────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue