added attachment option and capabilities.

This commit is contained in:
souravanand001 2026-06-06 03:06:37 +05:30
parent 70c0e575e2
commit 8aec5fb91c
25 changed files with 1549 additions and 278 deletions

View file

@ -66,6 +66,16 @@
android:theme="@style/Theme.TransparentActivity"
android:showOnLockScreen="true" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="com.app.assistant.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View file

@ -6,7 +6,7 @@ import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@Database(entities = [GroupEntity::class, MessageEntity::class], version = 3, exportSchema = false)
@Database(entities = [GroupEntity::class, MessageEntity::class, AttachmentEntity::class], version = 4, exportSchema = false)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun conversationDao(): ConversationDao

View file

@ -0,0 +1,39 @@
package com.app.assistant.db
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "attachments",
foreignKeys = [
ForeignKey(
entity = MessageEntity::class,
parentColumns = ["id"],
childColumns = ["message_id"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index(value = ["message_id"])]
)
data class AttachmentEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@ColumnInfo(name = "message_id")
val messageId: Long,
@ColumnInfo(name = "file_path")
val filePath: String,
@ColumnInfo(name = "mime_type")
val mimeType: String,
@ColumnInfo(name = "file_name")
val fileName: String,
@ColumnInfo(name = "iv")
val iv: String
)

View file

@ -31,4 +31,13 @@ interface ConversationDao {
@Query("SELECT * FROM messages WHERE group_id = :groupId ORDER BY id ASC")
suspend fun getMessagesForGroup(groupId: Long): List<MessageEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAttachment(attachment: AttachmentEntity)
@Query("SELECT * FROM attachments WHERE message_id = :messageId ORDER BY id ASC")
suspend fun getAttachmentsForMessage(messageId: Long): List<AttachmentEntity>
@Query("DELETE FROM attachments WHERE message_id = :messageId")
suspend fun deleteAttachmentsForMessage(messageId: Long)
}

View file

@ -3,8 +3,11 @@ package com.app.assistant.db
import android.content.Context
import com.app.assistant.model.Conversation
import com.app.assistant.model.Group
import com.app.assistant.model.Attachment
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@ -15,6 +18,7 @@ class DynamicConversationRepository(
private val db = AppDatabase.getDatabase(context)
private val dao = db.conversationDao()
var currentGroupId: Long = -1L
private val writeMutex = Mutex()
private suspend fun startNewChat(msg: String): Long {
return withContext(Dispatchers.IO) {
@ -33,31 +37,60 @@ class DynamicConversationRepository(
}
suspend fun addMessage(conversation: Conversation) {
withContext(Dispatchers.IO) {
if (currentGroupId == -1L) {
currentGroupId = startNewChat(conversation.text)
writeMutex.withLock {
withContext(Dispatchers.IO) {
if (currentGroupId == -1L) {
currentGroupId = startNewChat(conversation.text)
}
val iv = EncryptionUtil.generateIV()
val (encryptedText, _) = EncryptionUtil.encrypt(conversation.text, iv)
val messageEntity = MessageEntity(
id = conversation.id,
text = encryptedText,
isMe = conversation.isMe,
category = conversation.category,
contentURL = conversation.contentURL,
navigationURI = conversation.navigationURI,
iv = iv,
groupId = currentGroupId
)
dao.insertMessage(messageEntity)
// Insert attachments
conversation.attachments.forEach { attachment ->
val attachmentEntity = AttachmentEntity(
id = attachment.id,
messageId = conversation.id,
filePath = attachment.filePath,
mimeType = attachment.mimeType,
fileName = attachment.fileName,
iv = attachment.iv
)
dao.insertAttachment(attachmentEntity)
}
}
val iv = EncryptionUtil.generateIV()
val (encryptedText, _) = EncryptionUtil.encrypt(conversation.text, iv)
val messageEntity = MessageEntity(
id = conversation.id,
text = encryptedText,
isMe = conversation.isMe,
category = conversation.category,
contentURL = conversation.contentURL,
navigationURI = conversation.navigationURI,
iv = iv,
groupId = currentGroupId
)
dao.insertMessage(messageEntity)
}
}
suspend fun deleteMessage(id: Long) {
withContext(Dispatchers.IO) {
dao.deleteMessageById(id)
writeMutex.withLock {
withContext(Dispatchers.IO) {
val attachmentEntities = dao.getAttachmentsForMessage(id)
attachmentEntities.forEach { att ->
try {
val file = java.io.File(att.filePath)
if (file.exists()) {
file.delete()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
dao.deleteAttachmentsForMessage(id)
dao.deleteMessageById(id)
}
}
}
@ -65,30 +98,62 @@ class DynamicConversationRepository(
oldConversation: Conversation,
newConversation: Conversation,
) {
withContext(Dispatchers.IO) {
val iv = EncryptionUtil.generateIV()
val (encryptedText, _) = EncryptionUtil.encrypt(newConversation.text, iv)
writeMutex.withLock {
withContext(Dispatchers.IO) {
val iv = EncryptionUtil.generateIV()
val (encryptedText, _) = EncryptionUtil.encrypt(newConversation.text, iv)
val messageEntity = MessageEntity(
id = newConversation.id,
text = encryptedText,
isMe = newConversation.isMe,
category = newConversation.category,
contentURL = newConversation.contentURL,
navigationURI = newConversation.navigationURI,
iv = iv,
groupId = currentGroupId
)
dao.updateMessage(messageEntity)
val messageEntity = MessageEntity(
id = newConversation.id,
text = encryptedText,
isMe = newConversation.isMe,
category = newConversation.category,
contentURL = newConversation.contentURL,
navigationURI = newConversation.navigationURI,
iv = iv,
groupId = currentGroupId
)
dao.updateMessage(messageEntity)
// Delete old attachments first and insert new ones
dao.deleteAttachmentsForMessage(newConversation.id)
newConversation.attachments.forEach { attachment ->
val attachmentEntity = AttachmentEntity(
id = attachment.id,
messageId = newConversation.id,
filePath = attachment.filePath,
mimeType = attachment.mimeType,
fileName = attachment.fileName,
iv = attachment.iv
)
dao.insertAttachment(attachmentEntity)
}
}
}
}
suspend fun clearMessages(conversations: List<Conversation>) {
withContext(Dispatchers.IO) {
val ids = conversations.map { it.id }
dao.deleteMessagesByIds(ids)
dao.deleteGroupById(currentGroupId)
currentGroupId = -1L
writeMutex.withLock {
withContext(Dispatchers.IO) {
conversations.forEach { conversation ->
val attachmentEntities = dao.getAttachmentsForMessage(conversation.id)
attachmentEntities.forEach { att ->
try {
val file = java.io.File(att.filePath)
if (file.exists()) {
file.delete()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
dao.deleteAttachmentsForMessage(conversation.id)
}
val ids = conversations.map { it.id }
dao.deleteMessagesByIds(ids)
dao.deleteGroupById(currentGroupId)
currentGroupId = -1L
}
}
}
@ -104,6 +169,16 @@ class DynamicConversationRepository(
return withContext(Dispatchers.IO) {
dao.getMessagesForGroup(groupId).map { entity ->
val decryptedText = EncryptionUtil.decrypt(entity.text, entity.iv)
val attachmentEntities = dao.getAttachmentsForMessage(entity.id)
val attachments = attachmentEntities.map { attEntity ->
Attachment(
id = attEntity.id,
filePath = attEntity.filePath,
mimeType = attEntity.mimeType,
fileName = attEntity.fileName,
iv = attEntity.iv
)
}
Conversation(
id = entity.id,
@ -112,7 +187,8 @@ class DynamicConversationRepository(
isLoading = false,
category = entity.category,
contentURL = entity.contentURL,
navigationURI = entity.navigationURI
navigationURI = entity.navigationURI,
attachments = attachments
)
}.toMutableList()
}

View file

@ -75,6 +75,35 @@ object EncryptionUtil {
}
}
// Encrypt a file byte array using IV
fun encryptFile(inputBytes: ByteArray, ivString: String): ByteArray {
if (inputBytes.isEmpty()) return ByteArray(0)
return try {
val iv = Base64.decode(ivString, Base64.DEFAULT)
val cipher = Cipher.getInstance(TRANSFORMATION)
val spec = GCMParameterSpec(TAG_LENGTH_BIT, iv)
cipher.init(Cipher.ENCRYPT_MODE, secretKey, spec)
cipher.doFinal(inputBytes)
} catch (e: Exception) {
inputBytes
}
}
// Decrypt a file byte array using IV
fun decryptFile(encryptedBytes: ByteArray, ivString: String): ByteArray {
if (encryptedBytes.isEmpty() || ivString.isEmpty()) return encryptedBytes
return try {
val iv = Base64.decode(ivString, Base64.DEFAULT)
val cipher = Cipher.getInstance(TRANSFORMATION)
val spec = GCMParameterSpec(TAG_LENGTH_BIT, iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
cipher.doFinal(encryptedBytes)
} catch (e: Exception) {
encryptedBytes
}
}
// Retrieve or create a persistent AES key in Android KeyStore
private fun getOrCreateSecretKey(): SecretKey {
return try {

View file

@ -18,7 +18,8 @@ class FlexibleLlmAdapter(
private val client: OkHttpClient,
private val apiKey: String,
private val model: String,
private val config: LlmConfig
private val config: LlmConfig,
private val provider: LlmProvider
) : LlmAdapter {
private val json = Json { ignoreUnknownKeys = true }
@ -174,10 +175,14 @@ class FlexibleLlmAdapter(
"assistant" -> config.assistantRole
else -> msg.role
}
val escapedContent = escapeJsonString(msg.content)
val msgJson = config.messageFormat
.replace("{{ROLE}}", roleValue)
.replace("{{CONTENT}}", escapedContent)
val msgJson = if (msg.attachments.isNullOrEmpty()) {
val escapedContent = escapeJsonString(msg.content)
config.messageFormat
.replace("{{ROLE}}", roleValue)
.replace("{{CONTENT}}", escapedContent)
} else {
buildMultimodalMessageJson(roleValue, msg.content, msg.attachments, provider)
}
serializedMessagesBuilder.append(msgJson)
if (index < messagesToSerialize.size - 1) {
@ -205,6 +210,46 @@ class FlexibleLlmAdapter(
return requestBodyString
}
private fun buildMultimodalMessageJson(
role: String,
content: String,
attachments: List<LlmAttachment>,
provider: LlmProvider
): String {
return when (provider) {
LlmProvider.GEMINI -> {
val parts = mutableListOf<String>()
parts.add("{\"text\": \"${escapeJsonString(content)}\"}")
attachments.forEach { att ->
parts.add("{\"inlineData\": {\"mimeType\": \"${att.mimeType}\", \"data\": \"${att.base64Data}\"}}")
}
"{\"role\": \"$role\", \"parts\": [${parts.joinToString(",")}]}"
}
LlmProvider.ANTHROPIC -> {
val contentArray = mutableListOf<String>()
contentArray.add("{\"type\": \"text\", \"text\": \"${escapeJsonString(content)}\"}")
attachments.forEach { att ->
if (att.mimeType.startsWith("image/")) {
contentArray.add("{\"type\": \"image\", \"source\": {\"type\": \"base64\", \"media_type\": \"${att.mimeType}\", \"data\": \"${att.base64Data}\"}}")
} else if (att.mimeType == "application/pdf") {
contentArray.add("{\"type\": \"document\", \"source\": {\"type\": \"base64\", \"media_type\": \"${att.mimeType}\", \"data\": \"${att.base64Data}\"}}")
}
}
"{\"role\": \"$role\", \"content\": [${contentArray.joinToString(",")}]}"
}
else -> { // OpenAI, Groq, Ollama, DeepSeek, OpenRouter, Custom
val contentArray = mutableListOf<String>()
contentArray.add("{\"type\": \"text\", \"text\": \"${escapeJsonString(content)}\"}")
attachments.forEach { att ->
if (att.mimeType.startsWith("image/")) {
contentArray.add("{\"type\": \"image_url\", \"image_url\": {\"url\": \"data:${att.mimeType};base64,${att.base64Data}\"}}")
}
}
"{\"role\": \"$role\", \"content\": [${contentArray.joinToString(",")}]}"
}
}
}
private fun extractChunkContent(jsonStr: String, path: String): String? {
var extracted = extractValueFromJson(jsonStr, path)
if (extracted != null) return extracted

View file

@ -2,8 +2,16 @@ package com.app.assistant.llm
import kotlinx.serialization.Serializable
@Serializable
data class LlmAttachment(
val base64Data: String,
val mimeType: String,
val fileName: String
)
@Serializable
data class LlmMessage(
val role: String,
val content: String
val content: String,
val attachments: List<LlmAttachment>? = null
)

View file

@ -3,5 +3,6 @@ package com.app.assistant.llm
data class ModelCapabilities(
val hasImageInput: Boolean,
val hasAudioInput: Boolean,
val hasVideoInput: Boolean
val hasVideoInput: Boolean,
val hasDocumentInput: Boolean
)

View file

@ -19,11 +19,12 @@ object ModelCapabilityProber {
val model = modelName.lowercase()
return when (provider) {
LlmProvider.GEMINI -> {
val isMultimodal = model.contains("gemini-1.5") || model.contains("gemini-2.0") || model.contains("gemini-pro")
val isMultimodal = model.contains("gemini-1.5") || model.contains("gemini-2.0") || model.contains("gemini-pro") || model.contains("gemini-2.5")
ModelCapabilities(
hasImageInput = isMultimodal || model.contains("vision"),
hasAudioInput = isMultimodal,
hasVideoInput = isMultimodal
hasVideoInput = isMultimodal,
hasDocumentInput = isMultimodal
)
}
LlmProvider.OPENAI -> {
@ -32,7 +33,8 @@ object ModelCapabilityProber {
ModelCapabilities(
hasImageInput = hasVision,
hasAudioInput = hasAudio,
hasVideoInput = false
hasVideoInput = false,
hasDocumentInput = false
)
}
LlmProvider.ANTHROPIC -> {
@ -40,14 +42,16 @@ object ModelCapabilityProber {
ModelCapabilities(
hasImageInput = supportsVision,
hasAudioInput = false,
hasVideoInput = false
hasVideoInput = false,
hasDocumentInput = supportsVision
)
}
LlmProvider.GROQ -> {
ModelCapabilities(
hasImageInput = model.contains("vision") || model.contains("scout"),
hasAudioInput = false,
hasVideoInput = false
hasVideoInput = false,
hasDocumentInput = false
)
}
LlmProvider.OLLAMA -> {
@ -55,31 +59,36 @@ object ModelCapabilityProber {
ModelCapabilities(
hasImageInput = isMultimodal,
hasAudioInput = false,
hasVideoInput = false
hasVideoInput = false,
hasDocumentInput = false
)
}
LlmProvider.DEEPSEEK -> {
ModelCapabilities(
hasImageInput = model.contains("vl"),
hasAudioInput = false,
hasVideoInput = false
hasVideoInput = false,
hasDocumentInput = false
)
}
LlmProvider.OPEN_ROUTER -> {
val hasVision = model.contains("vision") || model.contains("llava") || model.contains("gpt-4o") || model.contains("claude-3") || model.contains("gemini")
val hasAudio = model.contains("gemini") || model.contains("audio")
val hasVideo = model.contains("gemini")
val hasDoc = model.contains("gemini") || model.contains("claude-3")
ModelCapabilities(
hasImageInput = hasVision,
hasAudioInput = hasAudio,
hasVideoInput = hasVideo
hasVideoInput = hasVideo,
hasDocumentInput = hasDoc
)
}
LlmProvider.CUSTOM -> {
ModelCapabilities(
hasImageInput = false,
hasAudioInput = false,
hasVideoInput = false
hasVideoInput = false,
hasDocumentInput = false
)
}
}

View file

@ -0,0 +1,11 @@
package com.app.assistant.model
import java.io.Serializable
data class Attachment(
val id: Long = System.currentTimeMillis() * 1_000_000 + (System.nanoTime() % 1_000_000),
val filePath: String,
val mimeType: String,
val fileName: String,
val iv: String
) : Serializable

View file

@ -11,6 +11,7 @@ data class Conversation(
val contentURL: String = "",
val navigationURI: URI = URI(""),
val isStreaming: Boolean = false,
val attachments: List<Attachment> = emptyList(),
) {
fun getThinkingProcess(): String? {
val start = text.indexOf("<think>")
@ -25,10 +26,17 @@ data class Conversation(
fun getActualAnswer(): String {
val end = text.indexOf("</think>")
return if (end == -1) {
val mainText = if (end == -1) {
if (text.contains("<think>")) "" else text
} else {
text.substring(end + 8)
}
if (isMe) {
val fileIndex = mainText.indexOf("\n\n[Attached File:")
if (fileIndex != -1) {
return mainText.substring(0, fileIndex)
}
}
return mainText
}
}

View file

@ -1,4 +1,4 @@
package com.app.assistant.repository
package com.app.assistant.repository
import android.content.Context
import android.content.SharedPreferences
@ -126,6 +126,11 @@ class SettingsRepository(
sharedPreferences.edit().putBoolean("is_video_supported", supported).apply()
}
fun getIsDocumentSupported(): Boolean = sharedPreferences.getBoolean("is_document_supported", false)
fun setIsDocumentSupported(supported: Boolean) {
sharedPreferences.edit().putBoolean("is_document_supported", supported).apply()
}
fun getIsModelVerified(): Boolean = sharedPreferences.getBoolean("is_model_verified", false)
fun setIsModelVerified(verified: Boolean) {
sharedPreferences.edit().putBoolean("is_model_verified", verified).apply()

View file

@ -111,6 +111,29 @@ fun SetupUI(viewModel: MainViewModel, settingsViewModel: SettingsViewModel) {
var currentScreen by remember { mutableStateOf("chat") }
val selectedAttachments by viewModel.selectedAttachments.collectAsState()
val isImageSupported = remember(currentScreen) { viewModel.settingsRepository.getIsImageSupported() }
val isAudioSupported = remember(currentScreen) { viewModel.settingsRepository.getIsAudioSupported() }
val isVideoSupported = remember(currentScreen) { viewModel.settingsRepository.getIsVideoSupported() }
val isDocumentSupported = remember(currentScreen) { viewModel.settingsRepository.getIsDocumentSupported() }
val pickerLauncher = androidx.activity.compose.rememberLauncherForActivityResult(
contract = androidx.activity.result.contract.ActivityResultContracts.GetContent()
) { uri ->
uri?.let { viewModel.addSelectedAttachment(it) }
}
var tempCameraUri by remember { mutableStateOf<android.net.Uri?>(null) }
val cameraLauncher = androidx.activity.compose.rememberLauncherForActivityResult(
contract = androidx.activity.result.contract.ActivityResultContracts.TakePicture()
) { success ->
if (success) {
tempCameraUri?.let { uri ->
viewModel.addSelectedAttachment(uri)
}
}
}
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.showToastEvent.collectLatest { message: String ->
@ -190,7 +213,29 @@ fun SetupUI(viewModel: MainViewModel, settingsViewModel: SettingsViewModel) {
onTranslationEnabledChange = { viewModel.updateTranslationEnabled(it) },
onLanguageSelected = { viewModel.onItemSelected(it) },
onDismissBottomSheet = { viewModel.setShowBottomSheet(false) },
onBackPressed = { /* Handled in parent context back handler */ }
onBackPressed = { /* Handled in parent context back handler */ },
selectedAttachments = selectedAttachments,
onRemoveAttachment = { viewModel.removeSelectedAttachment(it) },
onAttachClick = { type ->
if (type == "camera") {
try {
val file = java.io.File(context.cacheDir, "camera_capture_${System.currentTimeMillis()}.jpg")
val authority = "${context.packageName}.fileprovider"
val uri = androidx.core.content.FileProvider.getUriForFile(context, authority, file)
tempCameraUri = uri
cameraLauncher.launch(uri)
} catch (e: Exception) {
android.util.Log.e("ChatScreen", "Failed to launch camera", e)
Toast.makeText(context, "Failed to launch camera", Toast.LENGTH_SHORT).show()
}
} else {
pickerLauncher.launch(type)
}
},
isImageSupported = isImageSupported,
isAudioSupported = isAudioSupported,
isVideoSupported = isVideoSupported,
isDocumentSupported = isDocumentSupported
)
}
}
@ -227,6 +272,13 @@ fun ChatScreenContent(
onLanguageSelected: (String) -> Unit,
onDismissBottomSheet: () -> Unit,
onBackPressed: () -> Unit,
selectedAttachments: List<com.app.assistant.model.Attachment> = emptyList(),
onRemoveAttachment: (com.app.assistant.model.Attachment) -> Unit = {},
onAttachClick: (String) -> Unit = {},
isImageSupported: Boolean = false,
isAudioSupported: Boolean = false,
isVideoSupported: Boolean = false,
isDocumentSupported: Boolean = false,
) {
var showCopyIcon by remember { mutableStateOf(false) }
var selectedItemIndex by remember { mutableStateOf<Int?>(null) }
@ -332,6 +384,13 @@ fun ChatScreenContent(
onStartListening = onStartListening,
onProcessQuestion = onProcessQuestion,
isTranslateEnabled = isTranslationEnabled,
selectedAttachments = selectedAttachments,
onRemoveAttachment = onRemoveAttachment,
onAttachClick = onAttachClick,
isImageSupported = isImageSupported,
isAudioSupported = isAudioSupported,
isVideoSupported = isVideoSupported,
isDocumentSupported = isDocumentSupported,
)
}
}

View file

@ -39,11 +39,14 @@ import androidx.compose.ui.res.stringResource
import coil3.compose.AsyncImage
import com.app.assistant.R
import com.app.assistant.model.Conversation
import com.app.assistant.model.Attachment
import com.app.assistant.util.Category
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import com.app.assistant.ui.theme.AssistantTheme
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.filled.KeyboardArrowDown
@ -53,6 +56,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.foundation.Image
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
import androidx.compose.ui.platform.LocalContext
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.draw.clip
import androidx.compose.material3.IconButton
import androidx.compose.ui.res.painterResource
@Composable
@Suppress("ktlint:standard:function-naming")
@ -103,7 +120,7 @@ fun ConversationItem(
LoadingDots() // Show loading animation when isLoading is true
} else {
if (conversation.isMe) {
OtherCard(conversation, onLongClick, index)
OtherCard(conversation, onLongClick, index, cornerRadius)
} else {
when (conversation.category) {
Category.CALL.name -> {
@ -111,12 +128,12 @@ fun ConversationItem(
}
Category.OTHER.name -> {
OtherCard(conversation, onLongClick, index)
OtherCard(conversation, onLongClick, index, cornerRadius)
}
Category.SETTINGS.name -> {
// TODO
OtherCard(conversation, onLongClick, index)
OtherCard(conversation, onLongClick, index, cornerRadius)
}
Category.SONGS.name -> {
@ -132,15 +149,15 @@ fun ConversationItem(
}
Category.REMINDER.name -> {
OtherCard(conversation, onLongClick, index)
OtherCard(conversation, onLongClick, index, cornerRadius)
}
Category.ALARM.name -> {
OtherCard(conversation, onLongClick, index)
OtherCard(conversation, onLongClick, index, cornerRadius)
}
else -> {
OtherCard(conversation, onLongClick, index)
OtherCard(conversation, onLongClick, index, cornerRadius)
}
}
}
@ -279,13 +296,14 @@ private fun OtherCard(
conversation: Conversation,
onLongClick: (Int) -> Unit,
index: Int,
cornerRadius: Dp = 20.dp,
) {
val thinkingText = conversation.getThinkingProcess()
val answerText = conversation.getActualAnswer()
val hasText = answerText.isNotEmpty() || conversation.isStreaming || thinkingText != null
Column(
modifier = Modifier
.padding(16.dp, 8.dp)
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
@ -294,77 +312,504 @@ private fun OtherCard(
)
}
) {
if (thinkingText != null) {
var isExpanded by remember { mutableStateOf(true) }
Card(
if (conversation.attachments.isNotEmpty()) {
ConversationAttachments(
attachments = conversation.attachments,
hasTextBelow = hasText,
cornerRadius = cornerRadius,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
}
if (thinkingText != null || answerText.isNotEmpty() || conversation.isStreaming) {
Column(
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
top = if (conversation.attachments.isNotEmpty()) 8.dp else 8.dp,
bottom = 8.dp
)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
if (thinkingText != null) {
var isExpanded by remember { mutableStateOf(true) }
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { isExpanded = !isExpanded },
verticalAlignment = Alignment.CenterVertically
.padding(bottom = 8.dp),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) {
Text(
text = "🧠",
fontSize = 16.sp,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = "Thinking Process",
style = MaterialTheme.typography.labelLarge.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
),
modifier = Modifier.weight(1f)
)
Icon(
imageVector = if (isExpanded) {
Icons.Default.KeyboardArrowUp
} else {
Icons.Default.KeyboardArrowDown
},
contentDescription = if (isExpanded) "Collapse" else "Expand",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { isExpanded = !isExpanded },
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🧠",
fontSize = 16.sp,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = "Thinking Process",
style = MaterialTheme.typography.labelLarge.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
),
modifier = Modifier.weight(1f)
)
Icon(
imageVector = if (isExpanded) {
Icons.Default.KeyboardArrowUp
} else {
Icons.Default.KeyboardArrowDown
},
contentDescription = if (isExpanded) "Collapse" else "Expand",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
}
AnimatedVisibility(visible = isExpanded) {
Column {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = thinkingText,
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f)
),
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}
if (answerText.isNotEmpty() || conversation.isStreaming) {
val displayAnswer = if (conversation.isStreaming) {
"$answerText ▮"
} else {
answerText
}
AnimatedVisibility(visible = isExpanded) {
Column {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = thinkingText,
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f)
),
modifier = Modifier.fillMaxWidth()
)
}
MarkdownText(
markdown = displayAnswer,
modifier = Modifier
)
}
}
}
}
}
@Composable
fun ConversationAttachments(
attachments: List<Attachment>,
hasTextBelow: Boolean,
cornerRadius: Dp,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
attachments.forEachIndexed { index, attachment ->
val isFirst = index == 0
val isLast = index == attachments.size - 1
when {
attachment.mimeType.startsWith("image/") -> {
ImageAttachmentBubble(
attachment = attachment,
isFirst = isFirst,
isLast = isLast,
hasTextBelow = hasTextBelow,
cornerRadius = cornerRadius
)
}
attachment.mimeType.startsWith("audio/") -> {
Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) {
AudioAttachmentBubble(attachment)
}
}
attachment.mimeType.startsWith("video/") -> {
Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) {
VideoAttachmentBubble(attachment)
}
}
else -> {
Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) {
DocumentAttachmentBubble(attachment)
}
}
}
}
}
}
if (answerText.isNotEmpty() || conversation.isStreaming) {
val displayAnswer = if (conversation.isStreaming) {
"$answerText ▮"
} else {
answerText
@Composable
fun ImageAttachmentBubble(
attachment: Attachment,
isFirst: Boolean,
isLast: Boolean,
hasTextBelow: Boolean,
cornerRadius: Dp
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
var bitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
var isLoading by remember { mutableStateOf(true) }
var imageLoaded by remember { mutableStateOf(false) }
LaunchedEffect(attachment) {
isLoading = true
imageLoaded = false
withContext(Dispatchers.IO) {
try {
val file = java.io.File(attachment.filePath)
if (file.exists()) {
val fileBytes = file.readBytes()
val decrypted = com.app.assistant.db.EncryptionUtil.decryptFile(fileBytes, attachment.iv)
val decoded = android.graphics.BitmapFactory.decodeByteArray(decrypted, 0, decrypted.size)
bitmap = decoded
if (decoded != null) {
imageLoaded = true
}
}
} catch (e: Exception) {
e.printStackTrace()
} finally {
isLoading = false
}
}
}
MarkdownText(
markdown = displayAnswer,
val topRounding = if (isFirst) cornerRadius else 0.dp
val bottomRounding = if (isLast && !hasTextBelow) cornerRadius else 0.dp
val imageShape = RoundedCornerShape(
topStart = topRounding,
topEnd = topRounding,
bottomStart = bottomRounding,
bottomEnd = bottomRounding
)
val imageAlpha by animateFloatAsState(
targetValue = if (imageLoaded) 1f else 0f,
animationSpec = tween(durationMillis = 400),
label = "imageFadeIn"
)
Box(
modifier = Modifier
.fillMaxWidth()
.clip(imageShape)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
.clickable {
coroutineScope.launch(Dispatchers.IO) {
try {
val fileBytes = java.io.File(attachment.filePath).readBytes()
val decrypted = com.app.assistant.db.EncryptionUtil.decryptFile(fileBytes, attachment.iv)
val ext = attachment.fileName.substringAfterLast('.', "jpg")
val temp = java.io.File(context.cacheDir, "view_${attachment.id}.$ext")
temp.writeBytes(decrypted)
val uri = androidx.core.content.FileProvider.getUriForFile(
context,
"com.app.assistant.fileprovider",
temp
)
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
setDataAndType(uri, "image/*")
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
android.widget.Toast.makeText(context, "Cannot open image: ${e.message}", android.widget.Toast.LENGTH_SHORT).show()
}
}
}
},
contentAlignment = Alignment.Center
) {
if (isLoading) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(240.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(36.dp),
color = MaterialTheme.colorScheme.primary,
strokeWidth = 3.dp
)
}
} else if (bitmap != null) {
Image(
bitmap = bitmap!!.asImageBitmap(),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(240.dp),
contentScale = ContentScale.Crop,
alpha = imageAlpha
)
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "Failed to load image",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
@Composable
fun AudioAttachmentBubble(attachment: Attachment) {
val context = LocalContext.current
var isPlaying by remember { mutableStateOf(false) }
var progress by remember { mutableStateOf(0f) }
var duration by remember { mutableStateOf(0) }
var currentPosition by remember { mutableStateOf(0) }
val mediaPlayer = remember { android.media.MediaPlayer() }
var tempFile by remember { mutableStateOf<java.io.File?>(null) }
val coroutineScope = rememberCoroutineScope()
androidx.compose.runtime.DisposableEffect(Unit) {
onDispose {
try {
if (mediaPlayer.isPlaying) {
mediaPlayer.stop()
}
mediaPlayer.release()
} catch (e: Exception) {}
tempFile?.delete()
}
}
LaunchedEffect(isPlaying) {
if (isPlaying) {
while (isPlaying) {
try {
currentPosition = mediaPlayer.currentPosition
progress = if (duration > 0) currentPosition.toFloat() / duration else 0f
} catch (e: Exception) {}
kotlinx.coroutines.delay(200)
}
}
}
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
) {
Row(
modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = {
if (isPlaying) {
mediaPlayer.pause()
isPlaying = false
} else {
if (tempFile == null) {
coroutineScope.launch(Dispatchers.IO) {
try {
val fileBytes = java.io.File(attachment.filePath).readBytes()
val decrypted = com.app.assistant.db.EncryptionUtil.decryptFile(fileBytes, attachment.iv)
val temp = java.io.File(context.cacheDir, "play_${attachment.id}.mp3")
temp.writeBytes(decrypted)
tempFile = temp
withContext(Dispatchers.Main) {
mediaPlayer.reset()
mediaPlayer.setDataSource(temp.absolutePath)
mediaPlayer.prepare()
duration = mediaPlayer.duration
mediaPlayer.start()
isPlaying = true
mediaPlayer.setOnCompletionListener {
isPlaying = false
progress = 0f
currentPosition = 0
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
} else {
mediaPlayer.start()
isPlaying = true
}
}
}) {
Icon(
painter = painterResource(id = if (isPlaying) R.drawable.ic_stop else R.drawable.ic_mic),
contentDescription = if (isPlaying) "Pause" else "Play",
tint = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = attachment.fileName,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
androidx.compose.material3.LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth().height(4.dp),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f)
)
}
}
}
}
@Composable
fun VideoAttachmentBubble(attachment: Attachment) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable {
coroutineScope.launch(Dispatchers.IO) {
try {
val fileBytes = java.io.File(attachment.filePath).readBytes()
val decrypted = com.app.assistant.db.EncryptionUtil.decryptFile(fileBytes, attachment.iv)
val ext = attachment.fileName.substringAfterLast('.', "mp4")
val temp = java.io.File(context.cacheDir, "play_${attachment.id}.$ext")
temp.writeBytes(decrypted)
val uri = androidx.core.content.FileProvider.getUriForFile(
context,
"com.app.assistant.fileprovider",
temp
)
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
setDataAndType(uri, "video/*")
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
android.widget.Toast.makeText(context, "Cannot play video: ${e.message}", android.widget.Toast.LENGTH_SHORT).show()
}
}
}
}
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.ic_stop),
contentDescription = "Play Video",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = attachment.fileName,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Tap to play video",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
}
}
@Composable
fun DocumentAttachmentBubble(attachment: Attachment) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable {
coroutineScope.launch(Dispatchers.IO) {
try {
val fileBytes = java.io.File(attachment.filePath).readBytes()
val decrypted = com.app.assistant.db.EncryptionUtil.decryptFile(fileBytes, attachment.iv)
val ext = attachment.fileName.substringAfterLast('.', "pdf")
val temp = java.io.File(context.cacheDir, "doc_${attachment.id}.$ext")
temp.writeBytes(decrypted)
val uri = androidx.core.content.FileProvider.getUriForFile(
context,
"com.app.assistant.fileprovider",
temp
)
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
setDataAndType(uri, attachment.mimeType)
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
android.widget.Toast.makeText(context, "Cannot open document: ${e.message}", android.widget.Toast.LENGTH_SHORT).show()
}
}
}
}
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.ic_translate),
contentDescription = "Open Document",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = attachment.fileName,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
val subText = if (attachment.mimeType.startsWith("text/") || attachment.mimeType == "application/json") {
"Text appended inline (Tap to open)"
} else {
"Tap to open document"
}
Text(
text = subText,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
}
}

View file

@ -25,6 +25,23 @@ import androidx.compose.ui.tooling.preview.Preview
import com.app.assistant.ui.theme.AssistantTheme
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.platform.SoftwareKeyboardController
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.IconButton
import androidx.compose.ui.graphics.Color
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
@Composable
@Suppress("ktlint:standard:function-naming")
@ -43,6 +60,13 @@ fun MyLayout(
onStartListening: (onResult: (String) -> Unit, onPartialResult: (String) -> Unit) -> Unit,
onProcessQuestion: (FocusManager, SoftwareKeyboardController, Boolean) -> Unit,
isTranslateEnabled: Boolean,
selectedAttachments: List<com.app.assistant.model.Attachment> = emptyList(),
onRemoveAttachment: (com.app.assistant.model.Attachment) -> Unit = {},
onAttachClick: (String) -> Unit = {},
isImageSupported: Boolean = false,
isAudioSupported: Boolean = false,
isVideoSupported: Boolean = false,
isDocumentSupported: Boolean = false,
) {
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
@ -96,6 +120,68 @@ fun MyLayout(
}
}
var isDismissed by remember(chatList.size, isImageSupported, isAudioSupported, isVideoSupported, isDocumentSupported) {
mutableStateOf(false)
}
val showWarning = remember(chatList.size, isImageSupported, isAudioSupported, isVideoSupported, isDocumentSupported) {
chatList.any { conversation ->
conversation.attachments.any { att ->
val isTextDoc = att.mimeType.startsWith("text/") || att.mimeType == "application/json"
if (isTextDoc) {
false
} else {
when {
att.mimeType.startsWith("image/") -> !isImageSupported
att.mimeType.startsWith("audio/") -> !isAudioSupported
att.mimeType.startsWith("video/") -> !isVideoSupported
att.mimeType.startsWith("application/pdf") -> !isDocumentSupported
else -> true
}
}
}
}
}
if (showWarning && !isDismissed) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.background(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
shape = RoundedCornerShape(12.dp)
)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = "Warning",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Text(
text = "Current model does not support some media in this chat. Incompatible past attachments will be ignored by the LLM.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
IconButton(
onClick = { isDismissed = true },
modifier = Modifier.size(20.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Dismiss warning",
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.size(16.dp)
)
}
}
}
if (keyboardController != null) {
UserInputField(
focusManager = focusManager,
@ -107,6 +193,13 @@ fun MyLayout(
onStopSpeaking = onStopSpeaking,
onStartListening = onStartListening,
onProcessQuestion = onProcessQuestion,
selectedAttachments = selectedAttachments,
onRemoveAttachment = onRemoveAttachment,
onAttachClick = onAttachClick,
isImageSupported = isImageSupported,
isAudioSupported = isAudioSupported,
isVideoSupported = isVideoSupported,
isDocumentSupported = isDocumentSupported,
)
}
}

View file

@ -664,6 +664,7 @@ fun VerifySection(
CapabilityRow("Vision / Image Support", verificationState.capabilities.hasImageInput)
CapabilityRow("Audio Support", verificationState.capabilities.hasAudioInput)
CapabilityRow("Video Support", verificationState.capabilities.hasVideoInput)
CapabilityRow("Document Support (PDF/TXT)", verificationState.capabilities.hasDocumentInput)
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(

View file

@ -4,52 +4,73 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.SoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.tooling.preview.Preview
import com.app.assistant.ui.theme.AssistantTheme
import androidx.compose.ui.unit.sp
import com.app.assistant.R
import com.app.assistant.model.Attachment
import com.app.assistant.ui.theme.AssistantTheme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.hypot
@Composable
@Suppress("ktlint:standard:function-naming")
fun UserInputField(
focusManager: FocusManager,
focusManager: androidx.compose.ui.focus.FocusManager,
keyboardController: SoftwareKeyboardController,
isSpeaking: Boolean,
isListening: Boolean,
@ -57,8 +78,15 @@ fun UserInputField(
onQuestionChange: (String) -> Unit,
onStopSpeaking: () -> Unit,
onStartListening: (onResult: (String) -> Unit, onPartialResult: (String) -> Unit) -> Unit,
onProcessQuestion: (FocusManager, SoftwareKeyboardController, Boolean) -> Unit,
onProcessQuestion: (androidx.compose.ui.focus.FocusManager, SoftwareKeyboardController, Boolean) -> Unit,
modifier: Modifier = Modifier,
selectedAttachments: List<Attachment> = emptyList(),
onRemoveAttachment: (Attachment) -> Unit = {},
onAttachClick: (String) -> Unit = {},
isImageSupported: Boolean = false,
isAudioSupported: Boolean = false,
isVideoSupported: Boolean = false,
isDocumentSupported: Boolean = false,
) {
val containerColor = TextFieldDefaults.colors().unfocusedContainerColor
val rippleColor = adjustContrast(containerColor)
@ -99,99 +127,274 @@ fun UserInputField(
}
}
Box(
modifier =
modifier
Column(
modifier = modifier
.fillMaxWidth()
.imePadding()
.padding(8.dp)
) {
if (selectedAttachments.isNotEmpty()) {
AttachmentPreviewList(
attachments = selectedAttachments,
onRemove = onRemoveAttachment,
modifier = Modifier.padding(bottom = 8.dp)
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.imePadding()
.padding(8.dp)
.clip(RoundedCornerShape(20)) // clip ripple inside
.onGloballyPositioned { coords -> textFieldSize = coords.size },
) {
TextField(
modifier =
Modifier
.fillMaxWidth(),
shape = RoundedCornerShape(20),
value = question,
placeholder = { Text(stringResource(id = R.string.type_here_placeholder)) },
maxLines = 2,
onValueChange = onQuestionChange,
colors =
TextFieldDefaults.colors(
) {
TextField(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20),
value = question,
placeholder = { Text(stringResource(id = R.string.type_here_placeholder)) },
maxLines = 4,
onValueChange = onQuestionChange,
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
),
trailingIcon = {
Row(horizontalArrangement = Arrangement.End) {
if (isSpeaking) {
leadingIcon = {
val showAttachmentButton = isImageSupported || isAudioSupported || isVideoSupported || isDocumentSupported
if (showAttachmentButton && !isSpeaking) {
var menuExpanded by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { menuExpanded = true }) {
Icon(
painter = painterResource(id = R.drawable.ic_attachment),
contentDescription = "Attach file",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }
) {
if (isImageSupported) {
DropdownMenuItem(
text = { Text("Image") },
onClick = {
menuExpanded = false
onAttachClick("image/*")
}
)
DropdownMenuItem(
text = { Text("Take Photo (Camera)") },
onClick = {
menuExpanded = false
onAttachClick("camera")
}
)
}
if (isAudioSupported) {
DropdownMenuItem(
text = { Text("Audio") },
onClick = {
menuExpanded = false
onAttachClick("audio/*")
}
)
}
if (isVideoSupported) {
DropdownMenuItem(
text = { Text("Video") },
onClick = {
menuExpanded = false
onAttachClick("video/*")
}
)
}
if (isDocumentSupported) {
DropdownMenuItem(
text = { Text("Document (PDF)") },
onClick = {
menuExpanded = false
onAttachClick("application/pdf")
}
)
}
// Always support text files as they are decrypted and appended inline as text
DropdownMenuItem(
text = { Text("Text Document (.txt/.csv/.json)") },
onClick = {
menuExpanded = false
onAttachClick("text/*")
}
)
}
}
}
},
trailingIcon = {
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
if (isSpeaking) {
IconButton(onClick = {
if (isSpeaking) {
onStopSpeaking()
}
}) {
Icon(
painter = painterResource(id = R.drawable.ic_stop),
contentDescription = stringResource(id = R.string.stop_desc),
)
}
} else {
IconButton(onClick = {
onStartListening(
{ recognizedText ->
onQuestionChange(recognizedText)
if (recognizedText.isNotEmpty()) {
onProcessQuestion(focusManager, keyboardController, true)
}
},
{ recognizedText ->
onQuestionChange(recognizedText)
},
)
}) {
Icon(
painter = painterResource(id = R.drawable.ic_mic),
contentDescription = stringResource(id = R.string.mic_desc),
)
}
}
IconButton(onClick = {
if (isSpeaking) {
onStopSpeaking()
if (question.isNotEmpty() || selectedAttachments.isNotEmpty()) {
onProcessQuestion(focusManager, keyboardController, false)
}
}) {
Icon(
painter = painterResource(id = R.drawable.ic_stop),
contentDescription = stringResource(id = R.string.stop_desc),
)
}
} else {
IconButton(onClick = {
onStartListening(
{ recognizedText ->
onQuestionChange(recognizedText)
if (recognizedText.isNotEmpty()) {
onProcessQuestion(
focusManager,
keyboardController,
true,
)
}
},
{ recognizedText ->
onQuestionChange(recognizedText)
},
)
}) {
Icon(
painter = painterResource(id = R.drawable.ic_mic),
contentDescription = stringResource(id = R.string.mic_desc),
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(id = R.string.send_desc),
)
}
}
IconButton(onClick = {
if (question.isNotEmpty()) {
onProcessQuestion(
focusManager,
keyboardController,
false,
)
}
}) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(id = R.string.send_desc),
)
}
}
},
)
},
)
Canvas(
modifier =
Modifier
Canvas(
modifier = Modifier
.matchParentSize()
.clip(RoundedCornerShape(20)),
) {
if (rippleAlpha.value > 0f) {
drawCircle(
color = rippleColor.copy(alpha = rippleAlpha.value),
radius = rippleRadius.value,
center = center,
) {
if (rippleAlpha.value > 0f) {
drawCircle(
color = rippleColor.copy(alpha = rippleAlpha.value),
radius = rippleRadius.value,
center = center,
)
}
}
}
}
}
@Composable
fun AttachmentPreviewList(
attachments: List<Attachment>,
onRemove: (Attachment) -> Unit,
modifier: Modifier = Modifier
) {
LazyRow(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(attachments) { attachment ->
AttachmentPreviewCard(attachment = attachment, onRemove = { onRemove(attachment) })
}
}
}
@Composable
fun AttachmentPreviewCard(
attachment: Attachment,
onRemove: () -> Unit
) {
Box(
modifier = Modifier
.size(72.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
if (attachment.mimeType.startsWith("image/")) {
var bitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
LaunchedEffect(attachment) {
withContext(Dispatchers.IO) {
try {
val fileBytes = java.io.File(attachment.filePath).readBytes()
val decrypted = com.app.assistant.db.EncryptionUtil.decryptFile(fileBytes, attachment.iv)
bitmap = android.graphics.BitmapFactory.decodeByteArray(decrypted, 0, decrypted.size)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
if (bitmap != null) {
Image(
bitmap = bitmap!!.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
}
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
val iconRes = when {
attachment.mimeType.startsWith("audio/") -> R.drawable.ic_mic
attachment.mimeType.startsWith("video/") -> R.drawable.ic_stop
else -> R.drawable.ic_translate
}
Icon(
painter = painterResource(id = iconRes),
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = attachment.fileName,
fontSize = 8.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
IconButton(
onClick = onRemove,
modifier = Modifier
.align(Alignment.TopEnd)
.size(18.dp)
.background(Color.Black.copy(alpha = 0.6f), shape = CircleShape)
.padding(2.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Remove",
tint = Color.White,
modifier = Modifier.size(12.dp)
)
}
}
}
@ -201,7 +404,6 @@ fun adjustContrast(
): Color {
val isDark = color.luminance() < 0.5
return if (isDark) {
// lighten in dark mode
Color(
red = (color.red + factor).coerceAtMost(1f),
green = (color.green + factor).coerceAtMost(1f),
@ -209,7 +411,6 @@ fun adjustContrast(
alpha = color.alpha,
)
} else {
// darken in light mode
Color(
red = (color.red - factor).coerceAtLeast(0f),
green = (color.green - factor).coerceAtLeast(0f),
@ -235,51 +436,8 @@ fun UserInputFieldIdlePreview() {
onQuestionChange = {},
onStopSpeaking = {},
onStartListening = { _, _ -> },
onProcessQuestion = { _, _, _ -> }
)
}
}
}
@Preview(showBackground = true)
@Composable
fun UserInputFieldSpeakingPreview() {
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
val keyboardController = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
AssistantTheme {
if (keyboardController != null) {
UserInputField(
focusManager = focusManager,
keyboardController = keyboardController,
isSpeaking = true,
isListening = false,
question = "This is a spoken response from the AI",
onQuestionChange = {},
onStopSpeaking = {},
onStartListening = { _, _ -> },
onProcessQuestion = { _, _, _ -> }
)
}
}
}
@Preview(showBackground = true)
@Composable
fun UserInputFieldListeningPreview() {
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
val keyboardController = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
AssistantTheme {
if (keyboardController != null) {
UserInputField(
focusManager = focusManager,
keyboardController = keyboardController,
isSpeaking = false,
isListening = true,
question = "Listening for your voice...",
onQuestionChange = {},
onStopSpeaking = {},
onStartListening = { _, _ -> },
onProcessQuestion = { _, _, _ -> }
onProcessQuestion = { _, _, _ -> },
isImageSupported = true
)
}
}

View file

@ -51,7 +51,8 @@ class GetAiResponseUseCase(
client = client,
apiKey = apiKey ?: "",
model = model,
config = config
config = config,
provider = provider
)
val systemContext = messages.find { it.role == "system" }?.content ?: ""
@ -92,7 +93,8 @@ class GetAiResponseUseCase(
client = client,
apiKey = apiKey ?: "",
model = model,
config = config
config = config,
provider = provider
)
val systemContext = messages.find { it.role == "system" }?.content ?: ""

View file

@ -2,12 +2,19 @@ package com.app.assistant.usecase
import com.app.assistant.llm.LlmMessage
import com.app.assistant.model.Conversation
import com.app.assistant.repository.SettingsRepository
import com.app.assistant.util.Category
import com.google.mediapipe.tasks.text.textclassifier.TextClassifierResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.collect
import java.util.Locale
class ProcessChatCommandUseCase(
private val getAiResponseUseCase: GetAiResponseUseCase
private val getAiResponseUseCase: GetAiResponseUseCase,
private val settingsRepository: SettingsRepository
) {
fun cleanAndPunctuate(input: String): String {
val trimmedInput = input.trim()
@ -60,33 +67,68 @@ class ProcessChatCommandUseCase(
suspend fun getAiChatResponse(
systemContext: String,
chatHistory: List<Conversation>
): String? {
val messages = mutableListOf<LlmMessage>()
messages.add(LlmMessage(role = "system", content = systemContext))
for (item in chatHistory) {
if (item.isLoading) continue
val role = if (item.isMe) "user" else "assistant"
messages.add(LlmMessage(role = role, content = item.text))
}
return getAiResponseUseCase.execute(messages)
): String? = withContext(Dispatchers.IO) {
val messages = mapConversationsToLlmMessages(systemContext, chatHistory)
getAiResponseUseCase.execute(messages)
}
fun getAiChatResponseStream(
systemContext: String,
chatHistory: List<Conversation>
): kotlinx.coroutines.flow.Flow<String> {
): kotlinx.coroutines.flow.Flow<String> = flow {
val messages = withContext(Dispatchers.IO) {
mapConversationsToLlmMessages(systemContext, chatHistory)
}
getAiResponseUseCase.executeStream(messages).collect {
emit(it)
}
}.flowOn(Dispatchers.IO)
private fun mapConversationsToLlmMessages(
systemContext: String,
chatHistory: List<Conversation>
): List<LlmMessage> {
val messages = mutableListOf<LlmMessage>()
messages.add(LlmMessage(role = "system", content = systemContext))
for (item in chatHistory) {
if (item.isLoading) continue
val role = if (item.isMe) "user" else "assistant"
messages.add(LlmMessage(role = role, content = item.text))
val llmAttachments = if (item.attachments.isNotEmpty()) {
val filtered = item.attachments.filter { att ->
if (att.mimeType.startsWith("text/") || att.mimeType == "application/json") {
false
} else {
when {
att.mimeType.startsWith("image/") -> settingsRepository.getIsImageSupported()
att.mimeType.startsWith("audio/") -> settingsRepository.getIsAudioSupported()
att.mimeType.startsWith("video/") -> settingsRepository.getIsVideoSupported()
att.mimeType.startsWith("application/pdf") -> settingsRepository.getIsDocumentSupported()
else -> false
}
}
}
if (filtered.isNotEmpty()) {
filtered.map { att ->
val file = java.io.File(att.filePath)
val base64 = if (file.exists()) {
val encryptedBytes = file.readBytes()
val decryptedBytes = com.app.assistant.db.EncryptionUtil.decryptFile(encryptedBytes, att.iv)
android.util.Base64.encodeToString(decryptedBytes, android.util.Base64.NO_WRAP)
} else {
""
}
com.app.assistant.llm.LlmAttachment(
base64Data = base64,
mimeType = att.mimeType,
fileName = att.fileName
)
}
} else null
} else null
messages.add(LlmMessage(role = role, content = item.text, attachments = llmAttachments))
}
return getAiResponseUseCase.executeStream(messages)
return messages
}
companion object {

View file

@ -11,6 +11,7 @@ import com.app.assistant.db.DynamicConversationRepository
import com.app.assistant.db.SyncStateList
import com.app.assistant.model.Conversation
import com.app.assistant.model.Group
import com.app.assistant.model.Attachment
import com.app.assistant.repository.SettingsRepository
import com.app.assistant.translation.TranslatorManager
import com.app.assistant.usecase.CallContactUseCase
@ -36,6 +37,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.lang.reflect.Modifier
import java.net.URI
import kotlin.coroutines.resume
@ -57,6 +59,190 @@ class MainViewModel(
private val _question = MutableStateFlow("")
val question: StateFlow<String> = _question.asStateFlow()
private val _selectedAttachments = MutableStateFlow<List<Attachment>>(emptyList())
val selectedAttachments: StateFlow<List<Attachment>> = _selectedAttachments.asStateFlow()
private fun compressImageIfNeeded(context: android.content.Context, uri: android.net.Uri): ByteArray? {
val mimeType = context.contentResolver.getType(uri) ?: return null
if (!mimeType.startsWith("image/")) {
return null
}
try {
// First read EXIF rotation
var rotation = 0
try {
context.contentResolver.openInputStream(uri)?.use { stream ->
val exif = android.media.ExifInterface(stream)
val orientation = exif.getAttributeInt(
android.media.ExifInterface.TAG_ORIENTATION,
android.media.ExifInterface.ORIENTATION_NORMAL
)
rotation = when (orientation) {
android.media.ExifInterface.ORIENTATION_ROTATE_90 -> 90
android.media.ExifInterface.ORIENTATION_ROTATE_180 -> 180
android.media.ExifInterface.ORIENTATION_ROTATE_270 -> 270
else -> 0
}
}
} catch (e: Exception) {
Log.e("MainViewModel", "Failed to parse EXIF orientation", e)
}
// Decode dimensions only
val options = android.graphics.BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
context.contentResolver.openInputStream(uri)?.use { stream ->
android.graphics.BitmapFactory.decodeStream(stream, null, options)
}
val width = options.outWidth
val height = options.outHeight
if (width <= 0 || height <= 0) return null
// Calculate sample size (approximate scaling down during decode)
val maxDim = 1200
var inSampleSize = 1
if (width > maxDim || height > maxDim) {
val halfWidth = width / 2
val halfHeight = height / 2
while ((halfWidth / inSampleSize) >= maxDim || (halfHeight / inSampleSize) >= maxDim) {
inSampleSize *= 2
}
}
// Decode full image with sample size
val decodeOptions = android.graphics.BitmapFactory.Options().apply {
this.inSampleSize = inSampleSize
}
val bitmap = context.contentResolver.openInputStream(uri)?.use { stream ->
android.graphics.BitmapFactory.decodeStream(stream, null, decodeOptions)
} ?: return null
// Precise scaling to max 1200px
val currentWidth = bitmap.width
val currentHeight = bitmap.height
val scaledBitmap = if (currentWidth > maxDim || currentHeight > maxDim) {
val ratio = currentWidth.toFloat() / currentHeight.toFloat()
val (newWidth, newHeight) = if (currentWidth > currentHeight) {
maxDim to (maxDim / ratio).toInt()
} else {
(maxDim * ratio).toInt() to maxDim
}
android.graphics.Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true).also {
if (it != bitmap) {
bitmap.recycle()
}
}
} else {
bitmap
}
// Correct EXIF rotation
val rotatedBitmap = if (rotation != 0) {
val matrix = android.graphics.Matrix().apply { postRotate(rotation.toFloat()) }
android.graphics.Bitmap.createBitmap(
scaledBitmap, 0, 0, scaledBitmap.width, scaledBitmap.height, matrix, true
).also {
if (it != scaledBitmap) {
scaledBitmap.recycle()
}
}
} else {
scaledBitmap
}
// Compress to JPEG 80%
val outputStream = java.io.ByteArrayOutputStream()
rotatedBitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 80, outputStream)
val compressedBytes = outputStream.toByteArray()
rotatedBitmap.recycle()
return compressedBytes
} catch (e: Exception) {
Log.e("MainViewModel", "Error compressing image", e)
return null
}
}
fun addSelectedAttachment(uri: android.net.Uri) {
viewModelScope.launch(Dispatchers.IO) {
val context = getApplication<Application>().applicationContext
val contentResolver = context.contentResolver
var fileName = "file_${System.currentTimeMillis()}"
var mimeType = contentResolver.getType(uri) ?: "*/*"
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (nameIndex != -1 && cursor.moveToFirst()) {
fileName = cursor.getString(nameIndex)
}
}
try {
val isImage = mimeType.startsWith("image/") ||
fileName.endsWith(".jpg", ignoreCase = true) ||
fileName.endsWith(".jpeg", ignoreCase = true) ||
fileName.endsWith(".png", ignoreCase = true) ||
fileName.endsWith(".webp", ignoreCase = true)
var fileBytes: ByteArray? = null
if (isImage) {
fileBytes = compressImageIfNeeded(context, uri)
if (fileBytes != null) {
mimeType = "image/jpeg"
if (!fileName.lowercase().endsWith(".jpg") && !fileName.lowercase().endsWith(".jpeg")) {
val dotIndex = fileName.lastIndexOf('.')
fileName = if (dotIndex != -1) {
"${fileName.substring(0, dotIndex)}.jpg"
} else {
"$fileName.jpg"
}
}
}
}
if (fileBytes == null) {
val inputStream = contentResolver.openInputStream(uri) ?: return@launch
fileBytes = inputStream.readBytes()
inputStream.close()
}
val iv = com.app.assistant.db.EncryptionUtil.generateIV()
val encryptedBytes = com.app.assistant.db.EncryptionUtil.encryptFile(fileBytes, iv)
val attachmentsDir = java.io.File(context.filesDir, "attachments")
if (!attachmentsDir.exists()) {
attachmentsDir.mkdirs()
}
val targetFile = java.io.File(attachmentsDir, "file_${System.currentTimeMillis()}_${java.util.UUID.randomUUID()}.enc")
targetFile.writeBytes(encryptedBytes)
val attachment = Attachment(
filePath = targetFile.absolutePath,
mimeType = mimeType,
fileName = fileName,
iv = iv
)
_selectedAttachments.value = _selectedAttachments.value + attachment
} catch (e: Exception) {
Log.e("MainViewModel", "Failed to add attachment", e)
}
}
}
fun removeSelectedAttachment(attachment: Attachment) {
viewModelScope.launch(Dispatchers.IO) {
try {
val file = java.io.File(attachment.filePath)
if (file.exists()) {
file.delete()
}
} catch (e: Exception) {
e.printStackTrace()
}
_selectedAttachments.value = _selectedAttachments.value - attachment
}
}
fun setQuestion(text: String) {
_question.value = text
}
@ -268,13 +454,37 @@ class MainViewModel(
speak: Boolean = false,
) {
val originalQuestion = question.value
val currentAttachments = _selectedAttachments.value
_selectedAttachments.value = emptyList()
focusManager?.clearFocus()
keyboardController?.hide()
setQuestion("")
viewModelScope.launch {
val itemId: Long
val newItem = Conversation(text = originalQuestion, isMe = true)
val processedQuestion = processChatCommandUseCase.cleanAndPunctuate(originalQuestion)
val finalQuestionText = withContext(Dispatchers.IO) {
val finalQuestionBuilder = java.lang.StringBuilder(processedQuestion)
currentAttachments.forEach { att ->
if (att.mimeType.startsWith("text/") || att.mimeType == "application/json") {
try {
val file = java.io.File(att.filePath)
if (file.exists()) {
val encryptedBytes = file.readBytes()
val decryptedBytes = com.app.assistant.db.EncryptionUtil.decryptFile(encryptedBytes, att.iv)
val textContent = String(decryptedBytes, Charsets.UTF_8)
finalQuestionBuilder.append("\n\n[Attached File: ${att.fileName}]\n---\n$textContent\n---\n")
}
} catch (e: java.lang.Exception) {
Log.e("MainViewModel", "Failed to extract text file content", e)
}
}
}
finalQuestionBuilder.toString()
}
val newItem = Conversation(text = finalQuestionText, isMe = true, attachments = currentAttachments)
chatList.add(newItem)
if (_isCustomUIHalfPage.value) {
@ -287,12 +497,6 @@ class MainViewModel(
chatList.add(loadingItem)
val loadingItemId = loadingItem.id
val processedQuestion = processChatCommandUseCase.cleanAndPunctuate(originalQuestion)
chatList.indexOfFirst { it.id == itemId }.takeIf { it != -1 }?.let { index ->
val updatedItem = chatList[index].copy(text = processedQuestion)
chatList.set(index, updatedItem)
}
if (lockState != LockState.None && processChatCommandUseCase.isNegativeOrNotRequired(processedQuestion)) {
lockState = LockState.None
classifierHelper.classify(processedQuestion, itemId, loadingItemId, speak)

View file

@ -42,7 +42,7 @@ class MainViewModelFactory(
val getWeatherUseCase = GetWeatherUseCase(permissionChecker, weatherRepo, getAiResponseUseCase)
val setAlarmUseCase = SetAlarmUseCase(resourceProvider)
val setReminderUseCase = SetReminderUseCase(resourceProvider)
val processChatCommandUseCase = ProcessChatCommandUseCase(getAiResponseUseCase)
val processChatCommandUseCase = ProcessChatCommandUseCase(getAiResponseUseCase, settingsRepo)
@Suppress("UNCHECKED_CAST")
return MainViewModel(

View file

@ -154,19 +154,22 @@ class SettingsViewModel(
)
// 3. Audio/Video checks (currently Gemini 1.5/2.0 is the main provider that supports audio/video input natively in the API)
val hasAudio = provider == LlmProvider.GEMINI && (model.contains("1.5") || model.contains("2.0"))
val hasVideo = provider == LlmProvider.GEMINI && (model.contains("1.5") || model.contains("2.0"))
val hasAudio = provider == LlmProvider.GEMINI && (model.contains("1.5") || model.contains("2.0") || model.contains("2.5"))
val hasVideo = provider == LlmProvider.GEMINI && (model.contains("1.5") || model.contains("2.0") || model.contains("2.5"))
val hasDoc = (provider == LlmProvider.GEMINI && (model.contains("1.5") || model.contains("2.0") || model.contains("2.5"))) || (provider == LlmProvider.ANTHROPIC && model.contains("claude-3"))
val capabilities = ModelCapabilities(
hasImageInput = isVisionOk,
hasAudioInput = hasAudio,
hasVideoInput = hasVideo
hasVideoInput = hasVideo,
hasDocumentInput = hasDoc
)
// Save detected capabilities in repository
settingsRepository.setIsImageSupported(isVisionOk)
settingsRepository.setIsAudioSupported(hasAudio)
settingsRepository.setIsVideoSupported(hasVideo)
settingsRepository.setIsDocumentSupported(hasDoc)
settingsRepository.setIsModelVerified(true)
_verificationState.value = VerificationState.Success(capabilities)

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/black"
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</vector>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="temp_files" path="." />
</paths>