perf(#520): stream Base64 encode attachments in 128KB chunks with yield (rebase onto main) (#520)

This commit is contained in:
M57 authored and GitHub committed 2026-07-09 21:53:27 +03:00
1 parent 350db20af6
commit 545da107f9
1 file changed
+22 -7
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.chat
import android.app.Application import android.app.Application
import android.net.Uri import android.net.Uri
import android.util.Base64 import android.util.Base64
import android.util.Base64OutputStream
import android.util.Log import android.util.Log
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -33,11 +34,13 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.yield
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.decodeFromJsonElement
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
private const val TAG = "ChatViewModel" private const val TAG = "ChatViewModel"
@@ -589,12 +592,11 @@ class ChatViewModel(
val fileRefs = mutableListOf<String>() val fileRefs = mutableListOf<String>()
for (attachment in attachments) { for (attachment in attachments) {
val bytes = readContentUriBytes(attachment.uri) val b64 = readContentUriBase64(attachment.uri)
if (bytes == null) { if (b64 == null) {
Log.w(TAG, "Skipping unreadable attachment: ${attachment.name}") Log.w(TAG, "Skipping unreadable attachment: ${attachment.name}")
continue continue
} }
val b64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
try { try {
if (attachment.isImage) { if (attachment.isImage) {
@@ -650,14 +652,27 @@ class ChatViewModel(
} }
} }
/** Read bytes from a `content://` or `file://` URI via ContentResolver. */ /** Read and encode a `content://` or `file://` URI to Base64 via ContentResolver, avoiding large allocations. */
private fun readContentUriBytes(uriString: String): ByteArray? = private suspend fun readContentUriBase64(uriString: String): String? =
try { try {
val context = getApplication<Application>() val context = getApplication<Application>()
val uri = Uri.parse(uriString) val uri = Uri.parse(uriString)
context.contentResolver.openInputStream(uri)?.use { stream -> stream.readBytes() } context.contentResolver.openInputStream(uri)?.use { stream ->
val baos = ByteArrayOutputStream()
val b64os = Base64OutputStream(baos, Base64.NO_WRAP)
val buffer = ByteArray(1024 * 128) // 128KB chunk
var bytesRead: Int
while (stream.read(buffer).also { bytesRead = it } != -1) {
b64os.write(buffer, 0, bytesRead)
yield() // Prevent blocking the thread during large reads
}
b64os.close()
baos.toString("UTF-8")
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to read attachment bytes: ${e.message}", e) if (e is kotlinx.coroutines.CancellationException) throw e
Log.e(TAG, "Failed to read and encode attachment: ${e.message}", e)
null null
} }