commit ef6fa1e653cfd1ddb6686537844fe4e34351b6c7 Author: souravanand001 Date: Tue Sep 23 01:01:18 2025 +0530 Initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..807b00e --- /dev/null +++ b/.gitignore @@ -0,0 +1,74 @@ +# Built application files +*.apk +*.aab +*.ap_ +*.aapk +*.aar + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +*.ipr +*.iws +.idea/ +.idea/caches/ +.idea/libraries/ +.idea/misc.xml +.idea/modules.xml +.idea/runConfigurations/ +.idea/shelf/ +.idea/vcs.xml +.idea/workspace.xml + +# Keystore files +*.jks +*.keystore + +# External native build folder created in Android Studio 2.2 and later +.externalNativeBuild/ + +# Google Services (e.g. Google Maps) +google-services.json + +# Signed APKs +release/ + +# Crashlytics plugin +com_crashlytics_export_strings.xml + +# Other +.DS_Store +Thumbs.db +profile-results/ +lint-results-*.xml +lint-results-debug.xml +lint-results-release.xml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b12c086 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Sourav Anand + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f1e6ce4 --- /dev/null +++ b/README.md @@ -0,0 +1,168 @@ +# AI Assistant Android App + +![MIT License](https://img.shields.io/badge/License-MIT-green.svg) +![Kotlin](https://img.shields.io/badge/Kotlin-1.9-blue) +![Compose](https://img.shields.io/badge/Jetpack-Compose-purple) +![MediaPipe](https://img.shields.io/badge/MediaPipe-ML-brightgreen) +![TensorFlow Lite](https://img.shields.io/badge/TensorFlow-Lite-FF6F00) + +## Demo & Screenshots + +

+ App Overview:
+ App Overview GIF +

+ +

+ Screenshots:
+

+ + + + + + + +
+ Screenshot 1 + + Screenshot 2 + + Screenshot 3 +
+ +## Overview + +Assistant is an Android application designed to act as a personal voice assistant. It can understand spoken commands, process them, and perform various actions like making calls, playing music, setting alarms, providing weather information, and more. The app integrates with several external services and uses on-device machine learning for intent recognition. + +## Features + +* **Voice Commands:** Interact with the assistant using natural language. +* **Text-to-Speech & Speech-to-Text:** Converts spoken language to text and vice-versa for interaction. +* **Intent Classification:** Understands user intent and categorizes requests (e.g., Call, Music, Weather, Navigation, Alarm, Reminder). +* **Phone Calls:** Can initiate phone calls to contacts. +* **Music Playback:** Plays music via YouTube. +* **Alarms & Reminders:** Set alarms and reminders. +* **Navigation:** Provides navigation directions using Google Maps. +* **Weather Information:** Fetches and displays weather forecasts for specified locations or current location. +* **Large Language Model (LLM) Integration:** Uses Groq API for advanced query understanding and responses (can be changed as per requirement). +* **Translation:** Supports language translation for input queries and responses. +* **Conversation History:** Stores and displays past interactions. +* **Customizable UI:** Offers options for UI presentation. + +## Default Assistant App + +This application can be set as the default assistant app on your Android device. This allows you to launch the app by long-pressing the home button or using other system-level gestures for invoking the assistant. + +## Process Flow + +``` +Start (User Input on ChatScreen: Voice/Text) + │ + V +MainViewModel (Input Processing, Permissions, Translation, Cleaning) + │ + ├─+------------------------------------─> Lock Active? (e.g., Call/Song specific flow) + │ │ (Yes) + V (No / General Flow) │ +Text Classification (Determine User Intent) Process Input based on Active Lock + │ │ + V │ +Action Execution <──────────────────────────+ + (Based on Intent or Lock: + - Make Call + - Play Song + - Navigate + - Get Weather + - Set Alarm/Reminder + - Query General AI (Groq) + - etc.) + │ + V +Process Action Result (e.g., Format AI response, Translate back) + │ + V +Update UI (ChatScreen displays conversation) + │ + V +Speak Response (TextToSpeech, if enabled) + │ + V +End +``` + +1. **User Input:** The user provides input via voice or text in the `ChatScreen`. +2. **Input Handling (`MainViewModel`):** + * The input is sent to the `MainViewModel`. + * Permissions (like microphone for voice input) are checked. + * If the input is not in English, it's translated to English. * The text is cleaned and punctuated. +3. **Lock State Management (`MainViewModel`):** + * The current lock state is checked. + * If a specific lock is active (e.g., "call lock," "song lock"), the input is processed according to that lock. + * If no lock is active or the input is negative (e.g., "cancel"), the input proceeds to general classification. +4. **Text Classification (`TextClassifierHelper` via `MainViewModel`):** + * The input text is classified to determine the user's intent (e.g., make a call, play a song, navigate, get weather, set alarm/reminder, or general query). +5. **Action Execution (`CallCommand` & `MainViewModel`):** + * Based on the classified category, the `MainViewModel` (often via a `CallCommand` utility) triggers the appropriate action: + * **Call:** Initiates a phone call. + * **Songs:** Plays a song. + * **Navigation:** Starts navigation. + * **Weather:** Fetches weather information. + * **Alarm:** Sets an alarm. + * **Reminder:** Sets a reminder. + * **Other/Settings/General AI:** For other queries, or if the intent is unclear or relates to settings, the input is sent to a general AI (Groq API). +6. **AI Response Processing (if applicable via `MainViewModel`):** + * The response from the Groq API is received. + * The response is processed and formatted. + * If the original input was not in English, the AI's English response is translated back to the original language. +7. **Updating UI (`ChatScreen`):** + * The result of the action (e.g., confirmation of a call, song playing, navigation started, weather info, alarm/reminder set, or AI response) is added to the chat list in the `MainViewModel`. + * The `ChatScreen` observes changes in the chat list and updates to display the conversation. +8. **Speech Output (if enabled via `MainViewModel`):** + * The response or confirmation is converted to speech using TextToSpeech. + * The synthesized speech is played back to the user. + +## Technologies Used + +* **Kotlin:** Primary programming language. +* **Jetpack Compose:** For building the user interface. +* **Android Jetpack:** + * ViewModel: For managing UI-related data in a lifecycle-conscious way. +* **Speech Recognition & Synthesis:** Android's built-in `SpeechRecognizer` and `TextToSpeech` engines. +* **Text Classification:** MediaPipe, utilizing a custom-trained TensorFlow Lite (TFLite) model for on-device text classification. +* **Offline Translation:** Google ML Kit for on-device language identification and translation, enabling completely offline functionality. +* **Networking:** OkHttp for making API calls. +* **APIs:** + * Groq API (for LLM, can be changed as per requirement) + * YouTube Data API v3 (for music search) +* **Coroutines:** For asynchronous programming. + +## Setup + +1. **Clone the repository:** +2. **API Keys:** + This project requires API keys for YouTube and Groq services. You need to create a `local.properties` file in the root directory of your project (if it doesn't already exist) and add your keys there: + ```properties + YOUTUBE_API_KEY=YOUR_YOUTUBE_API_KEY + GROQ_API_KEY=YOUR_GROQ_API_KEY + ``` + These keys are then accessed via `BuildConfig` fields in the app. + +3. **Ensure `local.properties` is in `.gitignore`:** The `.gitignore` file should include `local.properties` to prevent your API keys from being checked into version control. + +## How to Build and Run + +1. Open the project in Android Studio. +2. Let Android Studio sync the project and download necessary dependencies. +3. Ensure you have an Android device or emulator connected/running. +4. Click the "Run" button (green play icon) in Android Studio. + +## Notes + +* The app requests various permissions at runtime (e.g., Record Audio, Read Contacts, Call Phone, Location) as needed for its features. +* Bluetooth SCO is managed for better compatibility with Bluetooth headsets during speech recognition. + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +Copyright (c) 2025 Sourav Anand diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..991a24f --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,115 @@ +import java.util.Properties +import java.io.FileInputStream + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.jetbrains.kotlin.android) + kotlin("plugin.serialization") version "2.0.0" +} + +android { + namespace = "com.app.assistant" + compileSdk = 34 + + defaultConfig { + applicationId = "com.app.assistant" + minSdk = 24 + targetSdk = 34 + 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 = "1.5.1" + } + 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("androidx.compose.material3:material3:1.2.1") + implementation("androidx.compose.material3:material3-window-size-class:1.2.1") + implementation("androidx.compose.material3:material3-adaptive-navigation-suite:1.3.0-beta02") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + implementation ("com.google.mlkit:translate:17.0.2") + implementation ("org.commonmark:commonmark:0.24.0") + //implementation ("com.github.jeziellago:compose-markdown:0.5.4") + implementation("io.coil-kt.coil3:coil-compose:3.0.0-rc02") + implementation("io.coil-kt.coil3:coil-network-okhttp:3.0.0-rc02") + implementation ("org.jetbrains.kotlin:kotlin-reflect") + implementation(libs.play.services.location) + implementation(libs.androidx.ui.test.android) + 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 ("com.google.mediapipe:tasks-text:0.10.14") +} + diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..63aecae --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/model.tflite b/app/src/main/assets/model.tflite new file mode 100644 index 0000000..7315fc6 Binary files /dev/null and b/app/src/main/assets/model.tflite differ diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..1019d35 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/app/assistant/AssistActivity.kt b/app/src/main/java/com/app/assistant/AssistActivity.kt new file mode 100644 index 0000000..9a4d31f --- /dev/null +++ b/app/src/main/java/com/app/assistant/AssistActivity.kt @@ -0,0 +1,54 @@ +package com.app.assistant + +import android.app.KeyguardManager +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.ComponentActivity + +class AssistActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + allowOnLockScreen() + + // Check if the app was launched as an assistant + if (Intent.ACTION_ASSIST == intent.action || Intent.ACTION_VOICE_COMMAND == intent.action) { + // Launched via home button long press + moveToMainActivity() + } else { + startDefaultFlow() + } + } + + private fun startDefaultFlow() { + // Directly launch MainActivity when launched as assistant + val intent = Intent(this, MainActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT + startActivity(intent) + finish() + } + + private fun moveToMainActivity(){ + val intent = Intent(this, MainActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION + intent.putExtra("speak", true) + startActivity(intent) + finish() + } + + private fun allowOnLockScreen() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + keyguardManager.requestDismissKeyguard(this, null) + } else { + this.window.addFlags( + WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD or + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/app/assistant/Classifier/TextClassifierHelper.kt b/app/src/main/java/com/app/assistant/Classifier/TextClassifierHelper.kt new file mode 100644 index 0000000..4abe4c1 --- /dev/null +++ b/app/src/main/java/com/app/assistant/Classifier/TextClassifierHelper.kt @@ -0,0 +1,71 @@ +package com.app.assistant.Classifier + +import android.content.Context +import android.os.SystemClock +import android.util.Log +import com.google.mediapipe.tasks.core.BaseOptions +import com.google.mediapipe.tasks.text.textclassifier.TextClassifier +import com.google.mediapipe.tasks.text.textclassifier.TextClassifierResult +import java.util.concurrent.ScheduledThreadPoolExecutor + +class TextClassifierHelper( + var currentModel: String = MOBILEBERT, + val context: Context, + val listener: TextResultsListener +) { + private lateinit var textClassifier: TextClassifier + private lateinit var executor: ScheduledThreadPoolExecutor + + init { + initClassifier() + } + + fun initClassifier() { + val baseOptionsBuilder = BaseOptions.builder() + .setModelAssetPath(currentModel) + + try { + val baseOptions = baseOptionsBuilder.build() + val optionsBuilder = TextClassifier.TextClassifierOptions.builder() + .setBaseOptions(baseOptions) + val options = optionsBuilder.build() + textClassifier = TextClassifier.createFromOptions(context, options) + } catch (e: IllegalStateException) { + listener.onError( + "Text classifier failed to initialize. See error logs for " + + "details" + ) + Log.e( + TAG, "Text classifier failed to load the task with error: " + e + .message + ) + } + } + + // Run text classification using MediaPipe Text Classifier API + fun classify(text: String, itemId: Long, loadingItemId: Long, speak: Boolean) { + executor = ScheduledThreadPoolExecutor(1) + + executor.execute { + // inferenceTime is the amount of time, in milliseconds, that it takes to + // classify the input text. + var inferenceTime = SystemClock.uptimeMillis() + + val results = textClassifier.classify(text) + + inferenceTime = SystemClock.uptimeMillis() - inferenceTime + + listener.onResult(results, inferenceTime, text, itemId, loadingItemId, speak) + } + } + + interface TextResultsListener { + fun onError(error: String) + fun onResult(results: TextClassifierResult, inferenceTime: Long, inputText: String, itemId: Long, loadingItemId: Long, speak: Boolean) + } + + companion object { + const val TAG = "TextClassifierHelper" + const val MOBILEBERT = "model.tflite" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/app/assistant/MainActivity.kt b/app/src/main/java/com/app/assistant/MainActivity.kt new file mode 100644 index 0000000..52e5037 --- /dev/null +++ b/app/src/main/java/com/app/assistant/MainActivity.kt @@ -0,0 +1,58 @@ +package com.app.assistant + +import android.app.KeyguardManager +import android.content.Context +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import com.app.assistant.ui.screen.SetupUI +import com.app.assistant.viewmodel.MainViewModel +import com.app.assistant.viewmodel.MainViewModelFactory + +class MainActivity : ComponentActivity() { + private val viewModel: MainViewModel by viewModels{ + MainViewModelFactory(application, intent.getBooleanExtra("speak", false)) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + allowOnLockScreen() + enableEdgeToEdge() + setContent { + SetupUI(viewModel) + } + } + + private fun allowOnLockScreen() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { // Android 8.1+ + setShowWhenLocked(true) + setTurnScreenOn(true) + } else { + window.addFlags( + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + ) + } + + try { + val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + if (keyguardManager.isKeyguardLocked) { + keyguardManager.requestDismissKeyguard(this, null) + } + } + }catch (ex: Exception){ + ex.message?.let { Log.d("Exception Occurred", it) } + } + } + + override fun onDestroy() { + super.onDestroy() + viewModel.shutdownResources() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/app/assistant/db/DynamicConversationDbHelper.kt b/app/src/main/java/com/app/assistant/db/DynamicConversationDbHelper.kt new file mode 100644 index 0000000..9f04d63 --- /dev/null +++ b/app/src/main/java/com/app/assistant/db/DynamicConversationDbHelper.kt @@ -0,0 +1,77 @@ +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 { + return when (kotlinType) { + "kotlin.String" -> "TEXT" + "kotlin.Int", "kotlin.Long", "kotlin.Boolean" -> "INTEGER" + "java.net.URI" -> "TEXT" + else -> "TEXT" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/app/assistant/db/DynamicConversationRepository.kt b/app/src/main/java/com/app/assistant/db/DynamicConversationRepository.kt new file mode 100644 index 0000000..e651fd1 --- /dev/null +++ b/app/src/main/java/com/app/assistant/db/DynamicConversationRepository.kt @@ -0,0 +1,201 @@ +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) { + + 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) + 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 currentGroupId + } + + private fun generateChatTitle(msg: String): String { + val date = Date() + val words = msg.split(" ").take(2).joinToString(" ") + val time = SimpleDateFormat("h:mm aa - EEE, MMM d", Locale.getDefault()).format(date) + return "$words $time" + } + + suspend fun addMessage(conversation: Conversation) { + withContext(Dispatchers.IO) { + if (currentGroupId == -1L) { + 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) } + } + } + } + + suspend fun deleteMessage(id: Long) { + withContext(Dispatchers.IO) { + val db = dbHelper.writableDatabase + db.delete( + TABLE_MESSAGES, + "id = ?", + arrayOf(id.toString()) + ) + } + } + + suspend fun updateMessage(oldConversation: Conversation, 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()) + ) + } + } + + suspend fun clearMessages(conversations: List) { + 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") + currentGroupId = -1L + } + } + + suspend fun loadAllGroups(): MutableList { + val groupList = mutableListOf() + 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 groupList + } + + + suspend fun loadMessagesForGroup(groupId: Long): MutableList { + val tempList = mutableListOf() + 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 + ) + + while (cursor.moveToNext()) { + val conversation = cursorToConversation(cursor) + tempList.add(conversation) + } + cursor.close() + } + return tempList + } + + private fun cursorToConversation(cursor: Cursor): Conversation { + val properties = Conversation::class.memberProperties + val fieldMap = mutableMapOf() + 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, String> { + val properties = Conversation::class.memberProperties + val encryptedMap = mutableMapOf() + 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/app/assistant/db/EncryptionUtil.kt b/app/src/main/java/com/app/assistant/db/EncryptionUtil.kt new file mode 100644 index 0000000..17474cf --- /dev/null +++ b/app/src/main/java/com/app/assistant/db/EncryptionUtil.kt @@ -0,0 +1,69 @@ +package com.app.assistant.db + +import android.util.Base64 +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +object EncryptionUtil { + private const val KEY_SIZE = 256 + private const val IV_SIZE = 12 // Recommended size for GCM is 12 bytes + private const val ALGORITHM = "AES" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val TAG_LENGTH_BIT = 128 + + // Generate a random encryption key + private val secretKey: SecretKey = generateSecretKey() + + // Generates a new random initialization vector (IV) + fun generateIV(): String { + val secureRandom = SecureRandom() + val iv = ByteArray(IV_SIZE) + secureRandom.nextBytes(iv) + return Base64.encodeToString(iv, Base64.DEFAULT) + } + + // Encrypt a string and return encrypted text and IV + fun encrypt(input: String, ivString: String): Pair { + 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) + + val encryptedBytes = cipher.doFinal(input.toByteArray()) + val encryptedText = Base64.encodeToString(encryptedBytes, Base64.DEFAULT) + return Pair(encryptedText, ivString) + } + + // Decrypt an encrypted string using IV + fun decrypt(encryptedInput: String, ivString: String): String { + 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) + + val encryptedBytes = Base64.decode(encryptedInput, Base64.DEFAULT) + val decryptedBytes = cipher.doFinal(encryptedBytes) + return String(decryptedBytes) + } + + // Generate a new secret key + private fun generateSecretKey(): SecretKey { + val keyGenerator = KeyGenerator.getInstance(ALGORITHM) + keyGenerator.init(KEY_SIZE, SecureRandom()) + return keyGenerator.generateKey() + } + + // Save and restore secret key as Base64 (for persistence) + fun getSecretKeyAsString(): String { + return Base64.encodeToString(secretKey.encoded, Base64.DEFAULT) + } + + fun restoreSecretKeyFromString(keyString: String): SecretKey { + val decodedKey = Base64.decode(keyString, Base64.DEFAULT) + return SecretKeySpec(decodedKey, 0, decodedKey.size, ALGORITHM) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/app/assistant/db/SyncStateList.kt b/app/src/main/java/com/app/assistant/db/SyncStateList.kt new file mode 100644 index 0000000..799354f --- /dev/null +++ b/app/src/main/java/com/app/assistant/db/SyncStateList.kt @@ -0,0 +1,50 @@ +package com.app.assistant.db + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import com.app.assistant.model.Conversation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +class SyncStateList( + private val repository: DynamicConversationRepository, // Repository to handle DB operations + private val stateList: SnapshotStateList = mutableStateListOf() +) : MutableList by stateList { + + override fun add(element: Conversation): Boolean { + val result = stateList.add(element) + if (result && !element.isLoading) { + CoroutineScope(Dispatchers.IO).launch { + repository.addMessage(element) + } + } + return result + } + + override fun removeAt(index: Int): Conversation { + val result = stateList.removeAt(index) + CoroutineScope(Dispatchers.IO).launch { + repository.deleteMessage(result.id) + } + return result + } + + override fun set(index: Int, element: Conversation): Conversation { + val oldElement = stateList[index] + stateList[index] = element + CoroutineScope(Dispatchers.IO).launch { + repository.updateMessage(oldElement, element) + } + return oldElement + } + + fun clearAll(): Job { + val oldList = stateList.toList() + stateList.clear() + return CoroutineScope(Dispatchers.IO).launch { + repository.clearMessages(oldList) // Suspends until DB operations finish + } + } +} diff --git a/app/src/main/java/com/app/assistant/model/Contact.kt b/app/src/main/java/com/app/assistant/model/Contact.kt new file mode 100644 index 0000000..528f44a --- /dev/null +++ b/app/src/main/java/com/app/assistant/model/Contact.kt @@ -0,0 +1,3 @@ +package com.app.assistant.model + +data class Contact(val id: String ,val name: String, val phoneNumber: String) \ No newline at end of file diff --git a/app/src/main/java/com/app/assistant/model/Conversation.kt b/app/src/main/java/com/app/assistant/model/Conversation.kt new file mode 100644 index 0000000..b9aa12e --- /dev/null +++ b/app/src/main/java/com/app/assistant/model/Conversation.kt @@ -0,0 +1,5 @@ +package com.app.assistant.model +import com.app.assistant.util.Category +import java.net.URI + +data class Conversation(var id: Long = System.currentTimeMillis() * 1_000_000 + (System.nanoTime() % 1_000_000), var englishText: String, var translatedText: String, val isMe: Boolean, val isLoading: Boolean = false, var category: String = Category.OTHER.name, val contentURL: String ="", val navigationURI: URI=URI("") ) \ No newline at end of file diff --git a/app/src/main/java/com/app/assistant/model/Group.kt b/app/src/main/java/com/app/assistant/model/Group.kt new file mode 100644 index 0000000..59cd45f --- /dev/null +++ b/app/src/main/java/com/app/assistant/model/Group.kt @@ -0,0 +1,3 @@ +package com.app.assistant.model + +data class Group(val groupId: Long, val title: String) \ No newline at end of file diff --git a/app/src/main/java/com/app/assistant/translation/TranslatorManager.kt b/app/src/main/java/com/app/assistant/translation/TranslatorManager.kt new file mode 100644 index 0000000..6e98f65 --- /dev/null +++ b/app/src/main/java/com/app/assistant/translation/TranslatorManager.kt @@ -0,0 +1,138 @@ +package com.app.assistant.translation + +import android.util.Log +import com.google.mlkit.common.model.DownloadConditions +import com.google.mlkit.common.model.RemoteModelManager +import com.google.mlkit.nl.translate.TranslateLanguage +import com.google.mlkit.nl.translate.TranslateRemoteModel +import com.google.mlkit.nl.translate.Translation +import com.google.mlkit.nl.translate.Translator +import com.google.mlkit.nl.translate.TranslatorOptions + +class TranslatorManager { + + private var toEnglishTranslator: Translator? = null + private var fromEnglishTranslator: Translator? = null + private val modelManager = RemoteModelManager.getInstance() + + fun setupTranslators(selectedLanguageCode: String, onResult: (Boolean) -> Unit) { + modelManager.getDownloadedModels(TranslateRemoteModel::class.java) + .addOnSuccessListener { downloadedModels -> + val downloadedLanguages = downloadedModels.map { it.language }.toSet() + + val isSelectedModelDownloaded = selectedLanguageCode in downloadedLanguages + val isEnglishModelDownloaded = TranslateLanguage.ENGLISH in downloadedLanguages + + // Download selected language model if not already downloaded + if (!isSelectedModelDownloaded) { + downloadLanguageModel(selectedLanguageCode) { success -> + if (!success) { + onResult(false) + return@downloadLanguageModel + }else{ + // Download English model if not already downloaded + if (!isEnglishModelDownloaded) { + downloadLanguageModel(TranslateLanguage.ENGLISH) { successEnglish -> + if (!successEnglish) { + onResult(false) + return@downloadLanguageModel + }else{ + onResult(true) + } + } + } else { + Log.d("Translator", "English language model already exists. Not downloading.") + onResult(true) + } + } + } + } else { + Log.d("Translator", "Selected language model ($selectedLanguageCode) already exists. Not downloading.") + onResult(true) + } + + // Set up the translators for the selected language and English + initializeTranslators(selectedLanguageCode) + } + .addOnFailureListener { e -> + Log.e("Translator", "Failed to get downloaded models: ${e.message}") + onResult(false) + } + } + + // Helper function to initialize translators for the selected language + private fun initializeTranslators(selectedLanguageCode: String) { + // Set up translator for selected language -> English + val toEnglishOptions = TranslatorOptions.Builder() + .setSourceLanguage(selectedLanguageCode) + .setTargetLanguage(TranslateLanguage.ENGLISH) + .build() + + toEnglishTranslator = Translation.getClient(toEnglishOptions) + + // Set up translator for English -> selected language + val fromEnglishOptions = TranslatorOptions.Builder() + .setSourceLanguage(TranslateLanguage.ENGLISH) + .setTargetLanguage(selectedLanguageCode) + .build() + + fromEnglishTranslator = Translation.getClient(fromEnglishOptions) + } + + + // Use this method to translate from selected language to English + fun translateToEnglish(text: String, callback: (translatedText: String?) -> Unit) { + toEnglishTranslator?.translate(text) + ?.addOnSuccessListener { translatedText -> + callback(translatedText) + } + ?.addOnFailureListener { e -> + Log.e("Translator", "Translation to English failed: ${e.message}") + callback(null) + } + } + + // Use this method to translate from English to the selected language + fun translateFromEnglish(text: String, callback: (translatedText: String?) -> Unit) { + fromEnglishTranslator?.translate(text) + ?.addOnSuccessListener { translatedText -> + callback(translatedText) + } + ?.addOnFailureListener { e -> + Log.e("Translator", "Translation from English failed: ${e.message}") + callback(null) + } + } + + private fun downloadLanguageModel(languageCode: String, onDownloadComplete: (Boolean) -> Unit) { + val languageModel = TranslateRemoteModel.Builder(languageCode).build() + val conditions = DownloadConditions.Builder().build() + + modelManager.download(languageModel, conditions) + .addOnSuccessListener { + Log.d("Translator", "Language model for $languageCode downloaded successfully.") + onDownloadComplete(true) + } + .addOnFailureListener { e -> + Log.e("Translator", "Failed to download language model for $languageCode: ${e.message}") + onDownloadComplete(false) + } + } + + // Delete the previously downloaded language model + private fun deletePreviousLanguageModel(languageCode: String) { + val languageModel = TranslateRemoteModel.Builder(languageCode).build() + modelManager.deleteDownloadedModel(languageModel) + .addOnSuccessListener { + Log.d("Translator", "Language model for $languageCode deleted successfully.") + } + .addOnFailureListener { e -> + Log.e("Translator", "Failed to delete language model for $languageCode: ${e.message}") + } + } + + fun closeTranslator(){ + toEnglishTranslator?.close() + fromEnglishTranslator?.close() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/app/assistant/ui/screen/ChatScreen.kt b/app/src/main/java/com/app/assistant/ui/screen/ChatScreen.kt new file mode 100644 index 0000000..0032781 --- /dev/null +++ b/app/src/main/java/com/app/assistant/ui/screen/ChatScreen.kt @@ -0,0 +1,1272 @@ +package com.app.assistant.ui.screen + +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import android.util.Log +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.StartOffset +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Call +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberDrawerState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +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.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +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.draw.drawBehind +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PaintingStyle.Companion.Stroke +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.SoftwareKeyboardController +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import com.app.assistant.R +import com.app.assistant.model.Conversation +import com.app.assistant.ui.theme.AssistantTheme +import com.app.assistant.util.Category +import com.app.assistant.viewmodel.MainViewModel +import kotlinx.coroutines.currentCoroutineContext +import org.commonmark.node.Text as CText +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.commonmark.node.BlockQuote +import org.commonmark.node.BulletList +import org.commonmark.node.Code +import org.commonmark.node.Emphasis +import org.commonmark.node.FencedCodeBlock +import org.commonmark.node.HardLineBreak +import org.commonmark.node.Heading +import org.commonmark.node.IndentedCodeBlock +import org.commonmark.node.Link +import org.commonmark.node.ListItem +import org.commonmark.node.Node +import org.commonmark.node.OrderedList +import org.commonmark.node.Paragraph +import org.commonmark.node.SoftLineBreak +import org.commonmark.node.StrongEmphasis +import org.commonmark.node.ThematicBreak +import org.commonmark.parser.Parser +import kotlin.math.hypot + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SetupUI(viewModel: MainViewModel) { + + AssistantTheme { + var showCopyIcon by remember { mutableStateOf(false) } + var selectedItemIndex by remember { mutableStateOf(null) } + val clipboardManager = LocalClipboardManager.current + var clearShowDialog by remember { mutableStateOf(false) } + var deleteShowDialog by remember { mutableStateOf(false) } + + val sheetState = rememberModalBottomSheetState() + var showBottomSheet by viewModel.showBottomSheet + + val isCustomUI by viewModel.isCustomUI.collectAsState() + val isCustomUIHalfPage by viewModel.isCustomUIHalfPage.collectAsState() + val drawerState = remember(isCustomUI) { + DrawerState(DrawerValue.Closed) + } + val scope = rememberCoroutineScope() + val groupList by viewModel.groupList.collectAsState(initial = emptyList()) + + val screenHeight = LocalConfiguration.current.screenHeightDp.dp + val backPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + if (isCustomUI) null else { + ModalDrawerSheet { + Text("Chats", modifier = Modifier.padding(16.dp)) + Column( + modifier = Modifier + .padding(horizontal = 8.dp) + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + groupList.forEach { group -> + NavigationDrawerItem( + label = { Text(text = group.title) }, + selected = false, + onClick = { + viewModel.loadMessagesFromGroup(group.groupId) + scope.launch { drawerState.close() } + } + ) + } + } + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + NavigationDrawerItem( + label = { Text(text = "Start new chat") }, + icon = { + Icon( + painter = (painterResource(id = R.drawable.ic_add_new_chat)), + contentDescription = "Start new chat" + ) + }, + selected = false, + onClick = { + viewModel.newChat() + scope.launch { drawerState.close() } + } + ) + } + } + } + ) { + val scaffoldContent = @Composable{ + Scaffold( + containerColor = if(isCustomUIHalfPage) Color.Transparent else MaterialTheme.colorScheme.background, + modifier = if (isCustomUI) { + Modifier + .height(screenHeight / 2) + .padding(16.dp, 16.dp, 16.dp, 8.dp) + .clip(RoundedCornerShape(24.dp)) + .background(Color.Transparent, shape = RoundedCornerShape(16.dp)) + .clickable { + if (!isCustomUIHalfPage) { + Modifier.clickable(onClick = {}) + } else { + backPressedDispatcher?.onBackPressed() + } + } + } else { + Modifier + }, + topBar = { + if (!isCustomUI) { + TopAppBar( + navigationIcon = { + IconButton(onClick = { + scope.launch { + drawerState.apply { + if (isClosed) open() else close() + } + } + }) { + Icon( + painter = (painterResource(id = R.drawable.ic_menu)), + contentDescription = "Menu" + ) + } + }, + title = { Text("") }, + actions = { + if (showCopyIcon) { + IconButton(onClick = { + selectedItemIndex?.let { index -> + val textToCopy = when { + viewModel.getIsTranslationEnabled() -> viewModel.chatList.getOrNull( + index + )?.translatedText + + else -> viewModel.chatList.getOrNull(index)?.englishText + } ?: "" + clipboardManager.setText(AnnotatedString(textToCopy)) + selectedItemIndex = null + } + }) { + Icon( + painter = painterResource(id = R.drawable.ic_content_copy), + contentDescription = "Copy" + ) + } + IconButton(onClick = { + deleteShowDialog = true + }) { + Icon( + painter = painterResource(id = R.drawable.ic_delete), + contentDescription = "Delete" + ) + } + } + IconButton(onClick = { + showBottomSheet = true + }) { + Icon( + painter = (painterResource(id = R.drawable.ic_translate)), + contentDescription = "Translate" + ) + } + if (viewModel.chatList.isNotEmpty()) { + IconButton(onClick = { clearShowDialog = true }) { + Icon( + painter = (painterResource(id = R.drawable.ic_restart)), + contentDescription = "Restart Chat" + ) + } + } + } + ) + } + if(!isCustomUIHalfPage && isCustomUI){ + Box( + modifier = Modifier + .fillMaxWidth() + .height(24.dp) + .then( + Modifier + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { /* Optional: trigger on drag start */ }, + onDragEnd = { + viewModel.expandToFullScreen() + }, + onDrag = { change, dragAmount -> + //track dragAmount if needed + } + ) + }) + .clickable { viewModel.expandToFullScreen() }, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .width(36.dp) + .height(4.dp) + .background( + color = Color.Gray, + shape = RoundedCornerShape(2.dp) + ) + ) + } + + } + } + ) { innerPadding -> + MyLayout( + modifier = Modifier.padding(innerPadding), + onShowCopyIconChange = { newValue -> + showCopyIcon = newValue + }, + selectedItemIndex = selectedItemIndex, + onSelectedItemChange = { newIndex -> selectedItemIndex = newIndex }, + viewModel = viewModel, + isCustomUI = isCustomUI + ) + } + } + + if (isCustomUI) { + var tapped by remember { mutableStateOf(false) } + val backPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + + // Reset 'tapped' state if this composable part is recomposed due to isCustomUI becoming true + // This ensures the animation and tap logic are fresh each time it appears. + LaunchedEffect(Unit) { + tapped = false + } + + val animatedBoxAlpha by animateFloatAsState( + targetValue = if (tapped) 0.0f else 0.3f, + animationSpec = tween(durationMillis = 100), // Quick animation + label = "boxBackgroundAlpha", + finishedListener = { + // When animation to 0.0f (fully transparent) finishes due to a tap + if (tapped) { + backPressedDispatcher?.onBackPressed() + } + } + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = animatedBoxAlpha)) + .imePadding() + .animateContentSize() + .clickable( + enabled = !tapped, + onClick = { + tapped = true + } + ), + contentAlignment = Alignment.BottomCenter + ) { + scaffoldContent() + } + } + else{ + scaffoldContent() + } + } + + if (showBottomSheet) { + var searchQuery by remember { mutableStateOf("") } + val filteredItems = viewModel.languages.filter {it.first.contains(searchQuery, true)}.map { it } + val isLanguageLoading by viewModel.isLanguageLoading.collectAsState() + val isTranslationEnabled by viewModel.isTranslationEnabled.collectAsState() + + ModalBottomSheet( + onDismissRequest = { + showBottomSheet = false + }, + sheetState = sheetState + ) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + Row(modifier = Modifier + .fillMaxWidth() + .padding(16.dp, 0.dp, 16.dp, 16.dp)) { + Text( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically), + text = "Chat in different languages" + ) + Switch(checked = isTranslationEnabled, + onCheckedChange = { viewModel.updateTranslationEnabled(it) }, + modifier = Modifier.align( + Alignment.CenterVertically)) + } + + + TextField( + modifier = Modifier + .fillMaxWidth() + .padding(0.dp) + .imePadding(), + value = searchQuery, + placeholder = { Text("Search...") }, + singleLine = true, + onValueChange = { searchQuery = it }, + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent + ), + enabled = isTranslationEnabled + ) + + if(isLanguageLoading) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(0.dp, 16.dp, 0.dp, 0.dp), + ) { + items(items = filteredItems) { lang -> + Text( + text = lang.first, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = isTranslationEnabled) { + if (isTranslationEnabled) { + viewModel.onItemSelected(lang.second) + } + } + .padding(16.dp), + ) + } + } + } + } + } + + if (clearShowDialog) { + AlertDialog( + onDismissRequest = { clearShowDialog = false }, + text = { Text("Are you sure you want to clear this chat from history?") }, + confirmButton = { + TextButton(onClick = { + viewModel.clearBoxes() + clearShowDialog = false + }) { + Text("Yes") + } + }, + dismissButton = { + TextButton(onClick = { clearShowDialog = false }) { + Text("No") + } + } + ) + } + + if(deleteShowDialog){ + AlertDialog( + onDismissRequest = { + deleteShowDialog = false + selectedItemIndex = null + }, + text = { Text("Delete this message?") }, + confirmButton = { + TextButton(onClick = { + selectedItemIndex?.let { index -> + viewModel.deleteMessage(index) + } + deleteShowDialog = false + selectedItemIndex = null + }) { + Text("Yes") + } + }, + dismissButton = { + TextButton(onClick = { + deleteShowDialog = false + selectedItemIndex = null + }) { + Text("No") + } + } + ) + } + + // Only enable BackHandler if there's a selection + BackHandler(enabled = selectedItemIndex != null) { + selectedItemIndex = null // Clear selection instead of handling system back + } + } +} + +@Composable +fun MyLayout( + modifier: Modifier = Modifier, + onShowCopyIconChange: (Boolean) -> Unit, + selectedItemIndex: Int?, + onSelectedItemChange: (Int) -> Unit, + viewModel: MainViewModel, + isCustomUI: Boolean +) { + val boxes = viewModel.chatList + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val scrollState = rememberLazyListState() + + val context = LocalContext.current + val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + context.getSystemService(VibratorManager::class.java)?.defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Vibrator::class.java) + } + + LaunchedEffect(Unit) { + viewModel.showToastEvent.collectLatest { message: String -> + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } + + LaunchedEffect(selectedItemIndex) { + onShowCopyIconChange(selectedItemIndex != null) + if (selectedItemIndex != null) { + if (vibrator?.hasVibrator() == true) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(50) + } + } + } + } + + Column(modifier = modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .weight(1f) + .padding(0.dp, 0.dp, 0.dp, 0.dp), + verticalArrangement = if (isCustomUI) + Arrangement.spacedBy(8.dp, Alignment.Bottom) + else + Arrangement.spacedBy(8.dp), + state = scrollState, + ) { + itemsIndexed(boxes) { index, conversation -> + ConversationItem( + conversation = conversation, + index = index, + isSelected = index == selectedItemIndex, + onLongClick = { newIndex -> onSelectedItemChange(newIndex) }, + viewModel = viewModel + ) + } + } + + if (keyboardController != null) { + UserInputField( + focusManager = focusManager, + keyboardController = keyboardController, + viewModel = viewModel + ) + } + } + + LaunchedEffect(boxes.size) { + if (boxes.isNotEmpty()) { + scrollState.animateScrollToItem(boxes.size - 1) + } + } +} + +@Composable +private fun UserInputField( + focusManager: FocusManager, + keyboardController: SoftwareKeyboardController, + viewModel: MainViewModel +) { + val isSpeaking by viewModel.isSpeaking.collectAsState() + val isListening by viewModel.isListening.collectAsState() + + val containerColor = TextFieldDefaults.colors().unfocusedContainerColor + val rippleColor = adjustContrast(containerColor) + + // State for current radius + alpha + val rippleRadius = remember { Animatable(0f) } + val rippleAlpha = remember { Animatable(0f) } + + // Size of TextField (for max ripple radius) + var textFieldSize by remember { mutableStateOf(IntSize.Zero) } + + LaunchedEffect(isListening, textFieldSize) { + if (isListening && textFieldSize != IntSize.Zero) { + val maxRadius = hypot( + textFieldSize.width / 2f, + textFieldSize.height / 2f + ) + while (currentCoroutineContext().isActive) { // keep running until LaunchedEffect cancels + rippleRadius.snapTo(0f) + rippleAlpha.snapTo(0.5f) + + // animate simultaneously + launch { + rippleRadius.animateTo( + targetValue = maxRadius, + animationSpec = tween(1000, easing = LinearEasing) + ) + } + rippleAlpha.animateTo( + targetValue = 0f, + animationSpec = tween(1000, easing = LinearEasing) + ) + } + } else { + rippleRadius.snapTo(0f) + rippleAlpha.snapTo(0f) + } + } + + + 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 = viewModel.question.value, + placeholder = { Text("Type here...") }, + maxLines = 2, + onValueChange = { viewModel.question.value = it }, + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent + ), + trailingIcon = { + Row(horizontalArrangement = Arrangement.End) { + val context = LocalContext.current + if (isSpeaking) { + IconButton(onClick = { + if (viewModel.textToSpeech.isSpeaking) { + viewModel.stopTextToSpeech() + } + }) { + Icon( + painter = painterResource(id = R.drawable.ic_stop), + contentDescription = "Stop" + ) + } + } else { + IconButton(onClick = { + viewModel.startSpeechToText( + context, + onResult = { recognizedText -> + viewModel.question.value = recognizedText + if (viewModel.question.value.isNotEmpty()) { + viewModel.processQuestion( + focusManager, + keyboardController, + context, + true + ) + } + }, + onPartialResult = { recognizedText -> + viewModel.question.value = recognizedText + } + ) + }) { + Icon( + painter = painterResource(id = R.drawable.ic_mic), + contentDescription = "Mic" + ) + } + } + IconButton(onClick = { + if (viewModel.question.value.isNotEmpty()) { + viewModel.processQuestion( + focusManager, + keyboardController, + context, + false + ) + } + }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = "Send" + ) + } + } + } + ) + + // Ripple overlay drawn *above background* but *below text* + Canvas( + modifier = Modifier + .matchParentSize() + .clip(RoundedCornerShape(20)) + ) { + if (rippleAlpha.value > 0f) { + drawCircle( + color = rippleColor.copy(alpha = rippleAlpha.value), + radius = rippleRadius.value, + center = center + ) + } + } + } + +} + +fun adjustContrast(color: Color, factor: Float = 0.15f): 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), + blue = (color.blue + factor).coerceAtMost(1f), + alpha = color.alpha + ) + } else { + // darken in light mode + Color( + red = (color.red - factor).coerceAtLeast(0f), + green = (color.green - factor).coerceAtLeast(0f), + blue = (color.blue - factor).coerceAtLeast(0f), + alpha = color.alpha + ) + } +} + + + +@Composable +fun ConversationItem( + conversation: Conversation, + index: Int, + isSelected: Boolean, + onLongClick: (Int) -> Unit, + viewModel: MainViewModel, + backgroundColor: Color = if (conversation.isMe) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.surfaceContainerHighest, + cornerRadius: Dp = 20.dp +) { + val uriHandler = LocalUriHandler.current + val isTranslateEnabled = viewModel.getIsTranslationEnabled() + Column( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + horizontalArrangement = if (conversation.isMe) Arrangement.Start else Arrangement.End + ) { + if (conversation.isMe) + Spacer(modifier = Modifier.weight(1f)) + Card( + Modifier + .wrapContentWidth() + .widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.85f), + shape = RoundedCornerShape(cornerRadius), + colors = CardDefaults.cardColors(containerColor = if (isSelected) MaterialTheme.colorScheme.secondaryContainer else backgroundColor) + ) { + if (conversation.isLoading) { + LoadingDots() // Show loading animation when isLoading is true + } else { + if(conversation.isMe) + OtherCard(conversation, onLongClick, index, isTranslateEnabled) + else { + when (conversation.category) { + Category.CALL.name -> { + MakeCall(uriHandler, conversation, isTranslateEnabled) + } + + Category.OTHER.name -> { + OtherCard(conversation, onLongClick, index, isTranslateEnabled) + } + + Category.SETTINGS.name -> { + // TODO + OtherCard(conversation, onLongClick, index, isTranslateEnabled) + } + + Category.SONGS.name -> { + PlaySong(uriHandler, conversation, isTranslateEnabled) + } + + Category.NAVIGATION.name -> { + StartNavigation(uriHandler, conversation, isTranslateEnabled) + } + + Category.WEATHER.name -> { + ShowWeather(uriHandler, conversation, isTranslateEnabled) + } + + Category.REMINDER.name -> { + OtherCard(conversation, onLongClick, index, isTranslateEnabled) + } + + Category.ALARM.name -> { + OtherCard(conversation, onLongClick, index, isTranslateEnabled) + } + + else -> { + OtherCard(conversation, onLongClick, index, isTranslateEnabled) + } + } + } + } + } + if (!conversation.isMe) + Spacer(modifier = Modifier.weight(1f)) + } + + if (conversation.isMe) { + Text( + text = conversation.category, + modifier = Modifier + .padding(0.dp, 0.dp, 20.dp, 0.dp) + .fillMaxWidth(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.secondary, + textAlign = TextAlign.End + ) + } + } +} + +@Composable +private fun StartNavigation( + uriHandler: UriHandler, + conversation: Conversation, + isTranslateEnabled: Boolean +) { + Row( + modifier = Modifier + .padding(8.dp) + .clickable { + uriHandler.openUri(conversation.navigationURI.toString()) + }, horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .background( + shape = CircleShape, + color = MaterialTheme.colorScheme.secondaryContainer + ), + ) { + Icon( + modifier = Modifier.padding(8.dp), + imageVector = Icons.Default.LocationOn, + contentDescription = "Navigation", + ) + } + + MarkdownText( + modifier = Modifier + .align(alignment = Alignment.CenterVertically) + .clickable { + uriHandler.openUri(conversation.navigationURI.toString()) + }, markdown = if(isTranslateEnabled) conversation.translatedText else conversation.englishText + ) + } +} + +@Composable +private fun PlaySong( + uriHandler: UriHandler, + conversation: Conversation, + isTranslateEnabled: Boolean +) { + AsyncImage( + modifier = Modifier + .clickable { + uriHandler.openUri(conversation.navigationURI.toString()) + }, + model = conversation.contentURL, + contentDescription = if(isTranslateEnabled) conversation.translatedText else conversation.englishText + ) +} + +@Composable +private fun MakeCall( + uriHandler: UriHandler, + conversation: Conversation, + isTranslateEnabled: Boolean +) { + Row(modifier = Modifier + .padding(8.dp) + .clickable { + uriHandler.openUri(conversation.navigationURI.toString()) + }, horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .background( + shape = CircleShape, + color = MaterialTheme.colorScheme.secondaryContainer + ), + ) { + Icon( + modifier = Modifier.padding(8.dp), + imageVector = Icons.Default.Call, + contentDescription = "Call", + ) + } + + MarkdownText(modifier = Modifier + .align(alignment = Alignment.CenterVertically) + .clickable { + uriHandler.openUri(conversation.navigationURI.toString()) + }, markdown = if(isTranslateEnabled) conversation.translatedText else conversation.englishText + ) + } +} + +@Composable +private fun ShowWeather( + uriHandler: UriHandler, + conversation: Conversation, + isTranslateEnabled: Boolean +){ + MarkdownText(modifier = Modifier + .padding(16.dp) + .clickable { uriHandler.openUri(conversation.navigationURI.toString()) }, + markdown = if(isTranslateEnabled) conversation.translatedText else conversation.englishText + ) +} + +@Composable +private fun OtherCard( + conversation: Conversation, + onLongClick: (Int) -> Unit, + index: Int, + isTranslateEnabled: Boolean +) { + MarkdownText( + markdown = if (isTranslateEnabled && conversation.translatedText.isNotBlank()) + conversation.translatedText + else + conversation.englishText, + modifier = Modifier + .padding(16.dp, 8.dp) + .pointerInput(Unit) { + detectTapGestures( + onLongPress = { + onLongClick(index) + } + ) + } + ) +} + +@Composable +fun LoadingDots() { + val infiniteTransition = rememberInfiniteTransition() + + // Animating each dot separately + val dot1Offset by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = -10f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 400, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + initialStartOffset = StartOffset(0) // First dot starts immediately + ), label = "" + ) + + val dot2Offset by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = -10f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 400, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + initialStartOffset = StartOffset(200) // Second dot starts with a delay + ), label = "" + ) + + val dot3Offset by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = -10f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 400, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + initialStartOffset = StartOffset(400) // Third dot starts with a larger delay + ), label = "" + ) + + // Unicode character for big dot (⏺ or ● for larger circles) + val dotChar = "\u25CF" + + Row(modifier = Modifier.padding(16.dp)) { + // First dot + Text( + text = dotChar, + modifier = Modifier.offset(y = dot1Offset.dp), + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.width(4.dp)) + + // Second dot + Text( + text = dotChar, + modifier = Modifier.offset(y = dot2Offset.dp), + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.width(4.dp)) + + // Third dot + Text( + text = dotChar, + modifier = Modifier.offset(y = dot3Offset.dp), + style = MaterialTheme.typography.bodyLarge + ) + } +} + +@Composable +fun MarkdownText( + modifier: Modifier = Modifier, + markdown: String +) { + val annotatedText = remember(markdown) { + parseMarkdownToAnnotatedString(markdown) + } + Text( + text = annotatedText, + modifier = modifier + ) +} + +fun parseMarkdownToAnnotatedString(markdown: String): AnnotatedString { + val parser = Parser.builder().build() + val document = parser.parse(markdown) + val builder = AnnotatedString.Builder() + processNodes(document, builder) + return builder.toAnnotatedString() +} + +/** + * Recursively processes nodes. We only append a newline if there's a *next sibling*, + * preventing extra blank lines at the very end. + */ +private fun processNodes(node: Node, builder: AnnotatedString.Builder) { + var child = node.firstChild + while (child != null) { + val nextSibling = child.next + when (child) { + is CText -> { + builder.append(child.literal) + } + is Emphasis -> { + val start = builder.length + processNodes(child, builder) + val end = builder.length + builder.addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end) + } + is StrongEmphasis -> { + val start = builder.length + processNodes(child, builder) + val end = builder.length + builder.addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) + } + is Code -> { + // Inline code (single backticks) + val start = builder.length + builder.append(child.literal) + val end = builder.length + // Monospace but keep background transparent for inline code + builder.addStyle( + SpanStyle(fontFamily = FontFamily.Monospace), + start, + end + ) + } + is Link -> { + val start = builder.length + processNodes(child, builder) + val end = builder.length + // Annotate the text with the URL (useful if you want to make it clickable later) + builder.addStringAnnotation( + tag = "URL", + annotation = child.destination, + start = start, + end = end + ) + // Style the link text + builder.addStyle( + SpanStyle(color = Color.Blue, textDecoration = TextDecoration.Underline), + start, + end + ) + } + is Paragraph -> { + // Process paragraph content + processNodes(child, builder) + // Only append a newline if there's another sibling after this paragraph + if (nextSibling != null) { + builder.append("\n") + } + } + is Heading -> { + // Insert a newline before headings (for spacing), unless it's the first node + if (builder.isNotEmpty() && !builder.toString().endsWith("\n")) { + builder.append("\n") + } + val start = builder.length + processNodes(child, builder) + val end = builder.length + // Map heading level to style + val headingStyle = when (child.level) { + 1 -> SpanStyle(fontWeight = FontWeight.Bold, fontSize = 28.sp) + 2 -> SpanStyle(fontWeight = FontWeight.Bold, fontSize = 24.sp) + 3 -> SpanStyle(fontWeight = FontWeight.Bold, fontSize = 20.sp) + 4 -> SpanStyle(fontWeight = FontWeight.Bold, fontSize = 18.sp) + 5 -> SpanStyle(fontWeight = FontWeight.Bold, fontSize = 16.sp) + else -> SpanStyle(fontWeight = FontWeight.Bold, fontSize = 14.sp) + } + builder.addStyle(headingStyle, start, end) + // Only append a newline if there's another sibling + if (nextSibling != null) { + builder.append("\n") + } + } + is BlockQuote -> { + // Optional: style block quotes in italics or different color + val start = builder.length + processNodes(child, builder) + val end = builder.length + builder.addStyle( + SpanStyle(color = Color.Gray, fontStyle = FontStyle.Italic), + start, + end + ) + if (nextSibling != null) { + builder.append("\n") + } + } + is BulletList -> { + var listItem = child.firstChild + while (listItem != null) { + if (listItem is ListItem) { + // Prefix with a bullet + builder.append("• ") + processNodes(listItem, builder) + builder.append("\n") + } + listItem = listItem.next + } + if (nextSibling != null) { + builder.append("\n") + } + } + is OrderedList -> { + var index = child.startNumber + var listItem = child.firstChild + while (listItem != null) { + if (listItem is ListItem) { + builder.append("$index. ") + processNodes(listItem, builder) + builder.append("\n") + index++ + } + listItem = listItem.next + } + if (nextSibling != null) { + builder.append("\n") + } + } + is FencedCodeBlock -> { + // Code block with triple backticks + val start = builder.length + // Insert the code exactly as-is + builder.append(child.literal) + val end = builder.length + // Always dark background, white text + builder.addStyle( + SpanStyle( + fontFamily = FontFamily.Monospace + ), + start, + end + ) + // Only append a newline if there's another sibling + if (nextSibling != null) { + builder.append("\n") + } + } + is IndentedCodeBlock -> { + val start = builder.length + builder.append(child.literal) + val end = builder.length + builder.addStyle( + SpanStyle( + fontFamily = FontFamily.Monospace + ), + start, + end + ) + if (nextSibling != null) { + builder.append("\n") + } + } + is ThematicBreak -> { + builder.append("\n----------------\n") + if (nextSibling != null) { + builder.append("\n") + } + } + is SoftLineBreak -> { + // Usually rendered as a space in Markdown + builder.append(" ") + } + is HardLineBreak -> { + builder.append("\n") + } + else -> { + processNodes(child, builder) + } + } + child = nextSibling + } +} + +/** Quick helper to check if the builder already has text. */ +private fun AnnotatedString.Builder.isNotEmpty(): Boolean { + return this.length > 0 +} + + + diff --git a/app/src/main/java/com/app/assistant/ui/theme/Color.kt b/app/src/main/java/com/app/assistant/ui/theme/Color.kt new file mode 100644 index 0000000..05ffa41 --- /dev/null +++ b/app/src/main/java/com/app/assistant/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.app.assistant.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/app/assistant/ui/theme/Theme.kt b/app/src/main/java/com/app/assistant/ui/theme/Theme.kt new file mode 100644 index 0000000..fa44c69 --- /dev/null +++ b/app/src/main/java/com/app/assistant/ui/theme/Theme.kt @@ -0,0 +1,57 @@ +package com.app.assistant.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun AssistantTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/app/assistant/ui/theme/Type.kt b/app/src/main/java/com/app/assistant/ui/theme/Type.kt new file mode 100644 index 0000000..22515d0 --- /dev/null +++ b/app/src/main/java/com/app/assistant/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.app.assistant.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/java/com/app/assistant/util/Category.kt b/app/src/main/java/com/app/assistant/util/Category.kt new file mode 100644 index 0000000..06c211c --- /dev/null +++ b/app/src/main/java/com/app/assistant/util/Category.kt @@ -0,0 +1,5 @@ +package com.app.assistant.util + +enum class Category { + SONGS, CALL, ALARM, REMINDER, OTHER, NAVIGATION, SETTINGS, WEATHER +} \ No newline at end of file diff --git a/app/src/main/java/com/app/assistant/util/Constants.kt b/app/src/main/java/com/app/assistant/util/Constants.kt new file mode 100644 index 0000000..1d6d051 --- /dev/null +++ b/app/src/main/java/com/app/assistant/util/Constants.kt @@ -0,0 +1,6 @@ +package com.app.assistant.util + +object Constants { + const val MAIN_CONTEXT = "You are my smart assistant who answers just like normal human response, not too long. Don't refer yourself as an AI." + val CATEGORY_CONTEXT = "You are a assistant who can classify commands based on previous conversation, from these categories. "+ enumValues().joinToString(",") { it.name } +". Just say the category name." +} \ No newline at end of file diff --git a/app/src/main/java/com/app/assistant/util/LockState.kt b/app/src/main/java/com/app/assistant/util/LockState.kt new file mode 100644 index 0000000..631b639 --- /dev/null +++ b/app/src/main/java/com/app/assistant/util/LockState.kt @@ -0,0 +1,20 @@ +package com.app.assistant.util + +sealed class LockState { + object None : LockState() // Default state when no task is locked + + data class LockAlarm( + var day: String? = null, // Already processed day (e.g., "tomorrow") + var time: String? = null // Time to be provided + ) : LockState() + + data class LockReminder( + var day: String? = null, // Already processed day (e.g., "tomorrow") + var time: String? = null, // Time to be provided + var context: String? = null // Message for reminder + ) : LockState() + + object LockNavigation : LockState() // No additional data needed + + // Add more categories here as needed +} diff --git a/app/src/main/java/com/app/assistant/viewmodel/MainViewModel.kt b/app/src/main/java/com/app/assistant/viewmodel/MainViewModel.kt new file mode 100644 index 0000000..ac618d3 --- /dev/null +++ b/app/src/main/java/com/app/assistant/viewmodel/MainViewModel.kt @@ -0,0 +1,2028 @@ +package com.app.assistant.viewmodel + +import android.Manifest +import android.Manifest.permission.CALL_PHONE +import android.Manifest.permission.READ_CONTACTS +import android.Manifest.permission.RECORD_AUDIO +import android.annotation.SuppressLint +import android.app.Activity +import android.app.Application +import android.content.ComponentCallbacks2 +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.location.Geocoder +import android.location.LocationManager +import android.media.AudioManager +import android.media.MediaPlayer +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.provider.AlarmClock +import android.provider.ContactsContract +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import android.util.Log +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.platform.SoftwareKeyboardController +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.app.assistant.BuildConfig +import com.app.assistant.Classifier.TextClassifierHelper +import com.app.assistant.db.DynamicConversationRepository +import com.app.assistant.db.SyncStateList +import com.app.assistant.model.Contact +import com.app.assistant.model.Conversation +import com.app.assistant.model.Group +import com.app.assistant.translation.TranslatorManager +import com.app.assistant.util.Category +import com.app.assistant.util.Constants.MAIN_CONTEXT +import com.app.assistant.util.LockState +import com.google.android.gms.common.api.ResolvableApiException +import com.google.android.gms.location.LocationAvailability +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.LocationSettingsRequest +import com.google.android.gms.location.LocationSettingsResponse +import com.google.android.gms.location.Priority +import com.google.android.gms.location.SettingsClient +import com.google.android.gms.tasks.Task +import com.google.mediapipe.tasks.text.textclassifier.TextClassifierResult +import com.google.mlkit.nl.translate.TranslateLanguage +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.Call +import okhttp3.Callback +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException +import java.lang.reflect.Modifier +import java.net.URI +import java.net.URLEncoder +import java.net.UnknownHostException +import java.nio.charset.StandardCharsets +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlin.math.max +import androidx.core.net.toUri + + +class MainViewModel(application: Application, private val speak: Boolean) : AndroidViewModel(application) { + private var globalContext: Context? = application + private val sharedPreferences: SharedPreferences = + application.getSharedPreferences("app_prefs", Application.MODE_PRIVATE) + var question = mutableStateOf("") + // DB reference + private val repository = DynamicConversationRepository(application) + var chatList = SyncStateList(repository) + private set + var currentGroupId: Long = -1L + private set + var languages : List> + var showBottomSheet = mutableStateOf(false) + private set + private val _isLanguageLoading = MutableStateFlow(false) + val isLanguageLoading: StateFlow = _isLanguageLoading + private val _showToastEvent = MutableSharedFlow() + val showToastEvent = _showToastEvent.asSharedFlow() + private val translatorManager = TranslatorManager() + + //For custom ui + private val _isCustomUI = MutableStateFlow(false) + val isCustomUI: StateFlow = _isCustomUI + + //For custom ui half page + private val _isCustomUIHalfPage = MutableStateFlow(false) + val isCustomUIHalfPage : StateFlow = _isCustomUIHalfPage + + // MutableStateFlow for isTranslationEnabled + private val _isTranslationEnabled = MutableStateFlow( + sharedPreferences.getBoolean("is_translation_enabled", false) + ) + val isTranslationEnabled: StateFlow = _isTranslationEnabled + // MutableStateFlow for activeLanguageCode + private val _activeLanguageCode = MutableStateFlow( + sharedPreferences.getString("active_language_code", "en") ?: "en" + ) + private var _isSpeaking = MutableStateFlow(false) + val isSpeaking: StateFlow = _isSpeaking + + private val _isListening = MutableStateFlow(false) + val isListening: StateFlow = _isListening.asStateFlow() + + private val _groupList = MutableStateFlow>(emptyList()) + val groupList: StateFlow> = _groupList.asStateFlow() + + // Text-to-Speech and Translators + lateinit var textToSpeech: TextToSpeech + + //Classifier + private lateinit var classifierHelper: TextClassifierHelper + + //LockState + var lockState: LockState = LockState.None + set(value) { + // Automatically clear any stored data when lockState is set to None + if (value is LockState.None) { + clearLockStateData() + } + field = value + } + + // Classifier listener + private val listener = object : + TextClassifierHelper.TextResultsListener { + override fun onResult( + results: TextClassifierResult, + inferenceTime: Long, + inputText: String, + itemId: Long, + loadingItemId: Long, + speak: Boolean + ) { + processClassifierResponse(inferenceTime, results, inputText, itemId, loadingItemId, speak) + } + + override fun onError(error: String) { + Log.d("Classifier error", "Unable to classify$error") + } + } + + init { + if(speak) { + _isCustomUI.value = true + _isCustomUIHalfPage.value = true + _isListening.value = true + } + initializeTextToSpeech() + languages = getPublicStaticFinalStringsWithNames(TranslateLanguage::class.java) + if(speak) + startSpeechRecognition() + initializeTextClassifier() + initializeTranslator(); + loadGroup(); + } + + fun expandToFullScreen(){ + if(_isCustomUI.value) + _isCustomUI.value = false + if(_isCustomUIHalfPage.value) + _isCustomUIHalfPage.value = false + } + + private fun loadGroup(){ + viewModelScope.launch { + _groupList.value = repository.loadAllGroups().toList() + } + } + + fun loadMessagesFromGroup(groupId: Long){ + viewModelScope.launch{ + val newMessages = repository.loadMessagesForGroup(groupId) + chatList.clear() + chatList.addAll(newMessages) + repository.currentGroupId = groupId + } + } + + private fun initializeTranslator(){ + if(getIsTranslationEnabled() && getActiveLanguageCode() != ""){ + setupTranslator(getActiveLanguageCode()) + } + } + + private fun initializeTextClassifier(){ + // Create the classification helper that will do the heavy lifting + classifierHelper = TextClassifierHelper( + context = getApplication().applicationContext, + listener = listener + ) + classifierHelper.initClassifier() + } + + // Function to update isTranslationEnabled and persist the value + fun updateTranslationEnabled(enabled: Boolean) { + _isTranslationEnabled.value = enabled + viewModelScope.launch { + sharedPreferences.edit().putBoolean("is_translation_enabled", enabled).apply() + } + } + + // Function to get the current value of isTranslationEnabled + fun getIsTranslationEnabled(): Boolean { + return _isTranslationEnabled.value + } + + // Function to update activeLanguageCode and persist the value + fun updateActiveLanguageCode(languageCode: String) { + _activeLanguageCode.value = languageCode + viewModelScope.launch { + sharedPreferences.edit().putString("active_language_code", languageCode).apply() + } + } + + // Function to get the current value of ActiveLanguageCode + fun getActiveLanguageCode(): String { + return _activeLanguageCode.value + } + + fun stopTextToSpeech(){ + textToSpeech.stop() + _isSpeaking.value = false; + } + + private fun getPublicStaticFinalStringsWithNames(clazz: Class<*>): List> { + val publicStaticFinalStringsWithNames = mutableListOf>() + + val fields = clazz.declaredFields + for (field in fields) { + val modifiers = field.modifiers + if (Modifier.isPublic(modifiers) && Modifier.isStatic(modifiers) && Modifier.isFinal(modifiers)) { + if (field.type == String::class.java) { + try { + val name = field.name + val value = field.get(null) as String + publicStaticFinalStringsWithNames.add(Pair(name, value)) + } catch (e: IllegalAccessException) { + e.printStackTrace() + } + } + } + } + + return publicStaticFinalStringsWithNames + } + + private fun initializeTextToSpeech() { + textToSpeech = TextToSpeech(getApplication()) { status -> + if (status == TextToSpeech.SUCCESS) { + Log.d("TextToSpeech", "Initialization Success") + } else { + Log.d("TextToSpeech", "Initialization Failed") + } + } + } + + fun shutdownResources() { + textToSpeech.shutdown() + translatorManager.closeTranslator() + globalContext = null + } + + fun processQuestion( + focusManager: FocusManager? = null, + keyboardController: SoftwareKeyboardController? = null, + context: Context, + speak: Boolean = false + ) { + globalContext = context + val originalQuestion = question.value + focusManager?.clearFocus() + keyboardController?.hide() + question.value = "" + + viewModelScope.launch { + val itemId: Long + val newItem: Conversation = if(getIsTranslationEnabled()){ + Conversation(englishText = "", translatedText = originalQuestion, isMe = true) + + }else{ + Conversation(englishText = originalQuestion, translatedText = "", isMe = true) + } + + chatList.add(newItem) + if(_isCustomUIHalfPage.value) + _isCustomUIHalfPage.value = false + + itemId = newItem.id + + val loadingItem = Conversation(englishText = "", translatedText = "", isMe = false, isLoading = true) + chatList.add(loadingItem) + val loadingItemId = loadingItem.id + //_conversationListUI.add(loadingItem) + + val translatedQuestionInEnglish = if (getIsTranslationEnabled()) { + translatorManager.translateToEnglishSuspend(originalQuestion) ?: originalQuestion + } else { + originalQuestion + } + + val processedQuestion = cleanAndPunctuate(translatedQuestionInEnglish) + chatList.indexOfFirst { it.id == itemId }.takeIf { it != -1 }?.let { index -> + val updatedItem = chatList[index].copy(englishText = processedQuestion) + chatList.set(index, updatedItem) + } + + if(lockState != LockState.None && isNegativeOrNotRequired(processedQuestion)){ + lockState = LockState.None + classifierHelper.classify(processedQuestion, itemId, loadingItemId, speak) + }else{ + when(val state = lockState){ + is LockState.LockAlarm -> handleAlarmLockState(itemId, loadingItemId, speak, state) + is LockState.LockReminder -> handleReminderLockState(itemId, loadingItemId, speak, state) + is LockState.LockNavigation -> TODO() + is LockState.None -> classifierHelper.classify(processedQuestion, itemId, loadingItemId, speak) + } + } + } + } + + private fun processClassifierResponse( + inferenceTime: Long, + results: TextClassifierResult, + inputText: String, + itemId: Long, + loadingItemId: Long, + speak: Boolean + ) { + val categories = results.classificationResult() + .classifications().first() + .categories() + + // Define words for each category using Category enum + val categoryWords = mapOf( + Category.CALL to listOf("call", "phone", "ring", "connect", "need", "get", "dial"), + Category.SONGS to listOf("music", "song", "play", "tune", "listen"), + Category.ALARM to listOf("alarm", "wake", "set", "remind", "morning"), + Category.REMINDER to listOf("remind", "notify", "alert", "remember"), + Category.NAVIGATION to listOf("navigate","find","show","directions","take me to","where is","get to","go to","navigation to","way"), + Category.WEATHER to listOf("sunny", "cloudy", "rain", "temperature", "umbrella", "weather", "outside", "report", "forecast", "carry", "going to", "how is", "will it be"), + Category.SETTINGS to emptyList(), + Category.OTHER to emptyList() // "OTHER" doesn't need specific words + ) + + // Find the category with the highest score + val highestCategory = categories.maxByOrNull { it.score() } + val highestCategoryEnum = highestCategory?.categoryName()?.uppercase()?.let { Category.valueOf(it) } + + // Check if the highest category has specific words to verify + val wordsToMatch = categoryWords[highestCategoryEnum] ?: emptyList() + val containsWord = wordsToMatch.any { word -> word in inputText.lowercase() } + + // Set final category based on word verification + val finalCategory = if (wordsToMatch.isNotEmpty() && !containsWord) { + // Force the category to "OTHER" if no matching words found for the category + categories.firstOrNull { it.categoryName().equals(Category.OTHER.name, ignoreCase = true) } + } else { + // Keep the highest category if matching words found or no words defined + highestCategory + } + + val categoryFromString = getCategoryFromString(finalCategory?.categoryName()?.uppercase() ?: "").toString() + + CallCommand(categoryFromString, itemId, loadingItemId, speak) + + if (finalCategory != null) { + chatList.find{ it.id == itemId }?.category = getCategoryFromString(finalCategory.categoryName()).name + Log.d("Classifier Inference Result", finalCategory.categoryName()) + } + } + + private fun CallCommand( + categoryFromString: String, + itemId: Long, + loadingItemId: Long, + speak: Boolean + ) { + when (categoryFromString) { + Category.CALL.name -> { + Call(itemId, loadingItemId, speak, Category.CALL) + } + + Category.OTHER.name -> { + callAI(loadingItemId, speak, Category.OTHER) + } + + Category.SETTINGS.name -> { + // TODO + callAI(loadingItemId, speak, Category.OTHER) + } + + Category.SONGS.name -> { + Song(itemId, loadingItemId, speak, Category.SONGS) + } + + Category.NAVIGATION.name -> { + Navigation(itemId, loadingItemId, speak, Category.NAVIGATION) + } + + Category.WEATHER.name -> { + Weather(itemId, loadingItemId, speak, Category.WEATHER) + } + + Category.REMINDER.name -> { + Reminder(itemId, loadingItemId, speak, Category.REMINDER) + } + + Category.ALARM.name -> { + Alarm(itemId, loadingItemId, speak, Category.ALARM) + } + } + } + + // Function to map a category name to a Category enum + private fun getCategoryFromString(categoryName: String): Category { + return Category.entries.find { it.name == categoryName.uppercase() } ?: Category.OTHER + } + + private fun callAI(loadingItemId: Long, speak: Boolean, category: Category) { + viewModelScope.launch { + val response = withContext(Dispatchers.IO) { + extractFromAI(sendQuestionToAPI_LM(MAIN_CONTEXT)) + } + + processResponse(response, loadingItemId, speak, category = category) + } + } + + // Suspending function for translation + private suspend fun TranslatorManager.translateToEnglishSuspend(text: String): String? { + return suspendCoroutine { continuation -> + translateToEnglish(text) { translatedText -> + continuation.resume(translatedText) + } + } + } + + private fun cleanAndPunctuate(input: String): String { + val trimmedInput = input.trim() + + // Return immediately if the input is empty + if (trimmedInput.isEmpty()) return "" + + // Check if the last character is already a punctuation (either "." or "?") + val lastChar = trimmedInput.last() + return if (lastChar == '.' || lastChar == '?') { + trimmedInput + } else { + // List of common question words + val questionWords = listOf("wh", "how", "can", "do", "is", "are", "does", "did", "will", "could", "should", "would") + if (questionWords.any { trimmedInput.lowercase().startsWith(it) }) { + "$trimmedInput?" + } else { + trimmedInput + } + } + } + + private suspend fun processResponse( + response: String?, + loadingItemId: Long, + speak: Boolean, + category: Category, + contentURL: String ="", + navigationURI: URI = URI("") + ) { + response?.let { + withContext(Dispatchers.Main) { + + val plaintext = markdownToPlainText(it) + + if (getIsTranslationEnabled()) { + translatorManager.translateFromEnglish(plaintext) { translatedText -> + val finalText = translatedText ?: plaintext + chatList.removeAt(chatList.indexOfFirst { it.id == loadingItemId }) + addConversationItem(plaintext, finalText, false, category, contentURL, navigationURI) + if(speak) + speakResponse(finalText) + } + } else { + chatList.removeAt(chatList.indexOfFirst { it.id == loadingItemId }) + addConversationItem(response, "", false, category, contentURL, navigationURI) + if(speak) + speakResponse(plaintext) + } + } + } + } + + private fun addConversationItem(englishText: String, translatedText: String, isUser: Boolean, category: Category, contentURL: String ="", navigationURI: URI = URI("")) { + val conversation = Conversation(englishText = englishText, translatedText = translatedText, isMe = isUser, category = category.name, contentURL = contentURL, navigationURI = navigationURI) + chatList.add(conversation) + if(chatList.size<=2) + loadGroup() + } + + private fun speakResponse( + plaintext: String + ) { + textToSpeech.speak(plaintext, TextToSpeech.QUEUE_FLUSH, null, "left_for_now_id") + textToSpeech.setOnUtteranceProgressListener(object : UtteranceProgressListener() { + + override fun onDone(utteranceId: String?) { + _isSpeaking.value = false + } + + @Deprecated("Deprecated in Java") + override fun onError(utteranceId: String?) { + _isSpeaking.value = false + } + + override fun onStart(utteranceId: String?) { + _isSpeaking.value = true + } + }) + } + + // Function to clear the conversation list + fun clearBoxes() { + viewModelScope.launch { + chatList.clearAll().join() + loadGroup() + } + } + + //Function to start new chat + fun newChat(){ + viewModelScope.launch { + chatList.clear() + repository.currentGroupId = -1L + } + } + + fun deleteMessage(index: Int){ + if(index!=null){ + chatList.removeAt(index) + } + } + + private suspend fun youtubeApiCall(query: String): Pair{ + val client = OkHttpClient() + val deferredResult = CompletableDeferred>() + + val urlBuilder = "https://www.googleapis.com/youtube/v3/search".toHttpUrlOrNull()?.newBuilder() + ?.addQueryParameter("key", BuildConfig.YOUTUBE_API_KEY) + ?.addQueryParameter("q", query) + ?.addQueryParameter("type", "video") + ?.addQueryParameter("part", "snippet") + ?.addQueryParameter("maxResults","1") + ?.build() + + val request = Request.Builder() + .url(urlBuilder.toString()) + .build() + + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + println("Request failed: $e") + deferredResult.completeExceptionally(e) + } + + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful) { + val body = response.body?.string() + if(body != null) + deferredResult.complete(extractVideoIdAndThumbnail(body)) + else + deferredResult.complete(Pair("","")) + } else { + println("Request unsuccessful: ${response.code}") + if(response.code == 403) + deferredResult.complete(Pair("Missing API Key","")) + else + deferredResult.complete(Pair("","")) + } + response.close(); + } + }) + + return deferredResult.await() + } + + fun extractVideoIdAndThumbnail(jsonString: String): Pair { + 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("", "") + + // Make sure it's a video, not a channel + 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) + } + } + + return Pair("", "") // Return empty if no valid video is found + } + + private suspend fun sendToAI(messagesArray: JSONArray): String { + val client = OkHttpClient.Builder() + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() + + 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?) + } + + val requestBody = requestBodyJson.toString() + .toRequestBody("application/json".toMediaTypeOrNull()) + + val request = Request.Builder() + .url("https://api.groq.com/openai/v1/chat/completions") + .addHeader("Authorization", "Bearer ${BuildConfig.GROQ_API_KEY}") + .addHeader("Content-Type", "application/json") + .post(requestBody) + .build() + + return withContext(Dispatchers.IO) { + try { + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + if (response.code == 401) { + // API key is likely missing or invalid + return@withContext """{"choices":[{"message":{"content":"Your chat API key is missing or invalid."}}]}""" + } + throw IOException("Unexpected code $response") + } + response.body!!.string() + } + } catch (e: UnknownHostException) { + """{"choices":[{"message":{"content":"Seems this device is offline. Maybe try checking data connection."}}]}""" + } catch (e: Exception) { + e.printStackTrace() + """{"choices":[{"message":{"content":"Seems some issue in my server. Please try again."}}]}""" + } + } + } + + 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") + + // Regex to remove tags and the content inside + val thinkTagRegex = Regex(".*?", RegexOption.DOT_MATCHES_ALL) + content = content.replace(thinkTagRegex, "") // Remove ... + + // Remove any standalone opening or closing tags + content = content.replace("", "").replace("", "") + + return content.trim() // Clean and return the result + } else { + return "" + } + } else { + return "Seems some issue in my server. Please try again." + } + } catch (e: Exception) { + e.printStackTrace() + return null + } + } + + + private suspend fun sendQuestionToAPI_LM(system_context: String): String{ + val messagesArray = JSONArray() + + messagesArray.put(JSONObject().put("role", "system").put("content", system_context)) + + if(chatList.isNotEmpty()) { + for (item in chatList) { + val role = if (item.isMe) "user" else "assistant" + val messageObject = JSONObject().put("role", role).put("content", item.englishText) + messagesArray.put(messageObject) + } + } + val response = sendToAI(messagesArray) + return response + } + + private fun startSpeechRecognition() { + globalContext?.let { context -> + startSpeechToText( + context, + onResult = { recognizedText -> + question.value = recognizedText + if (question.value.isNotEmpty()) { + processQuestion(context = context, speak = true) // Process the question + } + }, + onPartialResult = { recognizedText -> + question.value = recognizedText + } + ) + } + } + + fun startSpeechToText(context: Context, onResult: (String) -> Unit, onPartialResult: (String) -> Unit) { + if (ContextCompat.checkSelfPermission(context, RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { + // Request the RECORD_AUDIO permission. + ActivityCompat.requestPermissions(context as Activity, arrayOf(RECORD_AUDIO), 101) + return + } + + // Ensure Bluetooth SCO is started for better compatibility with Bluetooth headsets + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + audioManager.mode = AudioManager.MODE_IN_CALL + audioManager.isBluetoothScoOn = true + audioManager.startBluetoothSco() + + val originalRingerMode = audioManager.ringerMode // Save the current ringer mode + + + if(textToSpeech.isSpeaking) + textToSpeech.stop() + + val speechRecognizer = SpeechRecognizer.createSpeechRecognizer(context) + val speechRecognizerIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) + speechRecognizerIntent.putExtra( + RecognizerIntent.EXTRA_LANGUAGE_MODEL, + RecognizerIntent.LANGUAGE_MODEL_FREE_FORM + ) + speechRecognizerIntent.putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true); + if (getIsTranslationEnabled()) + speechRecognizerIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE,getLocaleCode(getActiveLanguageCode())) + else + speechRecognizerIntent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault()) + + speechRecognizer.setRecognitionListener(object : RecognitionListener { + override fun onReadyForSpeech(bundle: Bundle?) { + // Put the phone in vibrate mode temporarily to suppress default beep + if(originalRingerMode == AudioManager.RINGER_MODE_NORMAL) + audioManager.ringerMode = AudioManager.RINGER_MODE_VIBRATE + } + override fun onBeginningOfSpeech() { + _isListening.value = true + } + override fun onRmsChanged(v: Float) {} + override fun onBufferReceived(bytes: ByteArray?) {} + override fun onEndOfSpeech() { + _isListening.value = false + if(originalRingerMode == AudioManager.RINGER_MODE_NORMAL) { + CoroutineScope(Dispatchers.Default).launch { + delay(800) + audioManager.ringerMode = originalRingerMode + } + } + } + + override fun onError(errorCode: Int) { + val message = when (errorCode) { + SpeechRecognizer.ERROR_AUDIO -> "Audio recording error" + SpeechRecognizer.ERROR_CLIENT -> "Client side error" + SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Insufficient permissions" + SpeechRecognizer.ERROR_NETWORK -> "Network error" + SpeechRecognizer.ERROR_NO_MATCH -> "No match found" + SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognition service is busy" + SpeechRecognizer.ERROR_SERVER -> "Server error" + SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Speech input timeout" + else -> "Unknown error" + } + //Toast.makeText(context, "Error occurred: $message", Toast.LENGTH_SHORT).show() + _isListening.value = false + if(originalRingerMode == AudioManager.RINGER_MODE_NORMAL) { + CoroutineScope(Dispatchers.Default).launch { + delay(800) + audioManager.ringerMode = originalRingerMode + } + } + } + + override fun onResults(bundle: Bundle?) { + _isListening.value = false + val recognizedText = bundle?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.get(0) ?: "" + onResult(recognizedText) + } + + override fun onPartialResults(bundle: Bundle) { + val recognizedText = bundle.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.get(0) ?: "" + onPartialResult(recognizedText) + } + override fun onEvent(i: Int, bundle: Bundle?) {} + + }) + speechRecognizer.startListening(speechRecognizerIntent) + + // Clean up resources when done + context.registerComponentCallbacks(object : ComponentCallbacks2 { + override fun onTrimMemory(level: Int) { + if (level == ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) { + // Stop Bluetooth SCO + audioManager.stopBluetoothSco() + audioManager.isBluetoothScoOn = false + speechRecognizer.destroy() + } + } + + override fun onConfigurationChanged(newConfig: Configuration) {} + override fun onLowMemory() {} + }) + } + + fun playCustomToneWithMutedBeep(context: Context, customToneResId: Int) { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val originalRingerMode = audioManager.ringerMode // Save the current ringer mode + + if(originalRingerMode == AudioManager.RINGER_MODE_NORMAL) { + + // Put the phone in vibrate mode temporarily to suppress default beep + audioManager.ringerMode = AudioManager.RINGER_MODE_VIBRATE + + // Play custom tone + val mediaPlayer = MediaPlayer.create(context, customToneResId) + mediaPlayer.setOnCompletionListener { + // Restore the original ringer mode after the tone finishes playing + audioManager.ringerMode = originalRingerMode + mediaPlayer.release() + } + mediaPlayer.start() + } + } + + @SuppressLint("ServiceCast") + fun MuteBeep(context: Context) { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + //val vibrator = getSystemService(context, Vibrator::class.java) + + val originalRingerMode = audioManager.ringerMode // Save the current ringer mode + + // Put the phone in vibrate mode temporarily to suppress default beep + audioManager.ringerMode = AudioManager.RINGER_MODE_VIBRATE + + // Vibrate for minimal duration +// vibrator?.let { +// if (Build.VERSION.SDK_INT >= 26) { +// it.vibrate(VibrationEffect.createOneShot(100, VibrationEffect.DEFAULT_AMPLITUDE)) +// } else { +// @Suppress("DEPRECATION") +// it.vibrate(100) +// } +// } + + // Restore the original ringer mode after a short delay + Handler(Looper.getMainLooper()).postDelayed({ + audioManager.ringerMode = originalRingerMode + }, 800) // Wait for a short time to ensure vibration is completed + } + + + fun getLocaleCode(languageCode: String): String { + val languageCountryMapping = mapOf( + "af" to "ZA", "sq" to "AL", "ar" to "SA", "be" to "BY", "bg" to "BG", + "bn" to "BD", "ca" to "ES", "zh" to "CN", "hr" to "HR", "cs" to "CZ", + "da" to "DK", "nl" to "NL", "en" to "US", "eo" to "EO", "et" to "EE", + "fi" to "FI", "fr" to "FR", "gl" to "ES", "ka" to "GE", "de" to "DE", + "el" to "GR", "gu" to "IN", "ht" to "HT", "he" to "IL", "hi" to "IN", + "hu" to "HU", "is" to "IS", "id" to "ID", "ga" to "IE", "it" to "IT", + "ja" to "JP", "kn" to "IN", "ko" to "KR", "lt" to "LT", "lv" to "LV", + "mk" to "MK", "mr" to "IN", "ms" to "MY", "mt" to "MT", "no" to "NO", + "fa" to "IR", "pl" to "PL", "pt" to "BR", "ro" to "RO", "ru" to "RU", + "sk" to "SK", "sl" to "SI", "es" to "ES", "sv" to "SE", "sw" to "KE", + "tl" to "PH", "ta" to "IN", "te" to "IN", "th" to "TH", "tr" to "TR", + "uk" to "UA", "ur" to "PK", "vi" to "VN", "cy" to "GB" + ) + + // Retrieve the country code based on the language or fall back to Locale's default country + val countryCode = languageCountryMapping[languageCode] ?: Locale.getDefault().country + return "$languageCode-$countryCode" + } + + fun markdownToPlainText(markdown: String): String { + var plainText = markdown + + // Basic Markdown stripping (simplified) + plainText = plainText.replace(Regex("#+ "), "") // Remove headers + plainText = plainText.replace(Regex("\\*\\*(.*?)\\*\\*"), "$1") // Bold + plainText = plainText.replace(Regex("\\*(.*?)\\*"), "$1") // Italic + plainText = plainText.replace(Regex("!\\[.*?\\]\\(.*?\\)"), "") // Images + plainText = plainText.replace(Regex("\\[.*?\\]\\(.*?\\)"), "") // Links + plainText = plainText.replace(Regex("`{1,3}.*?`{1,3}"), "") // Inline code + plainText = plainText.replace(Regex("\\n\\n"), "\n") // Simplify newlines + + return plainText.trim() + } + + override fun onCleared() { + super.onCleared() + shutdownResources() + } + + // Update the selected item + fun onItemSelected(selectedLanguageCode: String) { + _isLanguageLoading.value = true + setupTranslator(selectedLanguageCode, true) + triggerToast("Downloading translation model."); + } + + private fun setupTranslator(selectedLanguageCode: String, showCompletionToast: Boolean = false) { + translatorManager.setupTranslators(selectedLanguageCode) { success -> + if (success) { + updateActiveLanguageCode(selectedLanguageCode) + _isLanguageLoading.value = false + showBottomSheet.value = false + if(showCompletionToast) + triggerToast("Download completed, its recommended to use selected language keyboard."); + } else { + _isLanguageLoading.value = false + triggerToast("Something went wrong, model download failed.") + } + } + } + + private fun triggerToast(message: String) { + viewModelScope.launch { + _showToastEvent.emit(message) + } + } + + private fun isNegativeOrNotRequired(phrase: String): Boolean { + val negativePatterns = listOf( + Regex("\\bno\\b", RegexOption.IGNORE_CASE), + Regex("\\bnah\\b", RegexOption.IGNORE_CASE), + Regex("\\bnot\\b.*", RegexOption.IGNORE_CASE), // Matches "Not now", "Not anymore", etc. + Regex("\\bnever\\b.*", RegexOption.IGNORE_CASE), // Matches "Never mind", "Never again", etc. + Regex("\\bforget\\b.*", RegexOption.IGNORE_CASE), // Matches "Forget it", "Forget about it" + Regex("\\bleave\\b.*", RegexOption.IGNORE_CASE), // Matches "Leave it" + Regex("\\bdrop\\b.*", RegexOption.IGNORE_CASE), // Matches "Drop it" + Regex("\\bcancel\\b", RegexOption.IGNORE_CASE), + Regex("\\babort\\b", RegexOption.IGNORE_CASE), + Regex("\\bstop\\b", RegexOption.IGNORE_CASE), + Regex("\\bquit\\b", RegexOption.IGNORE_CASE), + Regex("\\bdisregard\\b", RegexOption.IGNORE_CASE), + Regex("changed my mind", RegexOption.IGNORE_CASE), + Regex("don't bother", RegexOption.IGNORE_CASE), + Regex("let it go", RegexOption.IGNORE_CASE), + Regex("scratch that", RegexOption.IGNORE_CASE), + Regex("just kidding", RegexOption.IGNORE_CASE), + ) + + return negativePatterns.any { it.containsMatchIn(phrase) } + } + + // Function to clear data in lock states + private fun clearLockStateData() { + // Reset specific fields for all lock states + if (lockState is LockState.LockAlarm) { + (lockState as LockState.LockAlarm).day = null + } + // Add similar reset logic for other lock states if needed in the future + } + + //For call + private fun Call(itemId: Long, loadingItemId: Long, speak: Boolean, category: Category){ + if(chatList.isNotEmpty()) { + val prompt = chatList.find { it.id == itemId }?.englishText?.replace("\\p{Punct}+".toRegex(), "")?:return + val permissions = arrayOf(READ_CONTACTS, CALL_PHONE) + val requiredPermissions = permissions.filter { + ContextCompat.checkSelfPermission(globalContext!!, it) != PackageManager.PERMISSION_GRANTED + } + + if (requiredPermissions.isNotEmpty()) { + ActivityCompat.requestPermissions( + globalContext as Activity, + requiredPermissions.toTypedArray(),102 + ) + viewModelScope.launch { + processResponse(getRandomResponse(ResponseStrings.permissionContactsCall), loadingItemId, speak, Category.OTHER) + } + return + } + + viewModelScope.launch { + val (success, result) = searchContactAndMakeCall(prompt) + + if (success) { + val contact = result as Contact + processResponse(contact.name, loadingItemId, false, category, navigationURI = URI("tel:${contact.phoneNumber.replace(" ","")}")) + } else { + processResponse(result.toString(), loadingItemId, speak, Category.OTHER) + } + } + } + } + + @SuppressLint("Range") + private suspend fun searchContactAndMakeCall(prompt: String): Pair { + val contentResolver = getApplication().contentResolver + + val contactList = mutableListOf() + + withContext(Dispatchers.IO) { + val cursor = contentResolver.query( + ContactsContract.Data.CONTENT_URI, + arrayOf( + ContactsContract.Data.CONTACT_ID, + ContactsContract.Data.DISPLAY_NAME, + ContactsContract.CommonDataKinds.Phone.NUMBER + ), + "${ContactsContract.Data.MIMETYPE} =? AND ${ContactsContract.Data.CONTACT_ID} IS NOT NULL", + arrayOf(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE), + null + ) + + cursor?.use { contactCursor -> + while (contactCursor.moveToNext()) { + val contactId = contactCursor.getString(contactCursor.getColumnIndex(ContactsContract.Data.CONTACT_ID)) + val name = contactCursor.getString(contactCursor.getColumnIndex(ContactsContract.Data.DISPLAY_NAME)) + val phoneNumber = contactCursor.getString(contactCursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)) + contactList.add(Contact(contactId, name, phoneNumber)) + } + } + } + + val contactName = cleanAndExtractName(prompt) + + val bestMatch = findBestMatch(contactName, contactList) + + if (bestMatch != null) { + try { + val callIntent = Intent(Intent.ACTION_CALL) + callIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + callIntent.data = Uri.parse("tel:${bestMatch.phoneNumber}") + globalContext?.startActivity(callIntent) + return Pair(true, bestMatch) + } catch (e: Exception) { + Log.e("MainViewModel", "Error making call:", e) + return Pair(false, "Sorry, failed to make call. Please try again.") + } + } else { + return Pair(false, "I cannot find such contact, please try again.") + } + } + + private fun cleanAndExtractName(command: String): String { + val stopWords = setOf("can", "you", "make", "call", "dial", "number", "to", "please", "the", "a", "on") + val words = command.lowercase().trim().split(" ") + return words.filter { !stopWords.contains(it) }.joinToString(" ") + } + + private fun findBestMatch(inputString: String, nameList: List, similarityThreshold: Double = 0.7): Contact? { + if(inputString.isEmpty()){ + return null + } + val inputLower = inputString.lowercase().trim() + var exactMatch: Contact? = null + var bestSubstringMatch: Contact? = null + var bestFuzzyMatch: Contact? = null + var bestFuzzyScore = 0.0 + + for (contact in nameList) { + val nameLower = contact.name.lowercase().trim() + + // Check for exact match + if (inputLower == nameLower) { + // Return immediately if there's an exact match + return contact + } + + // Check for exact match within tokens + val tokens = nameLower.split(" ") + for (token in tokens) { + when { + inputLower == token -> { + exactMatch = contact // Keep track of the exact match + } + token.contains(inputLower) -> { + bestSubstringMatch = contact // Keep track of the substring match if no exact match is found + } + else -> { + // Perform fuzzy matching as a last resort + val similarityScore = stringSimilarity(inputLower, token) + if (similarityScore > bestFuzzyScore) { + bestFuzzyScore = similarityScore + bestFuzzyMatch = contact + } + } + } + } + } + + // Return in order of priority: exact match, substring match, fuzzy match + return when { + exactMatch != null -> exactMatch + bestSubstringMatch != null -> bestSubstringMatch + bestFuzzyScore >= similarityThreshold -> bestFuzzyMatch + else -> null // No match found if similarity score is too low + } + } + + private fun stringSimilarity(a: String, b: String): Double { + val matcher = a.commonPrefixWith(b).length.toDouble() / max(a.length, b.length).toDouble() + return matcher + } + //End Call + + //For Song + private fun Song(itemId: Long, loadingItemId: Long, speak: Boolean, category: Category) { + try { + val prompt = chatList.find { it.id == itemId }?.englishText?.replace("\\p{Punct}+".toRegex(), "")?: return + if (!prompt.lowercase().contains("play ")) { + viewModelScope.launch { + processResponse(getRandomResponse(ResponseStrings.songNotFound),loadingItemId, speak, + Category.OTHER) + } + return + } + val searchQuery = prompt.lowercase().substringAfter("play").trim() + viewModelScope.launch { + val (videoId, thumbnailUrl) = youtubeApiCall(searchQuery) + if (videoId.isEmpty()) { + processResponse(getRandomResponse(ResponseStrings.songNotFound), loadingItemId, speak, Category.OTHER) + } else if(videoId == "Missing API Key"){ + val intent = Intent(Intent.ACTION_SEARCH) + intent.setPackage("com.google.android.youtube") + intent.putExtra("query", searchQuery) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + globalContext?.startActivity(intent) + processResponse("Your Youtube API key is missing or invalid.", loadingItemId, speak, Category.OTHER) + } else { + val intent = Intent(Intent.ACTION_VIEW, "http://www.youtube.com/watch?v=$videoId".toUri()) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + globalContext?.startActivity(intent) + processResponse("Playing $searchQuery", loadingItemId, speak, category, thumbnailUrl, URI("http://www.youtube.com/watch?v=$videoId")) + } + } + } catch (e: Exception) { + Log.d("Exception", e.message.toString()) + viewModelScope.launch { + processResponse(getRandomResponse(ResponseStrings.genericError),loadingItemId, speak, Category.OTHER) + } + } + } + //End Song + + //For Navigation + private fun Navigation(itemId: Long, loadingItemId: Long, speak: Boolean, category: Category){ + try { + if (chatList.isNotEmpty()) { + val prompt = chatList.find { it.id == itemId }?.englishText?.replace("\\p{Punct}+".toRegex(), "")?:return + val location = extractLocation(prompt) + viewModelScope.launch { + if (location!=null) { + val gmmIntentUri = + Uri.parse("google.navigation:q=$location") + val mapIntent = Intent(Intent.ACTION_VIEW, gmmIntentUri) + mapIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + mapIntent.setPackage("com.google.android.apps.maps") + globalContext?.startActivity(mapIntent) + val encodedURI = URLEncoder.encode(location, StandardCharsets.UTF_8.toString()) + processResponse("Navigating to $location.", loadingItemId, speak, category, navigationURI = URI("google.navigation:q=$encodedURI")) + }else{ + processResponse(getRandomResponse(ResponseStrings.locationNotFound), loadingItemId, speak, Category.OTHER) + } + } + }else{ + viewModelScope.launch { + processResponse(getRandomResponse(ResponseStrings.locationNotFound), loadingItemId, speak, Category.OTHER) + } + } + }catch (e: Exception){ + Log.d("Exception", e.message.toString()) + viewModelScope.launch { + processResponse(getRandomResponse(ResponseStrings.genericError), loadingItemId, speak, Category.OTHER) + } + } + } + + private fun extractLocation(command: String): String? { + // Regex pattern to capture location phrases based on patterns observed in the examples + val regex = Regex( + "(?<=to |find |show me |give me directions to |navigate me to |how do I get to |I'm on my way to |start a navigation to |where is |help me find |traffic like on the way to |way to )" + + "([A-Za-z0-9\\s&]+)" + ) + + // Attempt to find the location in the command string + val matchResult = regex.find(command) + return matchResult?.value?.trim() + } + //End Navigation + + //For Weather + private fun Weather(itemId: Long, loadingItemId: Long, speak: Boolean, category: Category){ + try { + if(chatList.isNotEmpty()){ + val prompt = chatList.find { it.id == itemId }?.englishText?.replace("\\p{Punct}+".toRegex(), "")?: return + val location = extractPlaceName(prompt) + if(location.isNotEmpty()){ + viewModelScope.launch { + val coordinates = getCoordinatesFromCity(location) + if(coordinates!=null) { + val (lat, long) = coordinates + val weatherData = getWeatherData(lat, long) + if(weatherData!=null){ + weatherData.remove("latitude"); + weatherData.remove("longitude"); + val response = withContext(Dispatchers.IO) { + extractFromAI(sendQuestionToAI(prompt, weatherData.toString())) + } + if(!response.isNullOrEmpty()){ + processResponse(response, loadingItemId, speak, category, navigationURI = URI("https://www.google.com/search?q=weather+${location}")) + } + }else{ + processResponse(getRandomResponse(ResponseStrings.weatherReportUnavailable), loadingItemId, speak, Category.OTHER) + } + }else{ + processResponse(getRandomResponse(ResponseStrings.locationNotFound), loadingItemId, speak, Category.OTHER) + } + } + }else{ + if (globalContext?.let { ContextCompat.checkSelfPermission(it, Manifest.permission.ACCESS_COARSE_LOCATION) } != PackageManager.PERMISSION_GRANTED) { + // Request permission if it hasn't been granted + ActivityCompat.requestPermissions( + globalContext as Activity, + arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION), + 103 + ) + viewModelScope.launch { + processResponse(getRandomResponse(ResponseStrings.permissionLocation), loadingItemId, speak, Category.OTHER) + } + }else{ + val locationManager = globalContext?.getSystemService(Context.LOCATION_SERVICE) as LocationManager + val gpsEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) + + if (!gpsEnabled) { + promptEnableLocation() + viewModelScope.launch { + processResponse(getRandomResponse(ResponseStrings.locationServiceOff), loadingItemId, speak, Category.OTHER) + } + } else { + // GPS is on and permission is granted + val fusedLocationClient = LocationServices.getFusedLocationProviderClient(globalContext!!) + // Create a LocationRequest + val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000).setWaitForAccurateLocation(true).setMaxUpdates(1).build() + // Create a LocationCallback + val locationCallback = object : LocationCallback() { + override fun onLocationResult(locationResult: LocationResult) { + val location = locationResult.lastLocation + if (location != null) { + val lat = location.latitude + val long = location.longitude + val city = getCityNameFromLocation(lat, long) + // Process the location + viewModelScope.launch { + val weatherData = getWeatherData(lat, long) + if(weatherData!=null){ + weatherData.remove("latitude"); + weatherData.remove("longitude"); + val response = withContext(Dispatchers.IO) { + extractFromAI(sendQuestionToAI(prompt, weatherData.toString())) + } + if(!response.isNullOrEmpty()){ + processResponse(response, loadingItemId, speak, Category.WEATHER, navigationURI = URI("https://www.google.com/search?q=weather+${URLEncoder.encode(city, StandardCharsets.UTF_8.toString())}")) + } + }else{ + processResponse(getRandomResponse(ResponseStrings.weatherReportUnavailable), loadingItemId, speak, Category.OTHER) + } + } + } else { + // If location is null, handle the failure + viewModelScope.launch { + processResponse(getRandomResponse(ResponseStrings.weatherReportUnavailable),loadingItemId, speak, + Category.OTHER) + } + } + // Remove location updates after receiving a result + fusedLocationClient.removeLocationUpdates(this) + } + + override fun onLocationAvailability(locationAvailability: LocationAvailability) { + if (!locationAvailability.isLocationAvailable) { + // Handle the case where location is unavailable + viewModelScope.launch { + processResponse(getRandomResponse(ResponseStrings.locationUnknownSuggestCity),loadingItemId, speak, + Category.OTHER) + } + } + } + } + // Request a single location update + fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, Looper.getMainLooper()) + } + } + } + } + }catch (e: Exception){ + Log.d("Exception", e.message.toString()) + viewModelScope.launch { + processResponse(getRandomResponse(ResponseStrings.genericError), loadingItemId, speak, Category.OTHER) + } + } + } + + private fun extractPlaceName(inputText: String): String { + // Pattern to match place names after "in" or "for", excluding trailing words like "today", "tomorrow", etc. + val pattern = Regex("\\b(?:in|for)\\s+([A-Za-z]+(?:\\s+[A-Za-z]+)*)(?=\\b(?:\\s+(today|now|tomorrow|report|forecast|currently|evening|morning|afternoon)\\b|\\s*$))") + + // Search for a match in the input text + val match = pattern.find(inputText) + + // Check if the captured group does not match excluded words (e.g., "tomorrow") and return it + val placeName = match?.groups?.get(1)?.value?.trim() + return if (placeName != null && !placeName.lowercase().matches(Regex("today|now|tomorrow|report|forecast|currently|evening|morning|afternoon"))) { + placeName + } else { + "" + } + } + + private suspend fun getCoordinatesFromCity(cityName: String): Pair? { + val client = OkHttpClient() + val deferredResult = CompletableDeferred?>() + + val urlBuilder = "https://geocoding-api.open-meteo.com/v1/search".toHttpUrlOrNull()?.newBuilder() + ?.addQueryParameter("name", cityName) + ?.build() + + val request = Request.Builder() + .url(urlBuilder.toString()) + .build() + + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + println("Request failed: $e") + deferredResult.completeExceptionally(e) + } + + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful) { + val body = response.body?.string()?: return + deferredResult.complete(extractCoordinates(body, cityName)) + } else { + println("Request unsuccessful: ${response.code}") + deferredResult.cancel() + } + } + }) + + return deferredResult.await() + } + + private fun extractCoordinates(body: String, cityName: String): Pair? { + val jsonObject = JSONObject(body) + var latitude: Double? = null + var longitude: Double? = null + + try { + + val results = jsonObject.getJSONArray("results") + + 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") + } + }catch (e: Exception){ + e.message?.let { Log.d("extractCoordinates Excepetion", it) } + } + + return if (latitude!= null && longitude!= null) Pair(latitude, longitude) else null + } + + private fun getCityNameFromLocation(latitude: Double, longitude: Double): String? { + val geocoder = globalContext?.let { Geocoder(it, Locale.getDefault()) } + return try { + val addresses = geocoder?.getFromLocation(latitude, longitude, 1) + if (addresses?.isNotEmpty() == true) { + addresses[0].locality // This gives the city name + } else { + null + } + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + private suspend fun getWeatherData(latitude: Double, longitude: Double): JSONObject? { + val client = OkHttpClient() + val deferredResult = CompletableDeferred() + + val urlBuilder = "https://api.open-meteo.com/v1/forecast".toHttpUrlOrNull()?.newBuilder() + ?.addQueryParameter("latitude", latitude.toString()) + ?.addQueryParameter("longitude", longitude.toString()) + ?.addQueryParameter("daily", "temperature_2m_max,temperature_2m_min,precipitation_sum,windspeed_10m_max") + ?.addQueryParameter("hourly", "temperature_2m,precipitation,windspeed_10m") + ?.addQueryParameter("timezone", "auto") + ?.addQueryParameter("forecast_days", "10") + ?.build() + + val request = Request.Builder() + .url(urlBuilder.toString()) + .build() + + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + println("Weather data request failed: $e") + deferredResult.complete(null) + } + + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful) { + val body = response.body?.string()?: return + try { + deferredResult.complete(JSONObject(body)) + } catch (e: JSONException) { + println("Error parsing JSON: $e") + deferredResult.complete(null) + } + } else { + println("Weather data request unsuccessful: ${response.code}") + deferredResult.complete(null) + } + } + }) + + return deferredResult.await() + } + + private fun promptEnableLocation() { + val locationRequest = LocationRequest.Builder(100, 10000) // 10-second interval + .setMinUpdateIntervalMillis(5000) // Minimum interval between updates + .build() + + val builder = LocationSettingsRequest.Builder().addLocationRequest(locationRequest) + val settingsClient: SettingsClient = LocationServices.getSettingsClient(globalContext!!) + val task: Task = settingsClient.checkLocationSettings(builder.build()) + + task.addOnFailureListener { exception -> + if (exception is ResolvableApiException) { + // Show the native dialog to turn on GPS + try { + exception.startResolutionForResult(globalContext as Activity, 104) + } catch (sendEx: IntentSender.SendIntentException) { + Log.d("Location", "Error showing location settings dialog: ${sendEx.message}") + } + } + } + } + + private suspend fun sendQuestionToAI(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 = "You are a smart weather assistant, up-to-date with the current date and time, 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 response = sendToAI(messagesArray) + return response + } + //End Weather + + //For Alarm + private fun Alarm(itemId: Long, loadingItemId: Long, speak: Boolean, category: Category){ + try { + if (chatList.isNotEmpty()) { + val prompt = chatList.find { it.id == itemId }?.englishText?.replace("[.?!]+".toRegex(), "")?:return + + val dayRegex = "(?i)\\b(today|tomorrow|next week|next weekend|((Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)( morning| evening| night| afternoon)?))\\b".toRegex(RegexOption.IGNORE_CASE) + + val timeRegex = "(?i)\\b(\\d{1,2})(:(\\d{2}))?\\s*(AM|PM)?\\b".toRegex(RegexOption.IGNORE_CASE) + + val relativeTimeRegex = "(?i)\\b(\\d+)?\\s*(?:and\\s*(half))?\\s*(hours?|hrs?|mins?|minutes?|seconds?|secs?)\\s*(?:and\\s*(\\d+))?\\s*(mins?|minutes?|secs?|seconds?)?\\s*(from now|later|next)?\\b".toRegex(RegexOption.IGNORE_CASE) + + + // Extract day, time, and relative time from the prompt + val dayMatch = dayRegex.find(prompt)?.value?.lowercase(Locale.ROOT) + val timeMatch = timeRegex.find(prompt) + val relativeTimeMatch = relativeTimeRegex.find(prompt) + + if(timeMatch == null && relativeTimeMatch == null){ + viewModelScope.launch { + lockState = LockState.LockAlarm(day = dayMatch) + processResponse(getRandomResponse(ResponseStrings.promptForTime), loadingItemId, speak, Category.OTHER) + } + }else{ + viewModelScope.launch { + setAlarmFromPrompt(dayMatch, timeMatch, relativeTimeMatch) + processResponse(getRandomResponse(ResponseStrings.alarmSetSuccess), loadingItemId, speak, category) + } + } + } + }catch (e: Exception){ + Log.d("Exception", e.message.toString()) + viewModelScope.launch { + processResponse(getRandomResponse(ResponseStrings.genericError), loadingItemId, speak, Category.OTHER) + } + } + } + + private fun handleAlarmLockState (itemId: Long, loadingItemId: Long, speak: Boolean, state: LockState.LockAlarm){ + try{ + val prompt = chatList.find { it.id == itemId }?.englishText?.replace("[.?!]+".toRegex(), "")?:return + val timeRegex = "(\\d{1,2})(:(\\d{2}))?\\s*(AM|PM)?".toRegex(RegexOption.IGNORE_CASE) + val relativeTimeRegex = "(\\d+)\\s*(hours?|mins?|minutes?)\\s*(from now|later|next)?".toRegex(RegexOption.IGNORE_CASE) + + val dayMatch = state.day + val timeMatch = timeRegex.find(prompt) + val relativeTimeMatch = relativeTimeRegex.find(prompt) + + if(timeMatch == null && relativeTimeMatch == null){ + viewModelScope.launch { + processResponse(getRandomResponse(ResponseStrings.invalidTime), loadingItemId, speak, Category.OTHER) + } + return + }else{ + setAlarmFromPrompt(dayMatch, timeMatch, relativeTimeMatch) + lockState = LockState.None + viewModelScope.launch { + processResponse(getRandomResponse(ResponseStrings.alarmSetSuccess), loadingItemId, speak, Category.ALARM) + } + } + }catch (e: Exception){ + Log.d("Exception", e.message.toString()) + viewModelScope.launch { + processResponse(getRandomResponse(ResponseStrings.genericError), loadingItemId, speak, Category.OTHER) + } + } + } + + private fun setAlarmFromPrompt(dayMatch: String?, timeMatch: MatchResult?, relativeTimeMatch: MatchResult?) { + val calendar = Calendar.getInstance() + + if (relativeTimeMatch != null) { + // Parse the first time unit + val firstValue = relativeTimeMatch.groupValues[1].takeIf { it.isNotEmpty() }?.toIntOrNull() ?: 0 + val halfValue = if (relativeTimeMatch.groupValues[2] == "half") 0.5 else 0.0 // Handle "half" scenario + val firstUnit = relativeTimeMatch.groupValues[3].lowercase(Locale.ROOT) + + var secondValue = 0 + var secondUnit = "" + + // Check if a second time component exists (e.g., "30 minutes" in "3 hours and 30 minutes") + if (relativeTimeMatch.groupValues[4].isNotEmpty()) { + secondValue = relativeTimeMatch.groupValues[4].toInt() + secondUnit = relativeTimeMatch.groupValues[5].lowercase(Locale.ROOT) + } + + // Add first time unit + when { + firstUnit.contains("hour") || firstUnit.contains("hrs") -> { + calendar.add(Calendar.HOUR_OF_DAY, firstValue) // Add full hours + if (halfValue > 0) { + calendar.add(Calendar.MINUTE, 30) // Correctly add 30 minutes for "half" + } + } + firstUnit.contains("min") -> calendar.add(Calendar.MINUTE, firstValue) + } + + // Add second time unit if available + when { + secondUnit.contains("hour") || secondUnit.contains("hrs") -> calendar.add(Calendar.HOUR_OF_DAY, secondValue) + secondUnit.contains("minute") || secondUnit.contains("minutes") || secondUnit.contains("min") -> calendar.add(Calendar.MINUTE, secondValue) + } + }else if (timeMatch != null) { + // Handle absolute time + val hour = timeMatch.groupValues[1].toInt() + val minutes = timeMatch.groupValues[3].toIntOrNull() ?: 0 // Default minutes to 0 if missing + val amPm = timeMatch.groupValues[4].uppercase(Locale.ROOT) + + // Convert time to 24-hour format + val hour24 = when { + amPm == "PM" && hour != 12 -> hour + 12 + amPm == "AM" && hour == 12 -> 0 + // If no AM/PM, assume 24-hour format + else -> hour + } + + calendar.set(Calendar.HOUR_OF_DAY, hour24) + calendar.set(Calendar.MINUTE, minutes) + } + + val repeatDays = mutableListOf() + + // Adjust day and determine repeat days (existing logic) + when { + dayMatch == "today" -> { /* No change needed */ } + dayMatch == "tomorrow" -> calendar.add(Calendar.DAY_OF_MONTH, 1) + dayMatch == "next week" -> calendar.add(Calendar.DAY_OF_MONTH, 7) + dayMatch == "next weekend" -> { + val currentDayOfWeek = calendar.get(Calendar.DAY_OF_WEEK) + val daysUntilSaturday = (Calendar.SATURDAY - currentDayOfWeek + 7) % 7 + calendar.add(Calendar.DAY_OF_MONTH, daysUntilSaturday) + } + dayMatch != null -> { + val dayMap = mapOf( + "sunday" to Calendar.SUNDAY, "monday" to Calendar.MONDAY, + "tuesday" to Calendar.TUESDAY, "wednesday" to Calendar.WEDNESDAY, + "thursday" to Calendar.THURSDAY, "friday" to Calendar.FRIDAY, + "saturday" to Calendar.SATURDAY + ) + + dayMap.entries.forEach { (dayName, dayConstant) -> + if (dayMatch.contains(dayName)) { + repeatDays.add(dayConstant) + } + } + + if (repeatDays.isEmpty()) { + val targetDay = dayMap.entries.firstOrNull { dayMatch.contains(it.key) }?.value + if (targetDay != null) { + val currentDayOfWeek = calendar.get(Calendar.DAY_OF_WEEK) + val daysToAdd = (targetDay - currentDayOfWeek + 7) % 7 + calendar.add(Calendar.DAY_OF_MONTH, if (daysToAdd == 0) 7 else daysToAdd) + } + } + } + } + + // Create the intent to set the alarm + val intent = Intent(AlarmClock.ACTION_SET_ALARM).apply { + putExtra(AlarmClock.EXTRA_MESSAGE, "New Alarm") + putExtra(AlarmClock.EXTRA_HOUR, calendar.get(Calendar.HOUR_OF_DAY)) + putExtra(AlarmClock.EXTRA_MINUTES, calendar.get(Calendar.MINUTE)) + putExtra(AlarmClock.EXTRA_SKIP_UI, true) + + if (repeatDays.isNotEmpty()) { + putExtra(AlarmClock.EXTRA_DAYS, ArrayList(repeatDays)) + } + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + // Start the intent + globalContext?.startActivity(intent) + } + //End Alarm + + //For Reminder + private fun Reminder(itemId: Long, loadingItemId: Long, speak: Boolean, category: Category){ + try { + if (chatList.isNotEmpty()) { + val prompt = chatList.find { it.id == itemId }?.englishText?.replace("[.?!]+".toRegex(), "")?:return + + //val dayRegex = "(today|tomorrow|next week|next weekend|next month|((Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)( morning| evening)?))".toRegex(RegexOption.IGNORE_CASE) + val dayRegex = "(?i)\\b(today|tomorrow|next week|next weekend|((Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)( morning| evening| night| afternoon)?))\\b".toRegex(RegexOption.IGNORE_CASE) + //val timeRegex = "(\\d{1,2})(:(\\d{2}))?\\s*(AM|PM)?".toRegex(RegexOption.IGNORE_CASE) + val timeRegex = "(?i)\\b(\\d{1,2})(:(\\d{2}))?\\s*(AM|PM)?\\b".toRegex(RegexOption.IGNORE_CASE) + //val relativeTimeRegex = "(\\d+)\\s*(hours?|mins?|minutes?)\\s*(from now|later|next)?".toRegex(RegexOption.IGNORE_CASE) + val relativeTimeRegex = "(?i)\\b(\\d+)?\\s*(?:and\\s*(half))?\\s*(hours?|hrs?|mins?|minutes?|seconds?|secs?)\\s*(?:and\\s*(\\d+))?\\s*(mins?|minutes?|secs?|seconds?)?\\s*(from now|later|next)?\\b".toRegex(RegexOption.IGNORE_CASE) + val contextRegex = "remind me(?: to| for| of| about| to create)?\\s+(.*?)(?:\\s+(at|on|in)\\s+.*)?$".toRegex(RegexOption.IGNORE_CASE) + + val contextMatch = contextRegex.find(prompt) + val context = contextMatch?.groupValues?.get(1)?.trim() ?: "Reminder" + + val timeMatch = timeRegex.find(prompt) + val relativeTimeMatch = relativeTimeRegex.find(prompt) + val dayMatch = dayRegex.find(prompt)?.value?.lowercase() + + if(timeMatch == null && relativeTimeMatch == null){ + viewModelScope.launch { + lockState = LockState.LockReminder(day = dayMatch, context = context) + processResponse(getRandomResponse(ResponseStrings.promptForTime), loadingItemId, speak, Category.OTHER) + } + }else{ + setReminderFromPrompt(dayMatch, timeMatch, relativeTimeMatch, context) + + viewModelScope.launch { + processResponse(getRandomResponse(ResponseStrings.reminderSetSuccess), loadingItemId, speak, category) + } + } + + } + }catch (e: Exception){ + Log.d("Exception", e.message.toString()) + viewModelScope.launch { + processResponse(getRandomResponse(ResponseStrings.genericError), loadingItemId, speak, Category.OTHER) + } + } + } + + private fun handleReminderLockState (itemId: Long, loadingItemId: Long, speak: Boolean, state: LockState.LockReminder){ + try{ + val prompt = chatList.find { it.id == itemId }?.englishText?.replace("[.?!]+".toRegex(), "")?:return + + val timeRegex = "(\\d{1,2})(:(\\d{2}))?\\s*(AM|PM)?".toRegex(RegexOption.IGNORE_CASE) + val relativeTimeRegex = "(\\d+)\\s*(hours?|mins?|minutes?)\\s*(from now|later|next)?".toRegex(RegexOption.IGNORE_CASE) + + val context = state.context ?: "Reminder" + val dayMatch = state.day + val timeMatch = timeRegex.find(prompt) + val relativeTimeMatch = relativeTimeRegex.find(prompt) + + if(timeMatch == null && relativeTimeMatch == null){ + viewModelScope.launch { + processResponse(getRandomResponse(ResponseStrings.invalidTime), loadingItemId, speak, Category.OTHER) + } + return + }else{ + setReminderFromPrompt(dayMatch, timeMatch, relativeTimeMatch, context) + lockState = LockState.None + viewModelScope.launch { + processResponse(getRandomResponse(ResponseStrings.reminderSetSuccess), loadingItemId, speak, Category.ALARM) + } + } + }catch (e: Exception){ + Log.d("Exception", e.message.toString()) + viewModelScope.launch { + processResponse(getRandomResponse(ResponseStrings.genericError), loadingItemId, speak, Category.OTHER) + } + } + } + + private fun setReminderFromPrompt(dayMatch: String?, timeMatch: MatchResult?, relativeTimeMatch: MatchResult?,context: String) { + val calendar = Calendar.getInstance() + + if (relativeTimeMatch != null) { + // Parse the first time unit + val firstValue = relativeTimeMatch.groupValues[1].takeIf { it.isNotEmpty() }?.toIntOrNull() ?: 0 + val halfValue = if (relativeTimeMatch.groupValues[2] == "half") 0.5 else 0.0 // Handle "half" scenario + val firstUnit = relativeTimeMatch.groupValues[3].lowercase(Locale.ROOT) + + var secondValue = 0 + var secondUnit = "" + + // Check if a second time component exists (e.g., "30 minutes" in "3 hours and 30 minutes") + if (relativeTimeMatch.groupValues[4].isNotEmpty()) { + secondValue = relativeTimeMatch.groupValues[4].toInt() + secondUnit = relativeTimeMatch.groupValues[5].lowercase(Locale.ROOT) + } + + // Add first time unit + when { + firstUnit.contains("hour") || firstUnit.contains("hrs") -> { + calendar.add(Calendar.HOUR_OF_DAY, firstValue) // Add full hours + if (halfValue > 0) { + calendar.add(Calendar.MINUTE, 30) // Correctly add 30 minutes for "half" + } + } + firstUnit.contains("min") -> calendar.add(Calendar.MINUTE, firstValue) + } + + // Add second time unit if available + when { + secondUnit.contains("hour") || secondUnit.contains("hrs") -> calendar.add(Calendar.HOUR_OF_DAY, secondValue) + secondUnit.contains("minute") || secondUnit.contains("minutes") || secondUnit.contains("min") -> calendar.add(Calendar.MINUTE, secondValue) + } + } else if (timeMatch != null) { + // Handle absolute time + val hour = timeMatch.groupValues[1].toInt() + val minutes = timeMatch.groupValues[3].toIntOrNull() ?: 0 // Default minutes to 0 if missing + val amPm = timeMatch.groupValues[4].uppercase(Locale.ROOT) + + // Convert time to 24-hour format + val hour24 = when { + amPm == "PM" && hour != 12 -> hour + 12 + amPm == "AM" && hour == 12 -> 0 + // If no AM/PM, assume 24-hour format + else -> hour + } + + calendar.set(Calendar.HOUR_OF_DAY, hour24) + calendar.set(Calendar.MINUTE, minutes) + } + + val repeatDays = mutableListOf() + + // Adjust day and determine repeat days (existing logic) + when { + dayMatch == "today" -> { /* No change needed */ } + dayMatch == "tomorrow" -> calendar.add(Calendar.DAY_OF_MONTH, 1) + dayMatch == "next week" -> calendar.add(Calendar.DAY_OF_MONTH, 7) + dayMatch == "next weekend" -> { + val currentDayOfWeek = calendar.get(Calendar.DAY_OF_WEEK) + val daysUntilSaturday = (Calendar.SATURDAY - currentDayOfWeek + 7) % 7 + calendar.add(Calendar.DAY_OF_MONTH, daysUntilSaturday) + } + dayMatch != null -> { + val dayMap = mapOf( + "sunday" to Calendar.SUNDAY, "monday" to Calendar.MONDAY, + "tuesday" to Calendar.TUESDAY, "wednesday" to Calendar.WEDNESDAY, + "thursday" to Calendar.THURSDAY, "friday" to Calendar.FRIDAY, + "saturday" to Calendar.SATURDAY + ) + + dayMap.entries.forEach { (dayName, dayConstant) -> + if (dayMatch.contains(dayName)) { + repeatDays.add(dayConstant) + } + } + + if (repeatDays.isEmpty()) { + val targetDay = dayMap.entries.firstOrNull { dayMatch.contains(it.key) }?.value + if (targetDay != null) { + val currentDayOfWeek = calendar.get(Calendar.DAY_OF_WEEK) + val daysToAdd = (targetDay - currentDayOfWeek + 7) % 7 + calendar.add(Calendar.DAY_OF_MONTH, if (daysToAdd == 0) 7 else daysToAdd) + } + } + } + } + + // Create the intent to set the alarm + val intent = Intent(AlarmClock.ACTION_SET_ALARM).apply { + putExtra(AlarmClock.EXTRA_MESSAGE, context) + putExtra(AlarmClock.EXTRA_HOUR, calendar.get(Calendar.HOUR_OF_DAY)) + putExtra(AlarmClock.EXTRA_MINUTES, calendar.get(Calendar.MINUTE)) + putExtra(AlarmClock.EXTRA_SKIP_UI, true) + putExtra(AlarmClock.EXTRA_RINGTONE, AlarmClock.VALUE_RINGTONE_SILENT) + + if (repeatDays.isNotEmpty()) { + putExtra(AlarmClock.EXTRA_DAYS, ArrayList(repeatDays)) + } + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + // Start the intent + globalContext?.startActivity(intent) + } + //End Reminder + + private fun getRandomResponse(responses: List): String { + return responses.randomOrNull() ?: responses.firstOrNull() ?: "An unexpected error occurred." // Fallback + } + + object ResponseStrings { + + // Permission for making call Related + val permissionContactsCall = listOf( + "I need permission to access your contacts and make phone call. Please allow and try again.", + "Hey, I’ll need access to your contacts and calling first. Can you allow that?", + "Looks like I don’t have permission to call yet. Please enable it and retry.", + "I can help with that, but I need contacts and call access first.", + "Please grant me contacts and calling permission so I can make the call for you.", + "I can’t make calls without your permission. Could you turn it on?" + ) + + // Location permission for weather report + val permissionLocation = listOf( + "I need permission to access your location. Please try again.", + "I can get the weather, but I’ll need your location first.", + "Looks like location access isn’t granted. Please allow it and try again.", + "Hey, could you enable location permission so I can show the weather?", + "To fetch the forecast, I’ll need your location. Can you grant access?", + "Without your location, I can’t check the weather. Please turn it on." + ) + + // Asking user to turn on location for weather report + val locationServiceOff = listOf( + "I need to know your location for that. Please turn on your location and try again.", + "looks like location is off. Could you enable it?", + "I can’t continue without your location. Please turn it on.", + "Hey, I’ll need your location for this. Can you switch it on?", + "Looks like your location services are disabled. Please activate them.", + "Please turn on location, then I’ll be able to continue." + ) + + // Failure / Not Found Related + val callFailed = listOf( + "Sorry, failed to make call. Please try again.", + "Oops, the call didn’t go through. Want to retry?", + "I couldn’t complete the call. Please try again.", + "Looks like that call failed. Give it another shot?", + "Something went wrong with the call. Please try again later.", + "I wasn’t able to connect the call. Can we try once more?" + ) + + // Contact not found + val contactNotFound = listOf( + "I cannot find such contact, please try again.", + "I didn’t find that contact in your list.", + "No contact matched that name, could you check and retry?", + "Sorry, I couldn’t locate that person in your contacts.", + "Looks like that name isn’t saved in your contacts.", + "I couldn’t find that contact. Maybe try with a different name?" + ) + + // Song not found + val songNotFound = listOf( + "I can not find such song, please try again.", + "Sorry, I couldn’t find that track.", + "no song matched your request. Want to try another?", + "I wasn’t able to locate that song. Please retry.", + "Looks like that song isn’t available right now.", + "I couldn’t find that one. Maybe try with a different title?" + ) + + // Location not found + val locationNotFound = listOf( + "I can not find such location, please try again.", + "Sorry, I couldn’t figure out where that is.", + "I wasn’t able to locate that place.", + "No results for that location. Can you check and try again?", + "Looks like that place isn’t on my map data.", + "I couldn’t find that spot. Maybe try with a different name?" + ) + + // Weather report unavailable + val weatherReportUnavailable = listOf( + "Seems weather report is not available, please try again.", + "Sorry, I couldn’t get the weather right now.", + "The weather service isn’t responding. Please try later.", + "Looks like weather data is down at the moment.", + "I wasn’t able to fetch the forecast. Can you retry later?", + "Weather info isn’t available right now. Please check back soon." + ) + + // Location unknown, suggest city + val locationUnknownSuggestCity = listOf( + "Your location is not available to me. Please try again with your city name.", + "I couldn’t detect your location. Could you tell me your city instead?", + "Looks like location services aren’t working. Please provide your city name.", + "I’m not getting your location right now. Can you enter your city?", + "Sorry, I can’t access your current location. A city name would help.", + "Your location seems unavailable. Please try with your city name." + ) + + // Invalid time + val invalidTime = listOf( + "That doesn't seems like an actual time. Please try again.", + "I didn’t recognize that as a valid time.", + "That time format looks off. Could you retry?", + "Sorry, I couldn’t understand that time input.", + "That doesn’t look like a proper time. Please try again.", + "Can you give me a valid time so I can continue?" + ) + + // Alarm success + val alarmSetSuccess = listOf( + "Alarm set successfully...", + "Done! Your alarm is ready.", + "Great, I’ve set the alarm for you.", + "All set, your alarm has been scheduled.", + "Alarm saved successfully.", + "Okay, I’ve configured the alarm as requested." + ) + + // Reminder success + val reminderSetSuccess = listOf( + "Reminder set successfully...", + "Done! Your reminder is ready.", + "Great, I’ve saved the reminder for you.", + "All set, your reminder has been scheduled.", + "Reminder saved successfully.", + "Okay, I’ve created the reminder as requested." + ) + + // Prompt for time + val promptForTime = listOf( + "Sure, at what time?", + "Alright, when should I set it?", + "Okay, what time would you like?", + "Got it, please tell me the time.", + "When do you want me to set it for?", + "Sure thing, what time works for you?" + ) + + // Generic error + val genericError = listOf( + "Something went wrong, please try again.", + "Oops, that didn’t work. Please retry.", + "I ran into an issue. Can you try again?", + "Sorry, something broke there. Please try once more.", + "That didn’t go through. Could you retry?", + "An error popped up. Let’s try that again." + ) + } + +} diff --git a/app/src/main/java/com/app/assistant/viewmodel/MainViewModelFactory.kt b/app/src/main/java/com/app/assistant/viewmodel/MainViewModelFactory.kt new file mode 100644 index 0000000..e8d4535 --- /dev/null +++ b/app/src/main/java/com/app/assistant/viewmodel/MainViewModelFactory.kt @@ -0,0 +1,19 @@ +package com.app.assistant.viewmodel + +import android.app.Application +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider + +class MainViewModelFactory( + private val application: Application, + private val speak: Boolean +) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(MainViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return MainViewModel(application, speak) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/app/src/main/res/drawable/ic_add_new_chat.xml b/app/src/main/res/drawable/ic_add_new_chat.xml new file mode 100644 index 0000000..9098ed3 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_new_chat.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_content_copy.xml b/app/src/main/res/drawable/ic_content_copy.xml new file mode 100644 index 0000000..bf18660 --- /dev/null +++ b/app/src/main/res/drawable/ic_content_copy.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..f192c77 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..0282f65 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_menu.xml b/app/src/main/res/drawable/ic_menu.xml new file mode 100644 index 0000000..e81d662 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_mic.xml b/app/src/main/res/drawable/ic_mic.xml new file mode 100644 index 0000000..e74b5ce --- /dev/null +++ b/app/src/main/res/drawable/ic_mic.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_restart.xml b/app/src/main/res/drawable/ic_restart.xml new file mode 100644 index 0000000..e576ca6 --- /dev/null +++ b/app/src/main/res/drawable/ic_restart.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_stop.xml b/app/src/main/res/drawable/ic_stop.xml new file mode 100644 index 0000000..d787363 --- /dev/null +++ b/app/src/main/res/drawable/ic_stop.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_translate.xml b/app/src/main/res/drawable/ic_translate.xml new file mode 100644 index 0000000..a681441 --- /dev/null +++ b/app/src/main/res/drawable/ic_translate.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_up_arrow.xml b/app/src/main/res/drawable/ic_up_arrow.xml new file mode 100644 index 0000000..5c737b7 --- /dev/null +++ b/app/src/main/res/drawable/ic_up_arrow.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..f40e6b4 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..de9c746 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..8615364 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..d2c64a7 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..da5693d Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..84bf2c7 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..de64166 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..71fb77c Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..74131ed Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..33742b6 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..d907111 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Assistant + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..8705473 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..fa0f996 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9ee9997 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..f74b04b --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,5 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.jetbrains.kotlin.android) apply false +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..20e2a01 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,23 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# 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 +# 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 +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +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 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..33b7da2 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,39 @@ +[versions] +agp = "8.12.3" +kotlin = "1.9.0" +coreKtx = "1.13.1" +junit = "4.13.2" +junitVersion = "1.1.5" +espressoCore = "3.5.1" +lifecycleRuntimeKtx = "2.8.0" +activityCompose = "1.9.0" +composeBom = "2023.08.00" +datastoreCoreAndroid = "1.1.1" +runtimeLivedata = "1.7.4" +playServicesLocation = "21.3.0" +uiTestAndroid = "1.7.0" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-datastore-core-android = { group = "androidx.datastore", name = "datastore-core-android", version.ref = "datastoreCoreAndroid" } +androidx-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "runtimeLivedata" } +play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" } +androidx-ui-test-android = { group = "androidx.compose.ui", name = "ui-test-android", version.ref = "uiTestAndroid" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2b6bea8 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Jun 05 13:23:07 IST 2024 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/screenshots/gif1.gif b/screenshots/gif1.gif new file mode 100644 index 0000000..79e56f6 Binary files /dev/null and b/screenshots/gif1.gif differ diff --git a/screenshots/image1.png b/screenshots/image1.png new file mode 100644 index 0000000..7f658ca Binary files /dev/null and b/screenshots/image1.png differ diff --git a/screenshots/image2.png b/screenshots/image2.png new file mode 100644 index 0000000..ad1ac94 Binary files /dev/null and b/screenshots/image2.png differ diff --git a/screenshots/image3.png b/screenshots/image3.png new file mode 100644 index 0000000..7c75c40 Binary files /dev/null and b/screenshots/image3.png differ diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..3ac0e0f --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven { url = uri("https://jitpack.io") } + } +} + +rootProject.name = "Assistant" +include(":app")