mirror of
https://github.com/souravanand001/ai-assistant-android.git
synced 2026-10-03 12:46:09 +00:00
added attachment option and capabilities.
This commit is contained in:
parent
70c0e575e2
commit
8aec5fb91c
25 changed files with 1549 additions and 278 deletions
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
39
app/src/main/java/com/app/assistant/db/AttachmentEntity.kt
Normal file
39
app/src/main/java/com/app/assistant/db/AttachmentEntity.kt
Normal 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
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
app/src/main/java/com/app/assistant/model/Attachment.kt
Normal file
11
app/src/main/java/com/app/assistant/model/Attachment.kt
Normal 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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ?: ""
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
10
app/src/main/res/drawable/ic_attachment.xml
Normal file
10
app/src/main/res/drawable/ic_attachment.xml
Normal 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>
|
||||
4
app/src/main/res/xml/file_paths.xml
Normal file
4
app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue