Refactor step 4 migrated to Room DB

This commit is contained in:
souravanand001 2026-05-30 21:56:13 +05:30
parent 3ba51c50ff
commit 8724ad25b7
15 changed files with 482 additions and 442 deletions

View file

@ -1,115 +1,134 @@
import java.util.Properties
import java.io.FileInputStream
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.jetbrains.kotlin.serialization)
}
android {
namespace = "com.app.assistant"
compileSdk = 35
defaultConfig {
applicationId = "com.app.assistant"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
// Read local.properties
val localProperties = Properties()
val localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
localProperties.load(FileInputStream(localPropertiesFile))
}
// Define BuildConfig fields for API keys
// Make sure YOUTUBE_API_KEY and GROQ_API_KEY exist in your local.properties
buildConfigField("String", "YOUTUBE_API_KEY",
"\"${localProperties.getProperty("YOUTUBE_API_KEY") ?: ""}\"" // Add quotes
)
buildConfigField("String", "GROQ_API_KEY",
"\"${localProperties.getProperty("GROQ_API_KEY") ?: ""}\"" // Add quotes
)
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
// You might want to also define buildConfigFields for release
// if you have different keys for release builds, or if local.properties is not available in CI
}
debug {
// BuildConfigFields from defaultConfig are inherited.
// Add specific debug configurations here if needed, for example:
// isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
buildConfig = true // Ensure buildConfig is enabled
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.androidxComposeCompiler.get()
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material3.window.size)
implementation(libs.androidx.material3.adaptive.navigation.suite)
implementation(libs.okhttp)
implementation(libs.kotlinx.serialization.json)
implementation(libs.mlkit.translate)
implementation(libs.commonmark)
//implementation ("com.github.jeziellago:compose-markdown:0.5.4")
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.kotlin.reflect)
implementation(libs.play.services.location)
implementation(libs.androidx.ui.test.android)
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.runtime.saveable)
testImplementation(libs.junit)
// androidTestImplementation(libs.androidx.junit)
// androidTestImplementation(libs.androidx.espresso.core)
// androidTestImplementation(platform(libs.androidx.compose.bom))
// androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
//MediaPipe library
implementation(libs.mediapipe.tasks.text)
}
import java.util.Properties
import java.io.FileInputStream
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.jetbrains.kotlin.serialization)
kotlin("kapt")
}
android {
namespace = "com.app.assistant"
compileSdk = 35
defaultConfig {
applicationId = "com.app.assistant"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
// Read local.properties
val localProperties = Properties()
val localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
localProperties.load(FileInputStream(localPropertiesFile))
}
// Define BuildConfig fields for API keys
// Make sure YOUTUBE_API_KEY and GROQ_API_KEY exist in your local.properties
buildConfigField("String", "YOUTUBE_API_KEY",
"\"${localProperties.getProperty("YOUTUBE_API_KEY") ?: ""}\"" // Add quotes
)
buildConfigField("String", "GROQ_API_KEY",
"\"${localProperties.getProperty("GROQ_API_KEY") ?: ""}\"" // Add quotes
)
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
// You might want to also define buildConfigFields for release
// if you have different keys for release builds, or if local.properties is not available in CI
}
debug {
// BuildConfigFields from defaultConfig are inherited.
// Add specific debug configurations here if needed, for example:
// isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
buildConfig = true // Ensure buildConfig is enabled
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.androidxComposeCompiler.get()
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material3.window.size)
implementation(libs.androidx.material3.adaptive.navigation.suite)
implementation(libs.okhttp)
constraints {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") {
because("Kotlin 1.9.0 compatibility")
}
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.3") {
because("Kotlin 1.9.0 compatibility")
}
}
implementation(libs.kotlinx.serialization.json)
implementation(libs.mlkit.translate)
implementation(libs.commonmark)
//implementation ("com.github.jeziellago:compose-markdown:0.5.4")
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
kapt(libs.androidx.room.compiler)
implementation(libs.play.services.location)
implementation(libs.androidx.ui.test.android)
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.runtime.saveable)
testImplementation(libs.junit)
// androidTestImplementation(libs.androidx.junit)
// androidTestImplementation(libs.androidx.espresso.core)
// androidTestImplementation(platform(libs.androidx.compose.bom))
// androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
//MediaPipe library
implementation(libs.mediapipe.tasks.text)
}
configurations.all {
resolutionStrategy {
force("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
force("org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.3")
force("org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.6.3")
}
}

View file

@ -0,0 +1,32 @@
package com.app.assistant.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@Database(entities = [GroupEntity::class, MessageEntity::class], version = 1, exportSchema = false)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun conversationDao(): ConversationDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"conversation.db"
)
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
instance
}
}
}
}

View file

@ -0,0 +1,34 @@
package com.app.assistant.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
@Dao
interface ConversationDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertGroup(group: GroupEntity): Long
@Query("SELECT * FROM `groups` ORDER BY group_id DESC")
suspend fun getAllGroups(): List<GroupEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMessage(message: MessageEntity)
@Query("DELETE FROM messages WHERE id = :messageId")
suspend fun deleteMessageById(messageId: Long)
@Update
suspend fun updateMessage(message: MessageEntity)
@Query("DELETE FROM messages WHERE id IN (:ids)")
suspend fun deleteMessagesByIds(ids: List<Long>)
@Query("DELETE FROM `groups` WHERE group_id = :groupId")
suspend fun deleteGroupById(groupId: Long)
@Query("SELECT * FROM messages WHERE group_id = :groupId ORDER BY id ASC")
suspend fun getMessagesForGroup(groupId: Long): List<MessageEntity>
}

View file

@ -0,0 +1,16 @@
package com.app.assistant.db
import androidx.room.TypeConverter
import java.net.URI
class Converters {
@TypeConverter
fun fromString(value: String?): URI? {
return value?.let { URI(it) }
}
@TypeConverter
fun uriToString(uri: URI?): String? {
return uri?.toString()
}
}

View file

@ -1,82 +0,0 @@
package com.app.assistant.db
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import com.app.assistant.model.Conversation
import kotlin.reflect.full.memberProperties
class DynamicConversationDbHelper(
context: Context,
) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
companion object {
private const val DATABASE_NAME = "conversation.db"
private const val DATABASE_VERSION = 1
private const val COLUMN_IV = "iv"
// Groups Table
private const val TABLE_GROUPS = "`groups`"
private const val COLUMN_GROUP_ID = "group_id"
private const val COLUMN_TITLE = "title"
private const val COLUMN_CREATED_AT = "created_at"
// Messages Table
private const val TABLE_MESSAGES = "messages"
}
override fun onCreate(db: SQLiteDatabase?) {
val createGroupsTableQuery =
"""
CREATE TABLE $TABLE_GROUPS (
$COLUMN_GROUP_ID INTEGER PRIMARY KEY AUTOINCREMENT,
$COLUMN_TITLE TEXT,
$COLUMN_CREATED_AT INTEGER
)
""".trimIndent()
db?.execSQL(createGroupsTableQuery)
val createMessageTableQuery = buildCreateTableQuery()
db?.execSQL(createMessageTableQuery)
}
override fun onUpgrade(
db: SQLiteDatabase?,
oldVersion: Int,
newVersion: Int,
) {
db?.execSQL("DROP TABLE IF EXISTS $TABLE_MESSAGES")
db?.execSQL("DROP TABLE IF EXISTS $TABLE_GROUPS")
onCreate(db)
}
private fun buildCreateTableQuery(): String {
val fields = Conversation::class.memberProperties
val columns =
fields.filterNot { it.name == "isLoading" }.joinToString(", ") { field ->
val fieldType = mapFieldTypeToSQLiteType(field.returnType.toString())
if (field.name == "id") {
"${field.name} INTEGER PRIMARY KEY"
} else {
"${field.name} $fieldType"
}
}
return """
CREATE TABLE $TABLE_MESSAGES (
$columns,
iv TEXT,
$COLUMN_GROUP_ID INTEGER,
FOREIGN KEY ($COLUMN_GROUP_ID) REFERENCES $TABLE_GROUPS($COLUMN_GROUP_ID)
)
""".trimIndent()
}
private fun mapFieldTypeToSQLiteType(kotlinType: String): String =
when (kotlinType) {
"kotlin.String" -> "TEXT"
"kotlin.Int", "kotlin.Long", "kotlin.Boolean" -> "INTEGER"
"java.net.URI" -> "TEXT"
else -> "TEXT"
}
}

View file

@ -1,48 +1,28 @@
package com.app.assistant.db
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.text.format.DateFormat
import android.util.Log
import androidx.compose.runtime.snapshots.SnapshotStateList
import com.app.assistant.model.Conversation
import com.app.assistant.model.Group
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URI
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.reflect.full.memberProperties
import kotlin.reflect.full.primaryConstructor
class DynamicConversationRepository(
private val context: Context,
context: Context,
) {
companion object {
private const val TABLE_GROUPS = "groups"
private const val COLUMN_GROUP_ID = "group_id"
private const val COLUMN_TITLE = "title"
private const val COLUMN_CREATED_AT = "created_at"
private const val TABLE_MESSAGES = "messages"
private const val COLUMN_MESSAGE_ID = "id"
}
private val dbHelper = DynamicConversationDbHelper(context)
private val db = AppDatabase.getDatabase(context)
private val dao = db.conversationDao()
var currentGroupId: Long = -1L
private suspend fun startNewChat(msg: String): Long {
withContext(Dispatchers.IO) {
val db = dbHelper.writableDatabase
val contentValues =
ContentValues().apply {
put(COLUMN_TITLE, generateChatTitle(msg))
put(COLUMN_CREATED_AT, System.nanoTime())
}
currentGroupId = db.insert(TABLE_GROUPS, null, contentValues)
return withContext(Dispatchers.IO) {
val title = generateChatTitle(msg)
val group = GroupEntity(title = title, createdAt = System.nanoTime())
currentGroupId = dao.insertGroup(group)
currentGroupId
}
return currentGroupId
}
private fun generateChatTitle(msg: String): String {
@ -58,30 +38,25 @@ class DynamicConversationRepository(
currentGroupId = startNewChat(conversation.englishText.takeIf { it.isNotEmpty() } ?: conversation.translatedText)
}
val db = dbHelper.writableDatabase
val (encryptedMap, iv) = encryptConversation(conversation)
val values =
ContentValues().apply {
encryptedMap.forEach { (key, value) -> put(key, value) }
put("iv", iv)
put(COLUMN_GROUP_ID, currentGroupId)
}
try {
db.insert(TABLE_MESSAGES, null, values)
} catch (e: Exception) {
e.message?.let { Log.d("Test", it) }
}
val iv = EncryptionUtil.generateIV()
val messageEntity = MessageEntity(
id = conversation.id,
englishText = conversation.englishText,
translatedText = conversation.translatedText,
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) {
val db = dbHelper.writableDatabase
db.delete(
TABLE_MESSAGES,
"id = ?",
arrayOf(id.toString()),
)
dao.deleteMessageById(id)
}
}
@ -90,130 +65,53 @@ class DynamicConversationRepository(
newConversation: Conversation,
) {
withContext(Dispatchers.IO) {
val db = dbHelper.writableDatabase
val (encryptedMap, iv) = encryptConversation(newConversation)
val values =
ContentValues().apply {
encryptedMap.forEach { (key, value) -> put(key, value) }
put("iv", iv)
}
db.update(
TABLE_MESSAGES,
values,
"id = ?",
arrayOf(oldConversation.id.toString()),
val iv = EncryptionUtil.generateIV()
val messageEntity = MessageEntity(
id = newConversation.id,
englishText = newConversation.englishText,
translatedText = newConversation.translatedText,
isMe = newConversation.isMe,
category = newConversation.category,
contentURL = newConversation.contentURL,
navigationURI = newConversation.navigationURI,
iv = iv,
groupId = currentGroupId
)
dao.updateMessage(messageEntity)
}
}
suspend fun clearMessages(conversations: List<Conversation>) {
withContext(Dispatchers.IO) {
val db = dbHelper.writableDatabase
val ids = conversations.joinToString(",") { it.id.toString() }
db.execSQL("DELETE FROM $TABLE_MESSAGES WHERE id IN ($ids)")
db.execSQL("DELETE FROM $TABLE_GROUPS WHERE $COLUMN_GROUP_ID == $currentGroupId")
val ids = conversations.map { it.id }
dao.deleteMessagesByIds(ids)
dao.deleteGroupById(currentGroupId)
currentGroupId = -1L
}
}
suspend fun loadAllGroups(): MutableList<Group> {
val groupList = mutableListOf<Group>()
withContext(Dispatchers.IO) {
val db = dbHelper.readableDatabase
val cursor: Cursor =
db.query(
TABLE_GROUPS,
arrayOf(COLUMN_GROUP_ID, COLUMN_TITLE),
null,
null,
null,
null,
"$COLUMN_GROUP_ID DESC", // Sort by latest created
)
while (cursor.moveToNext()) {
val groupId = cursor.getLong(cursor.getColumnIndexOrThrow(COLUMN_GROUP_ID))
val title = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_TITLE))
groupList.add(Group(groupId, title))
}
cursor.close()
return withContext(Dispatchers.IO) {
dao.getAllGroups().map { entity ->
Group(groupId = entity.groupId, title = entity.title)
}.toMutableList()
}
return groupList
}
suspend fun loadMessagesForGroup(groupId: Long): MutableList<Conversation> {
val tempList = mutableListOf<Conversation>()
withContext(Dispatchers.IO) {
val db = dbHelper.readableDatabase
val cursor: Cursor =
db.query(
TABLE_MESSAGES,
null,
"$COLUMN_GROUP_ID = ?",
arrayOf(groupId.toString()),
null,
null,
COLUMN_MESSAGE_ID, // Sort by latest created
return withContext(Dispatchers.IO) {
dao.getMessagesForGroup(groupId).map { entity ->
Conversation(
id = entity.id,
englishText = entity.englishText,
translatedText = entity.translatedText,
isMe = entity.isMe,
isLoading = false,
category = entity.category,
contentURL = entity.contentURL,
navigationURI = entity.navigationURI
)
while (cursor.moveToNext()) {
val conversation = cursorToConversation(cursor)
tempList.add(conversation)
}
cursor.close()
}.toMutableList()
}
return tempList
}
private fun cursorToConversation(cursor: Cursor): Conversation {
val properties = Conversation::class.memberProperties
val fieldMap = mutableMapOf<String, Any?>()
val iv = cursor.getString(cursor.getColumnIndexOrThrow("iv"))
properties.filter { it.name != "isLoading" }.forEach { property ->
val columnName = property.name
val columnIndex = cursor.getColumnIndexOrThrow(columnName)
val value =
when (property.returnType.toString()) {
"kotlin.String" -> cursor.getString(columnIndex)
// ?.let { EncryptionUtil.decrypt(it, iv) }
"kotlin.Int" -> cursor.getInt(columnIndex)
"kotlin.Long" -> cursor.getLong(columnIndex)
"kotlin.Boolean" -> cursor.getString(columnIndex).toBooleanStrictOrNull() ?: false
"java.net.URI" -> URI(cursor.getString(columnIndex))
else -> null
}
fieldMap[columnName] = value
}
return Conversation::class.constructors.first().callBy(
Conversation::class
.primaryConstructor!!
.parameters
.filter { it.name != "isLoading" } // Ignore "isLoading"
.associateWith { parameter -> fieldMap[parameter.name] },
)
}
private fun encryptConversation(conversation: Conversation): Pair<Map<String, String>, String> {
val properties = Conversation::class.memberProperties
val encryptedMap = mutableMapOf<String, String>()
val iv = EncryptionUtil.generateIV()
properties.forEach { property ->
val key = property.name
val value = property.getter.call(conversation)?.toString()
if (value != null && key != "isLoading") {
// encryptedMap[key] = EncryptionUtil.encrypt(value, iv).first
encryptedMap[key] = value
}
}
return Pair(encryptedMap, iv)
}
}

View file

@ -0,0 +1,18 @@
package com.app.assistant.db
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "groups")
data class GroupEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "group_id")
val groupId: Long = 0,
@ColumnInfo(name = "title")
val title: String,
@ColumnInfo(name = "created_at")
val createdAt: Long
)

View file

@ -0,0 +1,49 @@
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
import java.net.URI
@Entity(
tableName = "messages",
foreignKeys = [
ForeignKey(
entity = GroupEntity::class,
parentColumns = ["group_id"],
childColumns = ["group_id"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index(value = ["group_id"])]
)
data class MessageEntity(
@PrimaryKey
val id: Long,
@ColumnInfo(name = "englishText")
val englishText: String,
@ColumnInfo(name = "translatedText")
val translatedText: String,
@ColumnInfo(name = "isMe")
val isMe: Boolean,
@ColumnInfo(name = "category")
val category: String,
@ColumnInfo(name = "contentURL")
val contentURL: String,
@ColumnInfo(name = "navigationURI")
val navigationURI: URI,
@ColumnInfo(name = "iv")
val iv: String,
@ColumnInfo(name = "group_id")
val groupId: Long
)

View file

@ -6,21 +6,34 @@ import android.util.Log
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.Call
import okhttp3.Callback
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.json.JSONException
import org.json.JSONObject
import java.io.IOException
import java.util.Locale
@Serializable
data class GeocodingResult(
val name: String,
val latitude: Double,
val longitude: Double
)
@Serializable
data class GeocodingResponse(
val results: List<GeocodingResult>? = null
)
class WeatherRepository(
private val context: Context,
) {
private val client = OkHttpClient()
private val json = Json { ignoreUnknownKeys = true }
fun extractPlaceName(inputText: String): String {
// Pattern to match place names after "in" or "for", excluding trailing words like "today", "tomorrow", etc.
@ -98,32 +111,17 @@ class WeatherRepository(
cityName: String,
): Pair<Double, Double>? {
if (body.isEmpty()) return null
val jsonObject = JSONObject(body)
var latitude: Double? = null
var longitude: Double? = null
try {
val results = jsonObject.getJSONArray("results")
val response = json.decodeFromString<GeocodingResponse>(body)
val results = response.results ?: return null
if (results.isEmpty()) return null
for (i in 0 until results.length()) {
val result = results.getJSONObject(i)
if (result.getString("name").equals(cityName, ignoreCase = true)) {
latitude = result.getDouble("latitude")
longitude = result.getDouble("longitude")
break
}
}
if ((latitude == null || longitude == null) && results.length() > 0) {
val firstResult = results.getJSONObject(0)
latitude = firstResult.getDouble("latitude")
longitude = firstResult.getDouble("longitude")
}
val matchedResult = results.find { it.name.equals(cityName, ignoreCase = true) } ?: results[0]
return Pair(matchedResult.latitude, matchedResult.longitude)
} catch (e: Exception) {
e.message?.let { Log.d("extractCoordinates Excepetion", it) }
Log.d("extractCoordinates Exception", e.message ?: "")
}
return if (latitude != null && longitude != null) Pair(latitude, longitude) else null
return null
}
fun getCityNameFromLocation(
@ -147,8 +145,8 @@ class WeatherRepository(
suspend fun getWeatherData(
latitude: Double,
longitude: Double,
): JSONObject? {
val deferredResult = CompletableDeferred<JSONObject?>()
): String? {
val deferredResult = CompletableDeferred<String?>()
val urlBuilder =
"https://api.open-meteo.com/v1/forecast"
@ -184,12 +182,7 @@ class WeatherRepository(
) {
if (response.isSuccessful) {
val body = response.body?.string() ?: ""
try {
deferredResult.complete(JSONObject(body))
} catch (e: JSONException) {
Log.e("WeatherRepository", "Error parsing JSON: $e")
deferredResult.complete(null)
}
deferredResult.complete(body)
} else {
Log.e("WeatherRepository", "Weather data request unsuccessful: ${response.code}")
deferredResult.complete(null)

View file

@ -5,16 +5,42 @@ import com.app.assistant.BuildConfig
import com.app.assistant.repository.SettingsRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.net.UnknownHostException
import java.util.concurrent.TimeUnit
@Serializable
data class GroqMessage(
val role: String,
val content: String
)
@Serializable
data class GroqRequest(
val model: String,
val messages: List<GroqMessage>,
val temperature: Double = 1.0,
val top_p: Double = 1.0,
val stop: String? = null
)
@Serializable
data class GroqResponse(
val choices: List<GroqChoice>
)
@Serializable
data class GroqChoice(
val message: GroqMessage
)
class GetAiResponseUseCase(
private val settingsRepository: SettingsRepository
) {
@ -24,16 +50,18 @@ class GetAiResponseUseCase(
.writeTimeout(60, TimeUnit.SECONDS)
.build()
suspend fun execute(messagesArray: JSONArray): String? {
val requestBodyJson = JSONObject().apply {
put("model", "llama-3.3-70b-versatile")
put("messages", messagesArray)
put("temperature", 1)
put("top_p", 1)
put("stop", null as Any?)
}
private val json = Json { ignoreUnknownKeys = true }
val requestBody = requestBodyJson.toString()
suspend fun execute(messages: List<GroqMessage>): String? {
val requestObj = GroqRequest(
model = "llama-3.3-70b-versatile",
messages = messages,
temperature = 1.0,
top_p = 1.0,
stop = null
)
val requestBody = json.encodeToString(requestObj)
.toRequestBody("application/json".toMediaTypeOrNull())
val chatKey = loadChatKey()
@ -69,12 +97,9 @@ class GetAiResponseUseCase(
fun extractFromAI(response: String): String? {
try {
if (response.isNotEmpty()) {
val jsonObject = JSONObject(response)
val choicesArray = jsonObject.getJSONArray("choices")
if (choicesArray.length() > 0) {
val choiceObject = choicesArray.getJSONObject(0)
val messageObject = choiceObject.getJSONObject("message")
var content = messageObject.getString("content")
val responseObj = json.decodeFromString<GroqResponse>(response)
if (responseObj.choices.isNotEmpty()) {
var content = responseObj.choices[0].message.content
// Regex to remove <think> tags and the content inside
val thinkTagRegex = Regex("<think>.*?</think>", RegexOption.DOT_MATCHES_ALL)

View file

@ -6,8 +6,9 @@ import android.content.pm.PackageManager
import android.util.Log
import androidx.core.content.ContextCompat
import com.app.assistant.repository.WeatherRepository
import org.json.JSONArray
import org.json.JSONObject
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
@ -32,10 +33,11 @@ class GetWeatherUseCase(
val (lat, long) = coordinates
val weatherData = weatherRepository.getWeatherData(lat, long)
if (weatherData != null) {
weatherData.remove("latitude")
weatherData.remove("longitude")
val weatherJson = Json.parseToJsonElement(weatherData).jsonObject
val filteredMap = weatherJson.filterKeys { it != "latitude" && it != "longitude" }
val filteredJson = JsonObject(filteredMap)
val aiResponse = queryWeatherAI(prompt, weatherData.toString())
val aiResponse = queryWeatherAI(prompt, filteredJson.toString())
if (!aiResponse.isNullOrEmpty()) {
onSuccess(aiResponse, location)
} else {
@ -75,10 +77,11 @@ class GetWeatherUseCase(
val city = weatherRepository.getCityNameFromLocation(lat, long) ?: "your location"
val weatherData = weatherRepository.getWeatherData(lat, long)
if (weatherData != null) {
weatherData.remove("latitude")
weatherData.remove("longitude")
val weatherJson = Json.parseToJsonElement(weatherData).jsonObject
val filteredMap = weatherJson.filterKeys { it != "latitude" && it != "longitude" }
val filteredJson = JsonObject(filteredMap)
val aiResponse = queryWeatherAI(prompt, weatherData.toString())
val aiResponse = queryWeatherAI(prompt, filteredJson.toString())
if (!aiResponse.isNullOrEmpty()) {
onSuccess(aiResponse, city)
} else {
@ -90,8 +93,6 @@ class GetWeatherUseCase(
}
private suspend fun queryWeatherAI(question: String, weatherData: String): String? {
val messagesArray = JSONArray()
val date = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault(Locale.Category.FORMAT)).format(Calendar.getInstance().time)
val time = SimpleDateFormat("HH:mm:ss", Locale.getDefault(Locale.Category.FORMAT)).format(Calendar.getInstance().time)
val systemContext =
@ -99,9 +100,11 @@ class GetWeatherUseCase(
"which is $date at $time. Please respond with an answer to the user's question based on " +
"the latest weather data provided. No need to mention data or time; just answer naturally in 2 to 3 lines."
messagesArray.put(JSONObject().put("role", "system").put("content", systemContext))
messagesArray.put(JSONObject().put("role", "user").put("content", question + System.lineSeparator() + weatherData))
val messages = listOf(
GroqMessage(role = "system", content = systemContext),
GroqMessage(role = "user", content = question + System.lineSeparator() + weatherData)
)
return getAiResponseUseCase.execute(messagesArray)
return getAiResponseUseCase.execute(messages)
}
}

View file

@ -6,21 +6,54 @@ import android.util.Log
import com.app.assistant.BuildConfig
import com.app.assistant.repository.SettingsRepository
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.Call
import okhttp3.Callback
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.json.JSONObject
import java.io.IOException
import java.net.URI
@Serializable
data class YouTubeItemId(
val kind: String? = null,
val videoId: String? = null
)
@Serializable
data class YouTubeThumbnailDetail(
val url: String? = null
)
@Serializable
data class YouTubeThumbnails(
val high: YouTubeThumbnailDetail? = null
)
@Serializable
data class YouTubeSnippet(
val thumbnails: YouTubeThumbnails? = null
)
@Serializable
data class YouTubeSearchItem(
val id: YouTubeItemId? = null,
val snippet: YouTubeSnippet? = null
)
@Serializable
data class YouTubeSearchResponse(
val items: List<YouTubeSearchItem>? = null
)
class PlaySongUseCase(
private val settingsRepository: SettingsRepository
) {
private val json = Json { ignoreUnknownKeys = true }
suspend fun execute(
prompt: String,
onIntentTriggered: suspend (Intent) -> Unit,
@ -112,23 +145,20 @@ class PlaySongUseCase(
}
private fun extractVideoIdAndThumbnail(jsonString: String): Pair<String, String> {
val jsonObject = JSONObject(jsonString)
val itemsArray = jsonObject.optJSONArray("items") ?: return Pair("", "")
if (itemsArray.length() > 0) {
val firstItem = itemsArray.optJSONObject(0) ?: return Pair("", "")
val idObject = firstItem.optJSONObject("id") ?: return Pair("", "")
if (idObject.optString("kind") == "youtube#video") {
val videoId = idObject.optString("videoId", "")
val thumbnailUrl = firstItem
.optJSONObject("snippet")
?.optJSONObject("thumbnails")
?.optJSONObject("high")
?.optString("url", "") ?: ""
return Pair(videoId, thumbnailUrl)
try {
val response = json.decodeFromString<YouTubeSearchResponse>(jsonString)
val items = response.items ?: return Pair("", "")
if (items.isNotEmpty()) {
val firstItem = items[0]
val idObject = firstItem.id ?: return Pair("", "")
if (idObject.kind == "youtube#video") {
val videoId = idObject.videoId ?: ""
val thumbnailUrl = firstItem.snippet?.thumbnails?.high?.url ?: ""
return Pair(videoId, thumbnailUrl)
}
}
} catch (e: Exception) {
Log.e("PlaySongUseCase", "Error parsing YouTube JSON", e)
}
return Pair("", "")
}

View file

@ -3,8 +3,6 @@ package com.app.assistant.usecase
import com.app.assistant.model.Conversation
import com.app.assistant.util.Category
import com.google.mediapipe.tasks.text.textclassifier.TextClassifierResult
import org.json.JSONArray
import org.json.JSONObject
import java.util.Locale
class ProcessChatCommandUseCase(
@ -96,15 +94,14 @@ class ProcessChatCommandUseCase(
systemContext: String,
chatHistory: List<Conversation>
): String? {
val messagesArray = JSONArray()
messagesArray.put(JSONObject().put("role", "system").put("content", systemContext))
val messages = mutableListOf<GroqMessage>()
messages.add(GroqMessage(role = "system", content = systemContext))
for (item in chatHistory) {
val role = if (item.isMe) "user" else "assistant"
val messageObject = JSONObject().put("role", role).put("content", item.englishText)
messagesArray.put(messageObject)
messages.add(GroqMessage(role = role, content = item.englishText))
}
return getAiResponseUseCase.execute(messagesArray)
return getAiResponseUseCase.execute(messages)
}
}

View file

@ -6,7 +6,7 @@
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 --add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
@ -20,4 +20,6 @@ kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.nonTransitiveRClass=true
kotlin.daemon.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 --add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
kotlin.compiler.execution.strategy=in-process

View file

@ -25,6 +25,8 @@ mediapipeTasksText = "0.10.29"
kotlinSerialization = "1.9.0"
androidxComposeCompiler = "1.5.1"
spotless = "8.4.0"
room = "2.6.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -57,6 +59,10 @@ androidx-ui-test-android = { group = "androidx.compose.ui", name = "ui-test-andr
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
androidx-runtime-saveable = { group = "androidx.compose.runtime", name = "runtime-saveable", version.ref = "runtimeSaveable" }
mediapipe-tasks-text = { group = "com.google.mediapipe", name = "tasks-text", version.ref = "mediapipeTasksText" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }