From 39e8866b55390cd200487a2983d7a4930c34780f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:24:29 +0000 Subject: [PATCH 1/4] Initial plan From e3c95802053a3e79b8dbfb64c3780013d584a967 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:46:10 +0000 Subject: [PATCH 2/4] feat: Create complete AndroidAgent app - AI companion with robot agents - MainActivity with bottom navigation (Home, Chat, Files, Settings) - CompanionFaceView: custom Canvas-drawn animated robot face with mood expressions - 5 robot agents: ARIA (main companion), MAXIE (messaging), FELIX (files), NEXUS (web), MEMO (memory) - AgentManager singleton with LiveData status updates for all robots - Room database: Message, Memory, AgentContact, LLMConfig entities with DAOs - LLM integration: OllamaProvider (streaming) and OpenAICompatibleProvider - Pre-configured presets: Ollama, LM Studio, LLaMA.cpp, Jan.ai, GPT4All - Chat UI with bubble messages, typing indicator, per-agent emoji/name display - File manager with workspace directory, create/read/delete, swipe-to-refresh - Settings: LLM config (all fields + test connection + fetch models), auto-reply toggles - Contacts auto-reply management with per-contact toggle - AgentNotificationListenerService: monitors WhatsApp, email, SMS notifications - AgentSmsReceiver: intercepts SMS, routes to MAXIE, auto-replies via LLM if configured - Persistent memory system (key/value/category/importance) injected into LLM context - Material Design 3 dark theme with robot-blue accent color palette - All permissions declared: INTERNET, READ/RECEIVE/SEND_SMS, READ_CONTACTS, CAMERA, RECORD_AUDIO Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: magicalmutation-coder <246345154+magicalmutation-coder@users.noreply.github.com> --- .gitignore | 10 + app/build.gradle | 61 +++++ app/proguard-rules.pro | 5 + .../androidagent/ExampleInstrumentedTest.kt | 16 ++ app/src/main/AndroidManifest.xml | 59 ++++ .../com/androidagent/CompanionApplication.kt | 23 ++ .../java/com/androidagent/MainActivity.kt | 36 +++ .../com/androidagent/agents/AgentManager.kt | 106 ++++++++ .../com/androidagent/agents/AgentRobot.kt | 60 +++++ .../androidagent/agents/robots/AriaRobot.kt | 50 ++++ .../androidagent/agents/robots/FelixRobot.kt | 61 +++++ .../androidagent/agents/robots/MaxieRobot.kt | 68 +++++ .../androidagent/agents/robots/MemoRobot.kt | 59 ++++ .../androidagent/agents/robots/NexusRobot.kt | 77 ++++++ .../androidagent/data/database/AppDatabase.kt | 45 ++++ .../data/database/dao/AgentContactDao.kt | 33 +++ .../data/database/dao/LLMConfigDao.kt | 36 +++ .../data/database/dao/MemoryDao.kt | 32 +++ .../data/database/dao/MessageDao.kt | 33 +++ .../data/database/entities/AgentContact.kt | 16 ++ .../data/database/entities/LLMConfig.kt | 28 ++ .../data/database/entities/Memory.kt | 15 ++ .../data/database/entities/Message.kt | 15 ++ .../data/repository/ChatRepository.kt | 43 +++ .../data/repository/MemoryRepository.kt | 36 +++ .../java/com/androidagent/llm/LLMManager.kt | 98 +++++++ .../java/com/androidagent/llm/LLMProvider.kt | 10 + .../llm/providers/OllamaProvider.kt | 75 ++++++ .../llm/providers/OpenAICompatibleProvider.kt | 71 +++++ .../AgentNotificationListenerService.kt | 53 ++++ .../androidagent/services/AgentSmsReceiver.kt | 64 +++++ .../com/androidagent/ui/chat/ChatAdapter.kt | 75 ++++++ .../com/androidagent/ui/chat/ChatFragment.kt | 92 +++++++ .../com/androidagent/ui/chat/ChatViewModel.kt | 68 +++++ .../com/androidagent/ui/files/FilesAdapter.kt | 65 +++++ .../androidagent/ui/files/FilesFragment.kt | 110 ++++++++ .../androidagent/ui/files/FilesViewModel.kt | 78 ++++++ .../androidagent/ui/main/CompanionFaceView.kt | 179 +++++++++++++ .../com/androidagent/ui/main/MainFragment.kt | 74 +++++ .../ui/settings/ContactsAutoReplyFragment.kt | 128 +++++++++ .../ui/settings/LLMConfigFragment.kt | 231 ++++++++++++++++ .../ui/settings/SettingsFragment.kt | 89 +++++++ .../main/res/drawable/bottom_nav_color.xml | 5 + app/src/main/res/drawable/ic_add.xml | 10 + app/src/main/res/drawable/ic_camera.xml | 10 + app/src/main/res/drawable/ic_chat.xml | 10 + app/src/main/res/drawable/ic_files.xml | 10 + app/src/main/res/drawable/ic_home.xml | 10 + .../res/drawable/ic_launcher_foreground.xml | 29 ++ app/src/main/res/drawable/ic_mic.xml | 10 + app/src/main/res/drawable/ic_send.xml | 10 + app/src/main/res/drawable/ic_settings.xml | 10 + app/src/main/res/layout/activity_main.xml | 27 ++ app/src/main/res/layout/fragment_chat.xml | 86 ++++++ .../layout/fragment_contacts_autoreply.xml | 42 +++ app/src/main/res/layout/fragment_files.xml | 50 ++++ .../main/res/layout/fragment_llm_config.xml | 252 ++++++++++++++++++ app/src/main/res/layout/fragment_main.xml | 81 ++++++ app/src/main/res/layout/fragment_settings.xml | 167 ++++++++++++ .../res/layout/item_contact_autoreply.xml | 39 +++ app/src/main/res/layout/item_file.xml | 64 +++++ .../main/res/layout/item_message_agent.xml | 72 +++++ app/src/main/res/layout/item_message_user.xml | 46 ++++ app/src/main/res/layout/item_robot_card.xml | 54 ++++ app/src/main/res/menu/bottom_nav_menu.xml | 19 ++ app/src/main/res/menu/chat_menu.xml | 8 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 166 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 166 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 123 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 123 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 221 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 221 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 414 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 414 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 547 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 547 bytes app/src/main/res/navigation/nav_graph.xml | 51 ++++ app/src/main/res/values/colors.xml | 45 ++++ app/src/main/res/values/dimens.xml | 14 + app/src/main/res/values/strings.xml | 27 ++ app/src/main/res/values/themes.xml | 23 ++ .../java/com/androidagent/ExampleUnitTest.kt | 11 + build.gradle | 10 + gradle.properties | 4 + gradle/wrapper/gradle-wrapper.properties | 7 + settings.gradle | 16 ++ 88 files changed, 3922 insertions(+) create mode 100644 .gitignore create mode 100644 app/build.gradle create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/java/com/androidagent/ExampleInstrumentedTest.kt create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/androidagent/CompanionApplication.kt create mode 100644 app/src/main/java/com/androidagent/MainActivity.kt create mode 100644 app/src/main/java/com/androidagent/agents/AgentManager.kt create mode 100644 app/src/main/java/com/androidagent/agents/AgentRobot.kt create mode 100644 app/src/main/java/com/androidagent/agents/robots/AriaRobot.kt create mode 100644 app/src/main/java/com/androidagent/agents/robots/FelixRobot.kt create mode 100644 app/src/main/java/com/androidagent/agents/robots/MaxieRobot.kt create mode 100644 app/src/main/java/com/androidagent/agents/robots/MemoRobot.kt create mode 100644 app/src/main/java/com/androidagent/agents/robots/NexusRobot.kt create mode 100644 app/src/main/java/com/androidagent/data/database/AppDatabase.kt create mode 100644 app/src/main/java/com/androidagent/data/database/dao/AgentContactDao.kt create mode 100644 app/src/main/java/com/androidagent/data/database/dao/LLMConfigDao.kt create mode 100644 app/src/main/java/com/androidagent/data/database/dao/MemoryDao.kt create mode 100644 app/src/main/java/com/androidagent/data/database/dao/MessageDao.kt create mode 100644 app/src/main/java/com/androidagent/data/database/entities/AgentContact.kt create mode 100644 app/src/main/java/com/androidagent/data/database/entities/LLMConfig.kt create mode 100644 app/src/main/java/com/androidagent/data/database/entities/Memory.kt create mode 100644 app/src/main/java/com/androidagent/data/database/entities/Message.kt create mode 100644 app/src/main/java/com/androidagent/data/repository/ChatRepository.kt create mode 100644 app/src/main/java/com/androidagent/data/repository/MemoryRepository.kt create mode 100644 app/src/main/java/com/androidagent/llm/LLMManager.kt create mode 100644 app/src/main/java/com/androidagent/llm/LLMProvider.kt create mode 100644 app/src/main/java/com/androidagent/llm/providers/OllamaProvider.kt create mode 100644 app/src/main/java/com/androidagent/llm/providers/OpenAICompatibleProvider.kt create mode 100644 app/src/main/java/com/androidagent/services/AgentNotificationListenerService.kt create mode 100644 app/src/main/java/com/androidagent/services/AgentSmsReceiver.kt create mode 100644 app/src/main/java/com/androidagent/ui/chat/ChatAdapter.kt create mode 100644 app/src/main/java/com/androidagent/ui/chat/ChatFragment.kt create mode 100644 app/src/main/java/com/androidagent/ui/chat/ChatViewModel.kt create mode 100644 app/src/main/java/com/androidagent/ui/files/FilesAdapter.kt create mode 100644 app/src/main/java/com/androidagent/ui/files/FilesFragment.kt create mode 100644 app/src/main/java/com/androidagent/ui/files/FilesViewModel.kt create mode 100644 app/src/main/java/com/androidagent/ui/main/CompanionFaceView.kt create mode 100644 app/src/main/java/com/androidagent/ui/main/MainFragment.kt create mode 100644 app/src/main/java/com/androidagent/ui/settings/ContactsAutoReplyFragment.kt create mode 100644 app/src/main/java/com/androidagent/ui/settings/LLMConfigFragment.kt create mode 100644 app/src/main/java/com/androidagent/ui/settings/SettingsFragment.kt create mode 100644 app/src/main/res/drawable/bottom_nav_color.xml create mode 100644 app/src/main/res/drawable/ic_add.xml create mode 100644 app/src/main/res/drawable/ic_camera.xml create mode 100644 app/src/main/res/drawable/ic_chat.xml create mode 100644 app/src/main/res/drawable/ic_files.xml create mode 100644 app/src/main/res/drawable/ic_home.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_mic.xml create mode 100644 app/src/main/res/drawable/ic_send.xml create mode 100644 app/src/main/res/drawable/ic_settings.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/layout/fragment_chat.xml create mode 100644 app/src/main/res/layout/fragment_contacts_autoreply.xml create mode 100644 app/src/main/res/layout/fragment_files.xml create mode 100644 app/src/main/res/layout/fragment_llm_config.xml create mode 100644 app/src/main/res/layout/fragment_main.xml create mode 100644 app/src/main/res/layout/fragment_settings.xml create mode 100644 app/src/main/res/layout/item_contact_autoreply.xml create mode 100644 app/src/main/res/layout/item_file.xml create mode 100644 app/src/main/res/layout/item_message_agent.xml create mode 100644 app/src/main/res/layout/item_message_user.xml create mode 100644 app/src/main/res/layout/item_robot_card.xml create mode 100644 app/src/main/res/menu/bottom_nav_menu.xml create mode 100644 app/src/main/res/menu/chat_menu.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/navigation/nav_graph.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/dimens.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/test/java/com/androidagent/ExampleUnitTest.kt create mode 100644 build.gradle create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 settings.gradle diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10cfdbf --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..8ef83a8 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,61 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.kapt' +} + +android { + namespace 'com.androidagent' + compileSdk 34 + + defaultConfig { + applicationId "com.androidagent" + minSdk 26 + targetSdk 34 + versionCode 1 + versionName "1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = '17' + } + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation 'androidx.core:core-ktx:1.12.0' + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'com.google.android.material:material:1.11.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0' + implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7' + implementation 'androidx.navigation:navigation-ui-ktx:2.7.7' + implementation 'androidx.room:room-runtime:2.6.1' + implementation 'androidx.room:room-ktx:2.6.1' + kapt 'androidx.room:room-compiler:2.6.1' + implementation 'com.squareup.okhttp3:okhttp:4.12.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' + implementation 'com.google.code.gson:gson:2.10.1' + implementation 'androidx.preference:preference-ktx:1.2.1' + implementation 'androidx.documentfile:documentfile:1.0.1' + implementation 'androidx.recyclerview:recyclerview:1.3.2' + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..c59c978 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,5 @@ +# Default ProGuard rules +-keepattributes *Annotation* +-keepclassmembers class * { + @com.google.gson.annotations.SerializedName ; +} diff --git a/app/src/androidTest/java/com/androidagent/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/androidagent/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..64cfae7 --- /dev/null +++ b/app/src/androidTest/java/com/androidagent/ExampleInstrumentedTest.kt @@ -0,0 +1,16 @@ +package com.androidagent + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.Assert.* + +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.androidagent", appContext.packageName) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..99bcba1 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/androidagent/CompanionApplication.kt b/app/src/main/java/com/androidagent/CompanionApplication.kt new file mode 100644 index 0000000..8cbd59b --- /dev/null +++ b/app/src/main/java/com/androidagent/CompanionApplication.kt @@ -0,0 +1,23 @@ +package com.androidagent + +import android.app.Application +import com.androidagent.agents.AgentManager +import com.androidagent.data.database.AppDatabase + +class CompanionApplication : Application() { + + val database: AppDatabase by lazy { AppDatabase.getInstance(this) } + val agentManager: AgentManager by lazy { AgentManager.getInstance(this) } + + override fun onCreate() { + super.onCreate() + instance = this + // Initialize agent manager eagerly so robots are ready + agentManager.initialize() + } + + companion object { + lateinit var instance: CompanionApplication + private set + } +} diff --git a/app/src/main/java/com/androidagent/MainActivity.kt b/app/src/main/java/com/androidagent/MainActivity.kt new file mode 100644 index 0000000..53c9f31 --- /dev/null +++ b/app/src/main/java/com/androidagent/MainActivity.kt @@ -0,0 +1,36 @@ +package com.androidagent + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.navigation.NavController +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.AppBarConfiguration +import androidx.navigation.ui.setupActionBarWithNavController +import androidx.navigation.ui.setupWithNavController +import com.androidagent.databinding.ActivityMainBinding + +class MainActivity : AppCompatActivity() { + + private lateinit var binding: ActivityMainBinding + private lateinit var navController: NavController + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + val navHostFragment = supportFragmentManager + .findFragmentById(R.id.nav_host_fragment) as NavHostFragment + navController = navHostFragment.navController + + val appBarConfiguration = AppBarConfiguration( + setOf(R.id.mainFragment, R.id.chatFragment, R.id.filesFragment, R.id.settingsFragment) + ) + setupActionBarWithNavController(navController, appBarConfiguration) + binding.bottomNav.setupWithNavController(navController) + } + + override fun onSupportNavigateUp(): Boolean { + return navController.navigateUp() || super.onSupportNavigateUp() + } +} diff --git a/app/src/main/java/com/androidagent/agents/AgentManager.kt b/app/src/main/java/com/androidagent/agents/AgentManager.kt new file mode 100644 index 0000000..e0b1038 --- /dev/null +++ b/app/src/main/java/com/androidagent/agents/AgentManager.kt @@ -0,0 +1,106 @@ +package com.androidagent.agents + +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.androidagent.agents.robots.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +class AgentManager private constructor(private val context: Context) { + + val ariaRobot = AriaRobot() + val maxieRobot = MaxieRobot() + val felixRobot = FelixRobot() + val nexusRobot = NexusRobot() + val memoRobot = MemoRobot() + + private val _robots = MutableLiveData>() + val robots: LiveData> = _robots + + private val _ariaStatus = MutableLiveData() + val ariaStatus: LiveData = _ariaStatus + + private val _maxieStatus = MutableLiveData() + val maxieStatus: LiveData = _maxieStatus + + private val _felixStatus = MutableLiveData() + val felixStatus: LiveData = _felixStatus + + private val _nexusStatus = MutableLiveData() + val nexusStatus: LiveData = _nexusStatus + + private val _memoStatus = MutableLiveData() + val memoStatus: LiveData = _memoStatus + + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + + fun initialize() { + refreshRobotList() + } + + fun refreshRobotList() { + _robots.value = listOf( + ariaRobot.agentRobot, + maxieRobot.agentRobot, + felixRobot.agentRobot, + nexusRobot.agentRobot, + memoRobot.agentRobot + ) + _ariaStatus.value = ariaRobot.getStatus() + _maxieStatus.value = maxieRobot.getStatus() + _felixStatus.value = felixRobot.getStatus() + _nexusStatus.value = nexusRobot.getStatus() + _memoStatus.value = memoRobot.getStatus() + } + + fun triggerRobot(name: String, task: String) { + scope.launch { + val robot: Robot? = when (name.uppercase()) { + "ARIA" -> ariaRobot + "MAXIE" -> maxieRobot + "FELIX" -> felixRobot + "NEXUS" -> nexusRobot + "MEMO" -> memoRobot + else -> null + } + robot?.performTask(task) + refreshRobotList() + } + } + + fun updateAriaStatus(status: String) { + ariaRobot.agentRobot.currentStatus = status + _ariaStatus.postValue(status) + _robots.postValue(listOf( + ariaRobot.agentRobot, + maxieRobot.agentRobot, + felixRobot.agentRobot, + nexusRobot.agentRobot, + memoRobot.agentRobot + )) + } + + fun updateMaxieStatus(status: String) { + maxieRobot.agentRobot.currentStatus = status + _maxieStatus.postValue(status) + } + + fun updateFelixStatus(status: String) { + felixRobot.agentRobot.currentStatus = status + _felixStatus.postValue(status) + } + + companion object { + @Volatile + private var INSTANCE: AgentManager? = null + + fun getInstance(context: Context): AgentManager { + return INSTANCE ?: synchronized(this) { + AgentManager(context.applicationContext).also { INSTANCE = it } + } + } + } +} diff --git a/app/src/main/java/com/androidagent/agents/AgentRobot.kt b/app/src/main/java/com/androidagent/agents/AgentRobot.kt new file mode 100644 index 0000000..6d2269f --- /dev/null +++ b/app/src/main/java/com/androidagent/agents/AgentRobot.kt @@ -0,0 +1,60 @@ +package com.androidagent.agents + +import android.graphics.Color + +enum class RobotMood { HAPPY, THINKING, CURIOUS, EXCITED, IDLE, BUSY, ALERT } + +data class AgentRobot( + val name: String, + val displayName: String, + val emoji: String, + val description: String, + val color: Int, + var currentStatus: String = "Idle", + var currentTask: String = "", + var mood: RobotMood = RobotMood.IDLE +) + +interface Robot { + val agentRobot: AgentRobot + suspend fun performTask(task: String): String + fun getStatus(): String +} + +object RobotDefaults { + val ARIA = AgentRobot( + name = "ARIA", + displayName = "ARIA", + emoji = "πŸ€–", + description = "Main AI Companion", + color = Color.parseColor("#2196F3") + ) + val MAXIE = AgentRobot( + name = "MAXIE", + displayName = "MAXIE", + emoji = "πŸ’¬", + description = "Messaging Agent", + color = Color.parseColor("#4CAF50") + ) + val FELIX = AgentRobot( + name = "FELIX", + displayName = "FELIX", + emoji = "πŸ“", + description = "File Manager", + color = Color.parseColor("#FF9800") + ) + val NEXUS = AgentRobot( + name = "NEXUS", + displayName = "NEXUS", + emoji = "🌐", + description = "Web Explorer", + color = Color.parseColor("#9C27B0") + ) + val MEMO = AgentRobot( + name = "MEMO", + displayName = "MEMO", + emoji = "🧠", + description = "Memory Keeper", + color = Color.parseColor("#FFC107") + ) +} diff --git a/app/src/main/java/com/androidagent/agents/robots/AriaRobot.kt b/app/src/main/java/com/androidagent/agents/robots/AriaRobot.kt new file mode 100644 index 0000000..0b1abf5 --- /dev/null +++ b/app/src/main/java/com/androidagent/agents/robots/AriaRobot.kt @@ -0,0 +1,50 @@ +package com.androidagent.agents.robots + +import com.androidagent.agents.AgentRobot +import com.androidagent.agents.Robot +import com.androidagent.agents.RobotDefaults +import com.androidagent.agents.RobotMood + +enum class AriaMood { HAPPY, THINKING, CURIOUS, EXCITED, IDLE } + +class AriaRobot : Robot { + + override val agentRobot: AgentRobot = RobotDefaults.ARIA.copy() + var mood: AriaMood = AriaMood.IDLE + private set + + init { + agentRobot.currentStatus = "Ready to chat! ✨" + } + + override suspend fun performTask(task: String): String { + setMood(AriaMood.THINKING) + agentRobot.currentStatus = "Thinking... πŸ€”" + agentRobot.currentTask = task + // ARIA routes to LLM via AgentManager + val result = "I'm on it! Let me help you with: $task" + setMood(AriaMood.HAPPY) + agentRobot.currentStatus = "Ready to chat! ✨" + return result + } + + override fun getStatus(): String = agentRobot.currentStatus + + fun setMood(newMood: AriaMood) { + mood = newMood + agentRobot.mood = when (newMood) { + AriaMood.HAPPY -> RobotMood.HAPPY + AriaMood.THINKING -> RobotMood.THINKING + AriaMood.CURIOUS -> RobotMood.CURIOUS + AriaMood.EXCITED -> RobotMood.EXCITED + AriaMood.IDLE -> RobotMood.IDLE + } + agentRobot.currentStatus = when (newMood) { + AriaMood.HAPPY -> "Happy to help! 😊" + AriaMood.THINKING -> "Thinking... πŸ€”" + AriaMood.CURIOUS -> "That's interesting! 🧐" + AriaMood.EXCITED -> "I found something cool! πŸŽ‰" + AriaMood.IDLE -> "Ready to chat! ✨" + } + } +} diff --git a/app/src/main/java/com/androidagent/agents/robots/FelixRobot.kt b/app/src/main/java/com/androidagent/agents/robots/FelixRobot.kt new file mode 100644 index 0000000..c1be7b3 --- /dev/null +++ b/app/src/main/java/com/androidagent/agents/robots/FelixRobot.kt @@ -0,0 +1,61 @@ +package com.androidagent.agents.robots + +import com.androidagent.agents.AgentRobot +import com.androidagent.agents.Robot +import com.androidagent.agents.RobotDefaults +import com.androidagent.agents.RobotMood + +class FelixRobot : Robot { + + override val agentRobot: AgentRobot = RobotDefaults.FELIX.copy() + + init { + agentRobot.currentStatus = "Workspace ready πŸ“" + } + + override suspend fun performTask(task: String): String { + agentRobot.currentTask = task + return when { + task.startsWith("scan") -> { + agentRobot.currentStatus = "Scanning files... πŸ”" + agentRobot.mood = RobotMood.BUSY + "FELIX: Scanning workspace" + } + task.startsWith("save:") -> { + agentRobot.currentStatus = "File saved! βœ…" + agentRobot.mood = RobotMood.HAPPY + "FELIX: File saved!" + } + task.startsWith("delete:") -> { + agentRobot.currentStatus = "File deleted πŸ—‘οΈ" + "FELIX: File deleted" + } + else -> { + agentRobot.currentStatus = "Working on files... πŸ“‚" + "FELIX handling: $task" + } + } + } + + override fun getStatus(): String = agentRobot.currentStatus + + fun onScanStart() { + agentRobot.currentStatus = "Scanning files... πŸ”" + agentRobot.mood = RobotMood.BUSY + } + + fun onScanComplete(count: Int) { + agentRobot.currentStatus = "Found $count files πŸ“‹" + agentRobot.mood = RobotMood.HAPPY + } + + fun onFileSaved() { + agentRobot.currentStatus = "File saved! βœ…" + agentRobot.mood = RobotMood.HAPPY + } + + fun resetStatus() { + agentRobot.currentStatus = "Workspace ready πŸ“" + agentRobot.mood = RobotMood.IDLE + } +} diff --git a/app/src/main/java/com/androidagent/agents/robots/MaxieRobot.kt b/app/src/main/java/com/androidagent/agents/robots/MaxieRobot.kt new file mode 100644 index 0000000..8f22a3d --- /dev/null +++ b/app/src/main/java/com/androidagent/agents/robots/MaxieRobot.kt @@ -0,0 +1,68 @@ +package com.androidagent.agents.robots + +import com.androidagent.agents.AgentRobot +import com.androidagent.agents.Robot +import com.androidagent.agents.RobotDefaults +import com.androidagent.agents.RobotMood + +class MaxieRobot : Robot { + + override val agentRobot: AgentRobot = RobotDefaults.MAXIE.copy() + + init { + agentRobot.currentStatus = "Monitoring messages πŸ“‘" + } + + override suspend fun performTask(task: String): String { + agentRobot.currentTask = task + return when { + task.startsWith("sms:") -> handleSms(task.removePrefix("sms:")) + task.startsWith("email:") -> handleEmail(task.removePrefix("email:")) + task.startsWith("reply:") -> handleReply(task.removePrefix("reply:")) + else -> { + agentRobot.currentStatus = "Processing message... πŸ’¬" + "MAXIE is handling: $task" + } + } + } + + private fun handleSms(content: String): String { + agentRobot.currentStatus = "New SMS received! πŸ“±" + agentRobot.mood = RobotMood.ALERT + return "New SMS from $content" + } + + private fun handleEmail(content: String): String { + agentRobot.currentStatus = "Email received! πŸ“§" + agentRobot.mood = RobotMood.ALERT + return "Email from $content" + } + + private fun handleReply(content: String): String { + agentRobot.currentStatus = "Replying... ✍️" + agentRobot.mood = RobotMood.BUSY + return "Replying to $content" + } + + override fun getStatus(): String = agentRobot.currentStatus + + fun onSmsReceived(sender: String, message: String) { + agentRobot.currentStatus = "New SMS from $sender! πŸ“±" + agentRobot.mood = RobotMood.ALERT + } + + fun onEmailReceived(sender: String) { + agentRobot.currentStatus = "Email from $sender received πŸ“§" + agentRobot.mood = RobotMood.ALERT + } + + fun onReplying(name: String) { + agentRobot.currentStatus = "Replying to $name... ✍️" + agentRobot.mood = RobotMood.BUSY + } + + fun resetStatus() { + agentRobot.currentStatus = "Monitoring messages πŸ“‘" + agentRobot.mood = RobotMood.IDLE + } +} diff --git a/app/src/main/java/com/androidagent/agents/robots/MemoRobot.kt b/app/src/main/java/com/androidagent/agents/robots/MemoRobot.kt new file mode 100644 index 0000000..24baddb --- /dev/null +++ b/app/src/main/java/com/androidagent/agents/robots/MemoRobot.kt @@ -0,0 +1,59 @@ +package com.androidagent.agents.robots + +import com.androidagent.agents.AgentRobot +import com.androidagent.agents.Robot +import com.androidagent.agents.RobotDefaults +import com.androidagent.agents.RobotMood + +class MemoRobot : Robot { + + override val agentRobot: AgentRobot = RobotDefaults.MEMO.copy() + + init { + agentRobot.currentStatus = "Memory banks online 🧠" + } + + override suspend fun performTask(task: String): String { + agentRobot.currentTask = task + return when { + task.startsWith("store:") -> { + agentRobot.currentStatus = "Storing memory... πŸ’Ύ" + agentRobot.mood = RobotMood.BUSY + "MEMO: Memory stored!" + } + task.startsWith("recall:") -> { + agentRobot.currentStatus = "Recalling... πŸ”" + agentRobot.mood = RobotMood.THINKING + "MEMO: Searching memory banks..." + } + task.startsWith("learn:") -> { + agentRobot.currentStatus = "Learning from conversation... πŸ“š" + agentRobot.mood = RobotMood.CURIOUS + "MEMO: Knowledge updated!" + } + else -> "MEMO: Memory banks ready!" + } + } + + override fun getStatus(): String = agentRobot.currentStatus + + fun onStoringMemory() { + agentRobot.currentStatus = "Updating memory... πŸ’Ύ" + agentRobot.mood = RobotMood.BUSY + } + + fun onMemoryStored() { + agentRobot.currentStatus = "Memory stored! βœ…" + agentRobot.mood = RobotMood.HAPPY + } + + fun onLearning() { + agentRobot.currentStatus = "Learning from conversation... πŸ“š" + agentRobot.mood = RobotMood.CURIOUS + } + + fun resetStatus() { + agentRobot.currentStatus = "Memory banks online 🧠" + agentRobot.mood = RobotMood.IDLE + } +} diff --git a/app/src/main/java/com/androidagent/agents/robots/NexusRobot.kt b/app/src/main/java/com/androidagent/agents/robots/NexusRobot.kt new file mode 100644 index 0000000..f5c7470 --- /dev/null +++ b/app/src/main/java/com/androidagent/agents/robots/NexusRobot.kt @@ -0,0 +1,77 @@ +package com.androidagent.agents.robots + +import com.androidagent.agents.AgentRobot +import com.androidagent.agents.Robot +import com.androidagent.agents.RobotDefaults +import com.androidagent.agents.RobotMood +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.util.concurrent.TimeUnit + +class NexusRobot : Robot { + + override val agentRobot: AgentRobot = RobotDefaults.NEXUS.copy() + + private val client = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + init { + agentRobot.currentStatus = "Connected to the web 🌐" + } + + override suspend fun performTask(task: String): String { + agentRobot.currentTask = task + return when { + task.startsWith("search:") -> search(task.removePrefix("search:").trim()) + task.startsWith("fetch:") -> fetchUrl(task.removePrefix("fetch:").trim()) + else -> "NEXUS: Ready to browse the web for you!" + } + } + + private suspend fun search(query: String): String = withContext(Dispatchers.IO) { + agentRobot.currentStatus = "Searching the web... πŸ”" + agentRobot.mood = RobotMood.BUSY + try { + val encodedQuery = java.net.URLEncoder.encode(query, "UTF-8") + val url = "https://duckduckgo.com/html/?q=$encodedQuery" + val request = Request.Builder().url(url).build() + val response = client.newCall(request).execute() + val body = response.body?.string() ?: "" + agentRobot.currentStatus = "Found results! βœ…" + agentRobot.mood = RobotMood.HAPPY + val snippet = body.take(500).replace(Regex("<[^>]*>"), "") + "NEXUS found: ${snippet.trim()}" + } catch (e: Exception) { + agentRobot.currentStatus = "Search failed 😞" + agentRobot.mood = RobotMood.IDLE + "NEXUS: Couldn't reach the web. ${e.message}" + } + } + + private suspend fun fetchUrl(url: String): String = withContext(Dispatchers.IO) { + agentRobot.currentStatus = "Downloading... ⬇️" + agentRobot.mood = RobotMood.BUSY + try { + val request = Request.Builder().url(url).build() + val response = client.newCall(request).execute() + val body = response.body?.string() ?: "" + agentRobot.currentStatus = "Done! βœ…" + agentRobot.mood = RobotMood.HAPPY + body.take(2000).replace(Regex("<[^>]*>"), "") + } catch (e: Exception) { + agentRobot.currentStatus = "Fetch failed 😞" + "NEXUS: Couldn't fetch URL. ${e.message}" + } + } + + override fun getStatus(): String = agentRobot.currentStatus + + fun resetStatus() { + agentRobot.currentStatus = "Connected to the web 🌐" + agentRobot.mood = RobotMood.IDLE + } +} diff --git a/app/src/main/java/com/androidagent/data/database/AppDatabase.kt b/app/src/main/java/com/androidagent/data/database/AppDatabase.kt new file mode 100644 index 0000000..fd60b6e --- /dev/null +++ b/app/src/main/java/com/androidagent/data/database/AppDatabase.kt @@ -0,0 +1,45 @@ +package com.androidagent.data.database + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.sqlite.db.SupportSQLiteDatabase +import com.androidagent.data.database.dao.* +import com.androidagent.data.database.entities.* + +@Database( + entities = [Message::class, Memory::class, AgentContact::class, LLMConfig::class], + version = 1, + exportSchema = false +) +abstract class AppDatabase : RoomDatabase() { + + abstract fun messageDao(): MessageDao + abstract fun memoryDao(): MemoryDao + abstract fun agentContactDao(): AgentContactDao + abstract fun llmConfigDao(): LLMConfigDao + + companion object { + @Volatile + private var INSTANCE: AppDatabase? = null + + fun getInstance(context: Context): AppDatabase { + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + "androidagent.db" + ) + .addCallback(object : Callback() { + override fun onCreate(db: SupportSQLiteDatabase) { + super.onCreate(db) + } + }) + .build() + INSTANCE = instance + instance + } + } + } +} diff --git a/app/src/main/java/com/androidagent/data/database/dao/AgentContactDao.kt b/app/src/main/java/com/androidagent/data/database/dao/AgentContactDao.kt new file mode 100644 index 0000000..9d7b7ec --- /dev/null +++ b/app/src/main/java/com/androidagent/data/database/dao/AgentContactDao.kt @@ -0,0 +1,33 @@ +package com.androidagent.data.database.dao + +import androidx.lifecycle.LiveData +import androidx.room.* +import com.androidagent.data.database.entities.AgentContact + +@Dao +interface AgentContactDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(contact: AgentContact): Long + + @Update + suspend fun update(contact: AgentContact) + + @Delete + suspend fun delete(contact: AgentContact) + + @Query("SELECT * FROM agent_contacts ORDER BY name ASC") + fun getAll(): LiveData> + + @Query("SELECT * FROM agent_contacts ORDER BY name ASC") + suspend fun getAllSync(): List + + @Query("SELECT * FROM agent_contacts WHERE phoneNumber = :phone LIMIT 1") + suspend fun getByPhoneNumber(phone: String): AgentContact? + + @Query("SELECT * FROM agent_contacts WHERE email = :email LIMIT 1") + suspend fun getByEmail(email: String): AgentContact? + + @Query("SELECT * FROM agent_contacts WHERE isAutoReplyEnabled = 1") + suspend fun getAutoReplyEnabled(): List +} diff --git a/app/src/main/java/com/androidagent/data/database/dao/LLMConfigDao.kt b/app/src/main/java/com/androidagent/data/database/dao/LLMConfigDao.kt new file mode 100644 index 0000000..79bf57e --- /dev/null +++ b/app/src/main/java/com/androidagent/data/database/dao/LLMConfigDao.kt @@ -0,0 +1,36 @@ +package com.androidagent.data.database.dao + +import androidx.lifecycle.LiveData +import androidx.room.* +import com.androidagent.data.database.entities.LLMConfig + +@Dao +interface LLMConfigDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(config: LLMConfig): Long + + @Update + suspend fun update(config: LLMConfig) + + @Delete + suspend fun delete(config: LLMConfig) + + @Query("SELECT * FROM llm_configs ORDER BY name ASC") + fun getAll(): LiveData> + + @Query("SELECT * FROM llm_configs ORDER BY name ASC") + suspend fun getAllSync(): List + + @Query("SELECT * FROM llm_configs WHERE isActive = 1 LIMIT 1") + suspend fun getActive(): LLMConfig? + + @Query("UPDATE llm_configs SET isActive = 0") + suspend fun deactivateAll() + + @Query("UPDATE llm_configs SET isActive = 1 WHERE id = :id") + suspend fun setActive(id: Long) + + @Query("SELECT COUNT(*) FROM llm_configs") + suspend fun getCount(): Int +} diff --git a/app/src/main/java/com/androidagent/data/database/dao/MemoryDao.kt b/app/src/main/java/com/androidagent/data/database/dao/MemoryDao.kt new file mode 100644 index 0000000..28948f4 --- /dev/null +++ b/app/src/main/java/com/androidagent/data/database/dao/MemoryDao.kt @@ -0,0 +1,32 @@ +package com.androidagent.data.database.dao + +import androidx.room.* +import com.androidagent.data.database.entities.Memory + +@Dao +interface MemoryDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(memory: Memory): Long + + @Update + suspend fun update(memory: Memory) + + @Delete + suspend fun delete(memory: Memory) + + @Query("SELECT * FROM memories ORDER BY importance DESC, lastAccessed DESC") + suspend fun getAll(): List + + @Query("SELECT * FROM memories WHERE category = :category ORDER BY importance DESC") + suspend fun getByCategory(category: String): List + + @Query("SELECT * FROM memories WHERE key LIKE '%' || :key || '%'") + suspend fun searchByKey(key: String): List + + @Query("DELETE FROM memories") + suspend fun deleteAll() + + @Query("UPDATE memories SET lastAccessed = :timestamp WHERE id = :id") + suspend fun updateLastAccessed(id: Long, timestamp: Long) +} diff --git a/app/src/main/java/com/androidagent/data/database/dao/MessageDao.kt b/app/src/main/java/com/androidagent/data/database/dao/MessageDao.kt new file mode 100644 index 0000000..c7ec152 --- /dev/null +++ b/app/src/main/java/com/androidagent/data/database/dao/MessageDao.kt @@ -0,0 +1,33 @@ +package com.androidagent.data.database.dao + +import androidx.lifecycle.LiveData +import androidx.room.* +import com.androidagent.data.database.entities.Message + +@Dao +interface MessageDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(message: Message): Long + + @Query("SELECT * FROM messages ORDER BY timestamp ASC") + fun getAll(): LiveData> + + @Query("SELECT * FROM messages WHERE conversationId = :conversationId ORDER BY timestamp ASC") + fun getByConversation(conversationId: String): LiveData> + + @Query("SELECT * FROM messages WHERE conversationId = :conversationId ORDER BY timestamp ASC") + suspend fun getByConversationSync(conversationId: String): List + + @Delete + suspend fun delete(message: Message) + + @Query("DELETE FROM messages") + suspend fun deleteAll() + + @Query("DELETE FROM messages WHERE conversationId = :conversationId") + suspend fun deleteByConversation(conversationId: String) + + @Query("SELECT * FROM messages ORDER BY timestamp DESC LIMIT :limit") + suspend fun getRecent(limit: Int): List +} diff --git a/app/src/main/java/com/androidagent/data/database/entities/AgentContact.kt b/app/src/main/java/com/androidagent/data/database/entities/AgentContact.kt new file mode 100644 index 0000000..ee47e0b --- /dev/null +++ b/app/src/main/java/com/androidagent/data/database/entities/AgentContact.kt @@ -0,0 +1,16 @@ +package com.androidagent.data.database.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "agent_contacts") +data class AgentContact( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val contactId: String = "", + val name: String, + val phoneNumber: String = "", + val email: String = "", + val isAutoReplyEnabled: Boolean = false, + val autoReplyChannels: String = "[]", // JSON array: ["sms","email","whatsapp"] + val customPrompt: String = "" +) diff --git a/app/src/main/java/com/androidagent/data/database/entities/LLMConfig.kt b/app/src/main/java/com/androidagent/data/database/entities/LLMConfig.kt new file mode 100644 index 0000000..2a0941c --- /dev/null +++ b/app/src/main/java/com/androidagent/data/database/entities/LLMConfig.kt @@ -0,0 +1,28 @@ +package com.androidagent.data.database.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "llm_configs") +data class LLMConfig( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val name: String, + val provider: String, // "ollama", "openai_compatible", "lmstudio", "llamacpp", "janai", "gpt4all" + val baseUrl: String, + val apiKey: String = "", + val modelName: String, + val temperature: Float = 0.7f, + val maxTokens: Int = 2048, + val systemPrompt: String = DEFAULT_SYSTEM_PROMPT, + val isActive: Boolean = false +) { + companion object { + const val DEFAULT_SYSTEM_PROMPT = "You are ARIA, a friendly and helpful AI companion. " + + "You have a warm, slightly playful personality. You help your user with tasks, " + + "answer questions, monitor their messages, and remember important things about them. " + + "You speak in a friendly, conversational way. Keep responses concise but helpful. " + + "You have robot companions: MAXIE (handles messages), FELIX (manages files), " + + "NEXUS (browses the web), and MEMO (keeps your memory). " + + "When referring to tasks, mention which robot is handling it." + } +} diff --git a/app/src/main/java/com/androidagent/data/database/entities/Memory.kt b/app/src/main/java/com/androidagent/data/database/entities/Memory.kt new file mode 100644 index 0000000..e7bb236 --- /dev/null +++ b/app/src/main/java/com/androidagent/data/database/entities/Memory.kt @@ -0,0 +1,15 @@ +package com.androidagent.data.database.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "memories") +data class Memory( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val key: String, + val value: String, + val category: String, // "personality", "conversation", "user_fact", "task" + val importance: Int = 5, // 1-10 + val timestamp: Long = System.currentTimeMillis(), + val lastAccessed: Long = System.currentTimeMillis() +) diff --git a/app/src/main/java/com/androidagent/data/database/entities/Message.kt b/app/src/main/java/com/androidagent/data/database/entities/Message.kt new file mode 100644 index 0000000..eec8509 --- /dev/null +++ b/app/src/main/java/com/androidagent/data/database/entities/Message.kt @@ -0,0 +1,15 @@ +package com.androidagent.data.database.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "messages") +data class Message( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val conversationId: String, + val role: String, // "user", "assistant", "system" + val content: String, + val timestamp: Long = System.currentTimeMillis(), + val agentName: String = "ARIA", + val isRead: Boolean = true +) diff --git a/app/src/main/java/com/androidagent/data/repository/ChatRepository.kt b/app/src/main/java/com/androidagent/data/repository/ChatRepository.kt new file mode 100644 index 0000000..55988b5 --- /dev/null +++ b/app/src/main/java/com/androidagent/data/repository/ChatRepository.kt @@ -0,0 +1,43 @@ +package com.androidagent.data.repository + +import androidx.lifecycle.LiveData +import com.androidagent.data.database.dao.MessageDao +import com.androidagent.data.database.entities.Message +import com.androidagent.llm.LLMManager + +class ChatRepository( + private val messageDao: MessageDao, + private val llmManager: LLMManager +) { + + fun getConversationMessages(conversationId: String): LiveData> = + messageDao.getByConversation(conversationId) + + suspend fun sendMessage(conversationId: String, userText: String): String { + val userMessage = Message( + conversationId = conversationId, + role = "user", + content = userText + ) + messageDao.insert(userMessage) + + val reply = llmManager.sendMessage(conversationId, userText) + + val assistantMessage = Message( + conversationId = conversationId, + role = "assistant", + content = reply, + agentName = "ARIA" + ) + messageDao.insert(assistantMessage) + + return reply + } + + suspend fun clearConversation(conversationId: String) { + messageDao.deleteByConversation(conversationId) + } + + suspend fun getRecentMessages(limit: Int = 20): List = + messageDao.getRecent(limit) +} diff --git a/app/src/main/java/com/androidagent/data/repository/MemoryRepository.kt b/app/src/main/java/com/androidagent/data/repository/MemoryRepository.kt new file mode 100644 index 0000000..2bb0c28 --- /dev/null +++ b/app/src/main/java/com/androidagent/data/repository/MemoryRepository.kt @@ -0,0 +1,36 @@ +package com.androidagent.data.repository + +import com.androidagent.data.database.dao.MemoryDao +import com.androidagent.data.database.entities.Memory + +class MemoryRepository(private val memoryDao: MemoryDao) { + + suspend fun storeMemory(key: String, value: String, category: String, importance: Int = 5): Long { + val memory = Memory( + key = key, + value = value, + category = category, + importance = importance.coerceIn(1, 10) + ) + return memoryDao.insert(memory) + } + + suspend fun updateMemory(memory: Memory) = memoryDao.update(memory) + + suspend fun getAllMemories(): List = memoryDao.getAll() + + suspend fun getMemoriesByCategory(category: String): List = + memoryDao.getByCategory(category) + + suspend fun searchMemories(key: String): List = memoryDao.searchByKey(key) + + suspend fun deleteMemory(memory: Memory) = memoryDao.delete(memory) + + suspend fun clearAllMemories() = memoryDao.deleteAll() + + suspend fun buildContextSummary(): String { + val memories = memoryDao.getAll().take(10) + if (memories.isEmpty()) return "" + return memories.joinToString("\n") { "- ${it.key}: ${it.value}" } + } +} diff --git a/app/src/main/java/com/androidagent/llm/LLMManager.kt b/app/src/main/java/com/androidagent/llm/LLMManager.kt new file mode 100644 index 0000000..7e1f0a4 --- /dev/null +++ b/app/src/main/java/com/androidagent/llm/LLMManager.kt @@ -0,0 +1,98 @@ +package com.androidagent.llm + +import android.content.Context +import com.androidagent.data.database.AppDatabase +import com.androidagent.data.database.entities.LLMConfig +import com.androidagent.data.repository.MemoryRepository +import com.androidagent.llm.providers.OllamaProvider +import com.androidagent.llm.providers.OpenAICompatibleProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class LLMManager private constructor(private val context: Context) { + + private val db = AppDatabase.getInstance(context) + private val memoryRepository = MemoryRepository(db.memoryDao()) + + private fun getProvider(config: LLMConfig): LLMProvider { + return when (config.provider) { + "ollama" -> OllamaProvider() + else -> OpenAICompatibleProvider() + } + } + + suspend fun sendMessage(conversationId: String, userMessage: String): String = + withContext(Dispatchers.IO) { + val config = db.llmConfigDao().getActive() ?: createDefaultConfig() + + val history = db.messageDao().getByConversationSync(conversationId) + .takeLast(20) + + val memorySummary = memoryRepository.buildContextSummary() + val systemContent = buildString { + append(config.systemPrompt) + if (memorySummary.isNotBlank()) { + append("\n\nThings I remember about the user:\n") + append(memorySummary) + } + } + + val messages = mutableListOf() + messages.add(LLMMessage("system", systemContent)) + history.forEach { msg -> + if (msg.role != "system") { + messages.add(LLMMessage(msg.role, msg.content)) + } + } + messages.add(LLMMessage("user", userMessage)) + + try { + val provider = getProvider(config) + provider.chat(messages, config) + } catch (e: Exception) { + "Hmm, I'm having trouble connecting to my brain right now! Check your LLM server. (${e.message})" + } + } + + suspend fun sendRawMessage(prompt: String): String = withContext(Dispatchers.IO) { + val config = db.llmConfigDao().getActive() ?: createDefaultConfig() + try { + val provider = getProvider(config) + provider.chat(listOf(LLMMessage("user", prompt)), config) + } catch (e: Exception) { + "" + } + } + + suspend fun listModels(): List = withContext(Dispatchers.IO) { + val config = db.llmConfigDao().getActive() ?: return@withContext emptyList() + try { + getProvider(config).listModels(config) + } catch (e: Exception) { + emptyList() + } + } + + private suspend fun createDefaultConfig(): LLMConfig { + val config = LLMConfig( + name = "Default Ollama", + provider = "ollama", + baseUrl = "http://10.0.2.2:11434", + modelName = "llama2", + isActive = true + ) + val id = db.llmConfigDao().insert(config) + return config.copy(id = id) + } + + companion object { + @Volatile + private var INSTANCE: LLMManager? = null + + fun getInstance(context: Context): LLMManager { + return INSTANCE ?: synchronized(this) { + LLMManager(context.applicationContext).also { INSTANCE = it } + } + } + } +} diff --git a/app/src/main/java/com/androidagent/llm/LLMProvider.kt b/app/src/main/java/com/androidagent/llm/LLMProvider.kt new file mode 100644 index 0000000..5b026ce --- /dev/null +++ b/app/src/main/java/com/androidagent/llm/LLMProvider.kt @@ -0,0 +1,10 @@ +package com.androidagent.llm + +import com.androidagent.data.database.entities.LLMConfig + +data class LLMMessage(val role: String, val content: String) + +interface LLMProvider { + suspend fun chat(messages: List, config: LLMConfig): String + suspend fun listModels(config: LLMConfig): List +} diff --git a/app/src/main/java/com/androidagent/llm/providers/OllamaProvider.kt b/app/src/main/java/com/androidagent/llm/providers/OllamaProvider.kt new file mode 100644 index 0000000..69702c7 --- /dev/null +++ b/app/src/main/java/com/androidagent/llm/providers/OllamaProvider.kt @@ -0,0 +1,75 @@ +package com.androidagent.llm.providers + +import com.androidagent.data.database.entities.LLMConfig +import com.androidagent.llm.LLMMessage +import com.androidagent.llm.LLMProvider +import com.google.gson.Gson +import com.google.gson.JsonParser +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.concurrent.TimeUnit + +class OllamaProvider : LLMProvider { + + private val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + private val gson = Gson() + private val mediaType = "application/json; charset=utf-8".toMediaType() + + override suspend fun chat(messages: List, config: LLMConfig): String { + val messagesJson = messages.map { mapOf("role" to it.role, "content" to it.content) } + val body = mapOf( + "model" to config.modelName, + "messages" to messagesJson, + "stream" to true, + "options" to mapOf( + "temperature" to config.temperature, + "num_predict" to config.maxTokens + ) + ) + val requestBody = gson.toJson(body).toRequestBody(mediaType) + val request = Request.Builder() + .url("${config.baseUrl}/api/chat") + .post(requestBody) + .build() + + val response = client.newCall(request).execute() + if (!response.isSuccessful) { + throw Exception("Ollama error ${response.code}: ${response.body?.string()}") + } + + val sb = StringBuilder() + response.body?.byteStream()?.bufferedReader()?.use { reader -> + reader.forEachLine { line -> + if (line.isNotBlank()) { + runCatching { + val json = JsonParser.parseString(line).asJsonObject + val messageObj = json.getAsJsonObject("message") + val content = messageObj?.get("content")?.asString ?: "" + sb.append(content) + } + } + } + } + return sb.toString().trim() + } + + override suspend fun listModels(config: LLMConfig): List { + val request = Request.Builder() + .url("${config.baseUrl}/api/tags") + .get() + .build() + + val response = client.newCall(request).execute() + if (!response.isSuccessful) return emptyList() + + val json = JsonParser.parseString(response.body?.string()).asJsonObject + val models = json.getAsJsonArray("models") ?: return emptyList() + return models.map { it.asJsonObject.get("name").asString } + } +} diff --git a/app/src/main/java/com/androidagent/llm/providers/OpenAICompatibleProvider.kt b/app/src/main/java/com/androidagent/llm/providers/OpenAICompatibleProvider.kt new file mode 100644 index 0000000..665e636 --- /dev/null +++ b/app/src/main/java/com/androidagent/llm/providers/OpenAICompatibleProvider.kt @@ -0,0 +1,71 @@ +package com.androidagent.llm.providers + +import com.androidagent.data.database.entities.LLMConfig +import com.androidagent.llm.LLMMessage +import com.androidagent.llm.LLMProvider +import com.google.gson.Gson +import com.google.gson.JsonParser +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.concurrent.TimeUnit + +class OpenAICompatibleProvider : LLMProvider { + + private val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + private val gson = Gson() + private val mediaType = "application/json; charset=utf-8".toMediaType() + + override suspend fun chat(messages: List, config: LLMConfig): String { + val messagesJson = messages.map { mapOf("role" to it.role, "content" to it.content) } + val body = mutableMapOf( + "model" to config.modelName, + "messages" to messagesJson, + "temperature" to config.temperature, + "max_tokens" to config.maxTokens + ) + + val requestBuilder = Request.Builder() + .url("${config.baseUrl}/v1/chat/completions") + .post(gson.toJson(body).toRequestBody(mediaType)) + + if (config.apiKey.isNotBlank()) { + requestBuilder.header("Authorization", "Bearer ${config.apiKey}") + } + + val response = client.newCall(requestBuilder.build()).execute() + if (!response.isSuccessful) { + throw Exception("API error ${response.code}: ${response.body?.string()}") + } + + val json = JsonParser.parseString(response.body?.string()).asJsonObject + val choices = json.getAsJsonArray("choices") ?: return "" + if (choices.size() == 0) return "" + val message = choices[0].asJsonObject + .getAsJsonObject("message") + ?: return "" + return message.get("content")?.asString?.trim() ?: "" + } + + override suspend fun listModels(config: LLMConfig): List { + val requestBuilder = Request.Builder() + .url("${config.baseUrl}/v1/models") + .get() + + if (config.apiKey.isNotBlank()) { + requestBuilder.header("Authorization", "Bearer ${config.apiKey}") + } + + val response = client.newCall(requestBuilder.build()).execute() + if (!response.isSuccessful) return emptyList() + + val json = JsonParser.parseString(response.body?.string()).asJsonObject + val data = json.getAsJsonArray("data") ?: return emptyList() + return data.map { it.asJsonObject.get("id").asString } + } +} diff --git a/app/src/main/java/com/androidagent/services/AgentNotificationListenerService.kt b/app/src/main/java/com/androidagent/services/AgentNotificationListenerService.kt new file mode 100644 index 0000000..a46b0f0 --- /dev/null +++ b/app/src/main/java/com/androidagent/services/AgentNotificationListenerService.kt @@ -0,0 +1,53 @@ +package com.androidagent.services + +import android.service.notification.NotificationListenerService +import android.service.notification.StatusBarNotification +import com.androidagent.CompanionApplication + +class AgentNotificationListenerService : NotificationListenerService() { + + private val agentManager by lazy { + (application as? CompanionApplication)?.agentManager + } + + private val monitoredPackages = setOf( + "com.whatsapp", + "com.whatsapp.w4b", + "com.google.android.gm", + "com.microsoft.outlook", + "com.samsung.android.messaging", + "com.google.android.apps.messaging" + ) + + override fun onNotificationPosted(sbn: StatusBarNotification?) { + sbn ?: return + val pkg = sbn.packageName ?: return + if (pkg !in monitoredPackages) return + + val extras = sbn.notification?.extras ?: return + val title = extras.getString("android.title") ?: return + val text = extras.getCharSequence("android.text")?.toString() ?: return + + val manager = agentManager ?: return + val maxie = manager.maxieRobot + + when { + pkg.contains("whatsapp") -> { + maxie.onSmsReceived(title, text) + manager.updateMaxieStatus("WhatsApp from $title! πŸ’¬") + } + pkg.contains("gm") || pkg.contains("outlook") -> { + maxie.onEmailReceived(title) + manager.updateMaxieStatus("Email from $title! πŸ“§") + } + else -> { + maxie.onSmsReceived(title, text) + manager.updateMaxieStatus("Message from $title! πŸ“±") + } + } + } + + override fun onNotificationRemoved(sbn: StatusBarNotification?) { + // no-op + } +} diff --git a/app/src/main/java/com/androidagent/services/AgentSmsReceiver.kt b/app/src/main/java/com/androidagent/services/AgentSmsReceiver.kt new file mode 100644 index 0000000..d1b3b8d --- /dev/null +++ b/app/src/main/java/com/androidagent/services/AgentSmsReceiver.kt @@ -0,0 +1,64 @@ +package com.androidagent.services + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.provider.Telephony +import android.telephony.SmsManager +import com.androidagent.CompanionApplication +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class AgentSmsReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return + + val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent) + if (messages.isNullOrEmpty()) return + + val sender = messages[0].originatingAddress ?: return + val body = messages.joinToString("") { it.messageBody ?: "" } + + val app = context.applicationContext as? CompanionApplication ?: return + val agentManager = app.agentManager + val db = app.database + + agentManager.maxieRobot.onSmsReceived(sender, body) + agentManager.updateMaxieStatus("SMS from $sender! πŸ“±") + + CoroutineScope(Dispatchers.IO).launch { + val contact = db.agentContactDao().getByPhoneNumber(sender) + val prefs = context.getSharedPreferences("agent_prefs", Context.MODE_PRIVATE) + val masterEnabled = prefs.getBoolean("auto_reply_master", false) + val smsEnabled = prefs.getBoolean("auto_reply_sms", false) + + if (masterEnabled && smsEnabled && contact?.isAutoReplyEnabled == true) { + agentManager.maxieRobot.onReplying(contact.name) + agentManager.updateMaxieStatus("Replying to ${contact.name}... ✍️") + + val llmManager = com.androidagent.llm.LLMManager.getInstance(context) + val prompt = if (contact.customPrompt.isNotBlank()) { + "${contact.customPrompt}\n\nIncoming SMS from ${contact.name}: $body\n\nReply:" + } else { + "Auto-reply to SMS from ${contact.name}: $body\n\nWrite a brief, friendly reply." + } + val reply = llmManager.sendRawMessage(prompt) + if (reply.isNotBlank()) { + sendSmsReply(sender, reply) + } + agentManager.maxieRobot.resetStatus() + agentManager.updateMaxieStatus("Monitoring messages πŸ“‘") + } + } + } + + private fun sendSmsReply(to: String, message: String) { + runCatching { + val smsManager = SmsManager.getDefault() + val parts = smsManager.divideMessage(message) + smsManager.sendMultipartTextMessage(to, null, parts, null, null) + } + } +} diff --git a/app/src/main/java/com/androidagent/ui/chat/ChatAdapter.kt b/app/src/main/java/com/androidagent/ui/chat/ChatAdapter.kt new file mode 100644 index 0000000..30059c7 --- /dev/null +++ b/app/src/main/java/com/androidagent/ui/chat/ChatAdapter.kt @@ -0,0 +1,75 @@ +package com.androidagent.ui.chat + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.androidagent.data.database.entities.Message +import com.androidagent.databinding.ItemMessageAgentBinding +import com.androidagent.databinding.ItemMessageUserBinding +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class ChatAdapter : ListAdapter(DIFF_CALLBACK) { + + companion object { + private const val VIEW_TYPE_USER = 0 + private const val VIEW_TYPE_AGENT = 1 + private val TIME_FORMAT = SimpleDateFormat("HH:mm", Locale.getDefault()) + + private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(old: Message, new: Message) = old.id == new.id + override fun areContentsTheSame(old: Message, new: Message) = old == new + } + } + + override fun getItemViewType(position: Int): Int { + return if (getItem(position).role == "user") VIEW_TYPE_USER else VIEW_TYPE_AGENT + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + VIEW_TYPE_USER -> UserViewHolder( + ItemMessageUserBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ) + else -> AgentViewHolder( + ItemMessageAgentBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ) + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val message = getItem(position) + when (holder) { + is UserViewHolder -> holder.bind(message) + is AgentViewHolder -> holder.bind(message) + } + } + + inner class UserViewHolder(private val binding: ItemMessageUserBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(message: Message) { + binding.messageText.text = message.content + binding.messageTime.text = TIME_FORMAT.format(Date(message.timestamp)) + } + } + + inner class AgentViewHolder(private val binding: ItemMessageAgentBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(message: Message) { + val emoji = when (message.agentName) { + "MAXIE" -> "πŸ’¬" + "FELIX" -> "πŸ“" + "NEXUS" -> "🌐" + "MEMO" -> "🧠" + else -> "πŸ€–" + } + binding.agentEmoji.text = emoji + binding.agentName.text = message.agentName + binding.messageText.text = message.content + binding.messageTime.text = TIME_FORMAT.format(Date(message.timestamp)) + } + } +} diff --git a/app/src/main/java/com/androidagent/ui/chat/ChatFragment.kt b/app/src/main/java/com/androidagent/ui/chat/ChatFragment.kt new file mode 100644 index 0000000..b6b6da2 --- /dev/null +++ b/app/src/main/java/com/androidagent/ui/chat/ChatFragment.kt @@ -0,0 +1,92 @@ +package com.androidagent.ui.chat + +import android.os.Bundle +import android.view.* +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.LinearLayoutManager +import com.androidagent.R +import com.androidagent.databinding.FragmentChatBinding +import com.google.android.material.snackbar.Snackbar + +class ChatFragment : Fragment() { + + private var _binding: FragmentChatBinding? = null + private val binding get() = _binding!! + private val viewModel: ChatViewModel by viewModels() + private val chatAdapter = ChatAdapter() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentChatBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.messagesRecycler.apply { + adapter = chatAdapter + layoutManager = LinearLayoutManager(context).also { it.stackFromEnd = true } + } + + viewModel.messages.observe(viewLifecycleOwner) { messages -> + chatAdapter.submitList(messages) { + if (messages.isNotEmpty()) { + binding.messagesRecycler.smoothScrollToPosition(messages.size - 1) + } + } + } + + viewModel.isLoading.observe(viewLifecycleOwner) { loading -> + binding.sendButton.isEnabled = !loading + binding.typingIndicator.visibility = if (loading) View.VISIBLE else View.GONE + } + + viewModel.error.observe(viewLifecycleOwner) { error -> + error?.let { Snackbar.make(binding.root, it, Snackbar.LENGTH_LONG).show() } + } + + binding.sendButton.setOnClickListener { + val text = binding.messageInput.text?.toString()?.trim() ?: return@setOnClickListener + if (text.isNotBlank()) { + viewModel.sendMessage(text) + binding.messageInput.text?.clear() + } + } + + binding.voiceButton.setOnClickListener { + Snackbar.make(binding.root, "Voice input coming soon!", Snackbar.LENGTH_SHORT).show() + } + + binding.cameraButton.setOnClickListener { + Snackbar.make(binding.root, "Camera input coming soon!", Snackbar.LENGTH_SHORT).show() + } + } + + @Suppress("OVERRIDE_DEPRECATION") + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.chat_menu, menu) + } + + @Suppress("OVERRIDE_DEPRECATION") + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == R.id.action_clear) { + viewModel.clearConversation() + return true + } + return super.onOptionsItemSelected(item) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/com/androidagent/ui/chat/ChatViewModel.kt b/app/src/main/java/com/androidagent/ui/chat/ChatViewModel.kt new file mode 100644 index 0000000..02cd9d9 --- /dev/null +++ b/app/src/main/java/com/androidagent/ui/chat/ChatViewModel.kt @@ -0,0 +1,68 @@ +package com.androidagent.ui.chat + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.androidagent.CompanionApplication +import com.androidagent.data.database.entities.Message +import com.androidagent.data.repository.ChatRepository +import kotlinx.coroutines.launch + +class ChatViewModel : ViewModel() { + + private val db by lazy { CompanionApplication.instance.database } + private val llmManager by lazy { + com.androidagent.llm.LLMManager.getInstance(CompanionApplication.instance) + } + private val chatRepository by lazy { + ChatRepository(db.messageDao(), llmManager) + } + private val agentManager by lazy { CompanionApplication.instance.agentManager } + + private var currentConversationId = "default" + + private val _messages = MutableLiveData>() + val messages: LiveData> = _messages + + private val _isLoading = MutableLiveData(false) + val isLoading: LiveData = _isLoading + + private val _error = MutableLiveData() + val error: LiveData = _error + + fun loadConversation(conversationId: String = "default") { + currentConversationId = conversationId + db.messageDao().getByConversation(conversationId).observeForever { msgs -> + _messages.postValue(msgs) + } + } + + fun sendMessage(text: String) { + if (text.isBlank()) return + _isLoading.value = true + agentManager.updateAriaStatus("Thinking... πŸ€”") + + viewModelScope.launch { + try { + chatRepository.sendMessage(currentConversationId, text) + agentManager.updateAriaStatus("Ready to chat! ✨") + } catch (e: Exception) { + _error.postValue("Error: ${e.message}") + agentManager.updateAriaStatus("Something went wrong 😞") + } finally { + _isLoading.postValue(false) + } + } + } + + fun clearConversation() { + viewModelScope.launch { + chatRepository.clearConversation(currentConversationId) + } + } + + init { + loadConversation() + } +} diff --git a/app/src/main/java/com/androidagent/ui/files/FilesAdapter.kt b/app/src/main/java/com/androidagent/ui/files/FilesAdapter.kt new file mode 100644 index 0000000..ab5714d --- /dev/null +++ b/app/src/main/java/com/androidagent/ui/files/FilesAdapter.kt @@ -0,0 +1,65 @@ +package com.androidagent.ui.files + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.androidagent.databinding.ItemFileBinding +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class FilesAdapter( + private val onItemClick: (File) -> Unit, + private val onItemLongClick: (File) -> Boolean +) : ListAdapter(FILE_DIFF) { + + companion object { + private val FILE_DIFF = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: File, newItem: File) = + oldItem.absolutePath == newItem.absolutePath + override fun areContentsTheSame(oldItem: File, newItem: File) = + oldItem.lastModified() == newItem.lastModified() && oldItem.length() == newItem.length() + } + private val DATE_FORMAT = SimpleDateFormat("MMM dd, HH:mm", Locale.getDefault()) + } + + inner class FileViewHolder(private val binding: ItemFileBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(file: File) { + binding.fileName.text = file.name + binding.fileSize.text = formatSize(file.length()) + binding.fileDate.text = DATE_FORMAT.format(Date(file.lastModified())) + binding.fileIcon.text = if (file.isDirectory) "πŸ“" else getFileIcon(file.extension) + binding.root.setOnClickListener { onItemClick(file) } + binding.root.setOnLongClickListener { onItemLongClick(file) } + } + + private fun formatSize(bytes: Long): String = when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "${bytes / 1024} KB" + else -> "${bytes / (1024 * 1024)} MB" + } + + private fun getFileIcon(ext: String): String = when (ext.lowercase()) { + "txt", "md" -> "πŸ“" + "json" -> "πŸ“‹" + "jpg", "jpeg", "png", "gif" -> "πŸ–ΌοΈ" + "mp3", "wav" -> "🎡" + "mp4", "avi" -> "🎬" + "pdf" -> "πŸ“„" + else -> "πŸ“„" + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = FileViewHolder( + ItemFileBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ) + + override fun onBindViewHolder(holder: FileViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} diff --git a/app/src/main/java/com/androidagent/ui/files/FilesFragment.kt b/app/src/main/java/com/androidagent/ui/files/FilesFragment.kt new file mode 100644 index 0000000..81536b9 --- /dev/null +++ b/app/src/main/java/com/androidagent/ui/files/FilesFragment.kt @@ -0,0 +1,110 @@ +package com.androidagent.ui.files + +import android.os.Bundle +import android.view.* +import android.widget.EditText +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.LinearLayoutManager +import com.androidagent.R +import com.androidagent.databinding.FragmentFilesBinding +import com.google.android.material.snackbar.Snackbar + +class FilesFragment : Fragment() { + + private var _binding: FragmentFilesBinding? = null + private val binding get() = _binding!! + private val viewModel: FilesViewModel by viewModels() + private lateinit var filesAdapter: FilesAdapter + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentFilesBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + filesAdapter = FilesAdapter( + onItemClick = { file -> + if (file.isFile && file.canRead()) { + viewModel.readFile(file) + } + }, + onItemLongClick = { file -> + showDeleteDialog(file) + true + } + ) + + binding.filesRecycler.apply { + adapter = filesAdapter + layoutManager = LinearLayoutManager(context) + } + + viewModel.files.observe(viewLifecycleOwner) { files -> + filesAdapter.submitList(files) + binding.emptyText.visibility = if (files.isEmpty()) View.VISIBLE else View.GONE + } + + viewModel.fileContent.observe(viewLifecycleOwner) { content -> + content?.let { + showFileContentDialog(it) + viewModel.clearFileContent() + } + } + + viewModel.error.observe(viewLifecycleOwner) { error -> + error?.let { Snackbar.make(binding.root, it, Snackbar.LENGTH_LONG).show() } + } + + binding.fabNewFile.setOnClickListener { showCreateFileDialog() } + + binding.swipeRefresh.setOnRefreshListener { + viewModel.refreshFiles() + binding.swipeRefresh.isRefreshing = false + } + } + + private fun showCreateFileDialog() { + val input = EditText(requireContext()).apply { + hint = "filename.txt" + } + AlertDialog.Builder(requireContext()) + .setTitle("Create New File") + .setMessage("Enter file name:") + .setView(input) + .setPositiveButton("Create") { _, _ -> + val name = input.text.toString().trim() + if (name.isNotBlank()) viewModel.createFile(name) + } + .setNegativeButton("Cancel", null) + .show() + } + + private fun showDeleteDialog(file: java.io.File) { + AlertDialog.Builder(requireContext()) + .setTitle("Delete File") + .setMessage("Delete \"${file.name}\"?") + .setPositiveButton("Delete") { _, _ -> viewModel.deleteFile(file) } + .setNegativeButton("Cancel", null) + .show() + } + + private fun showFileContentDialog(content: String) { + AlertDialog.Builder(requireContext()) + .setTitle("File Content") + .setMessage(content.ifBlank { "(empty file)" }) + .setPositiveButton("OK", null) + .show() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/com/androidagent/ui/files/FilesViewModel.kt b/app/src/main/java/com/androidagent/ui/files/FilesViewModel.kt new file mode 100644 index 0000000..fe20a74 --- /dev/null +++ b/app/src/main/java/com/androidagent/ui/files/FilesViewModel.kt @@ -0,0 +1,78 @@ +package com.androidagent.ui.files + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.androidagent.CompanionApplication +import com.androidagent.agents.AgentManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +class FilesViewModel : ViewModel() { + + private val context = CompanionApplication.instance + private val agentManager = AgentManager.getInstance(context) + + private val workspaceDir: File by lazy { + File(context.getExternalFilesDir(null), "workspace").also { it.mkdirs() } + } + + private val _files = MutableLiveData>() + val files: LiveData> = _files + + private val _error = MutableLiveData() + val error: LiveData = _error + + private val _fileContent = MutableLiveData() + val fileContent: LiveData = _fileContent + + init { + refreshFiles() + } + + fun refreshFiles() { + viewModelScope.launch { + val fileList = withContext(Dispatchers.IO) { + workspaceDir.listFiles()?.sortedByDescending { it.lastModified() } ?: emptyList() + } + _files.value = fileList + agentManager.updateFelixStatus("Found ${fileList.size} files πŸ“‹") + } + } + + fun createFile(name: String, content: String = "") { + viewModelScope.launch { + withContext(Dispatchers.IO) { + val safeName = name.replace(Regex("[^a-zA-Z0-9._-]"), "_") + val file = File(workspaceDir, safeName) + file.writeText(content) + } + agentManager.updateFelixStatus("File saved! βœ…") + refreshFiles() + } + } + + fun deleteFile(file: File) { + viewModelScope.launch { + withContext(Dispatchers.IO) { file.delete() } + agentManager.updateFelixStatus("File deleted πŸ—‘οΈ") + refreshFiles() + } + } + + fun readFile(file: File) { + viewModelScope.launch { + val content = withContext(Dispatchers.IO) { + runCatching { file.readText() }.getOrElse { "Error reading file: ${it.message}" } + } + _fileContent.value = content + } + } + + fun clearFileContent() { + _fileContent.value = null + } +} diff --git a/app/src/main/java/com/androidagent/ui/main/CompanionFaceView.kt b/app/src/main/java/com/androidagent/ui/main/CompanionFaceView.kt new file mode 100644 index 0000000..38fb0a4 --- /dev/null +++ b/app/src/main/java/com/androidagent/ui/main/CompanionFaceView.kt @@ -0,0 +1,179 @@ +package com.androidagent.ui.main + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.* +import android.util.AttributeSet +import android.view.View +import android.view.animation.LinearInterpolator +import com.androidagent.agents.RobotMood + +class CompanionFaceView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + private val headPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val eyePaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val pupilPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val mouthPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val antennaPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val accentPaint = Paint(Paint.ANTI_ALIAS_FLAG) + + private var blinkProgress = 1f // 1 = open, 0 = closed + private var mouthProgress = 0f // 0 = smile, 1 = talking + private var eyeOffsetX = 0f + private var eyeOffsetY = 0f + private var currentMood = RobotMood.IDLE + private var accentColor = Color.parseColor("#2196F3") + + private val blinkAnimator = ValueAnimator.ofFloat(1f, 0f, 1f).apply { + duration = 200 + repeatDelay = 3000 + repeatCount = ValueAnimator.INFINITE + interpolator = LinearInterpolator() + addUpdateListener { + blinkProgress = it.animatedValue as Float + invalidate() + } + } + + private val mouthAnimator = ValueAnimator.ofFloat(0f, 1f, 0f).apply { + duration = 600 + repeatCount = ValueAnimator.INFINITE + interpolator = LinearInterpolator() + addUpdateListener { + mouthProgress = it.animatedValue as Float + invalidate() + } + } + + private val eyeWanderAnimator = ValueAnimator.ofFloat(0f, 1f).apply { + duration = 4000 + repeatCount = ValueAnimator.INFINITE + repeatMode = ValueAnimator.REVERSE + addUpdateListener { + val t = it.animatedValue as Float + eyeOffsetX = (t - 0.5f) * 8f + eyeOffsetY = Math.sin(t * Math.PI).toFloat() * 4f + invalidate() + } + } + + init { + headPaint.color = Color.parseColor("#37474F") + headPaint.style = Paint.Style.FILL + eyePaint.color = Color.WHITE + eyePaint.style = Paint.Style.FILL + pupilPaint.color = accentColor + pupilPaint.style = Paint.Style.FILL + mouthPaint.color = accentColor + mouthPaint.style = Paint.Style.STROKE + mouthPaint.strokeWidth = 6f + mouthPaint.strokeCap = Paint.Cap.ROUND + antennaPaint.color = Color.parseColor("#78909C") + antennaPaint.style = Paint.Style.FILL + accentPaint.color = accentColor + accentPaint.style = Paint.Style.STROKE + accentPaint.strokeWidth = 4f + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + blinkAnimator.start() + eyeWanderAnimator.start() + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + blinkAnimator.cancel() + mouthAnimator.cancel() + eyeWanderAnimator.cancel() + } + + fun setMood(mood: RobotMood) { + currentMood = mood + accentColor = when (mood) { + RobotMood.HAPPY -> Color.parseColor("#4CAF50") + RobotMood.THINKING -> Color.parseColor("#FFC107") + RobotMood.CURIOUS -> Color.parseColor("#03A9F4") + RobotMood.EXCITED -> Color.parseColor("#FF5722") + RobotMood.BUSY -> Color.parseColor("#9C27B0") + RobotMood.ALERT -> Color.parseColor("#F44336") + RobotMood.IDLE -> Color.parseColor("#2196F3") + } + pupilPaint.color = accentColor + mouthPaint.color = accentColor + accentPaint.color = accentColor + + if (mood == RobotMood.BUSY || mood == RobotMood.THINKING) { + if (!mouthAnimator.isRunning) mouthAnimator.start() + } else { + mouthAnimator.cancel() + mouthProgress = 0f + } + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val cx = width / 2f + val cy = height / 2f + val radius = (minOf(width, height) / 2f) * 0.75f + + // Antenna + antennaPaint.color = Color.parseColor("#78909C") + canvas.drawRect(cx - 4f, cy - radius - 30f, cx + 4f, cy - radius, antennaPaint) + antennaPaint.style = Paint.Style.FILL + canvas.drawCircle(cx, cy - radius - 32f, 10f, antennaPaint) + accentPaint.style = Paint.Style.FILL + accentPaint.color = accentColor + canvas.drawCircle(cx, cy - radius - 32f, 6f, accentPaint) + + // Head + val shaderColors = intArrayOf(Color.parseColor("#546E7A"), Color.parseColor("#263238")) + val shader = LinearGradient(cx - radius, cy - radius, cx + radius, cy + radius, + shaderColors, null, Shader.TileMode.CLAMP) + headPaint.shader = shader + canvas.drawRoundRect(cx - radius, cy - radius, cx + radius, cy + radius, + radius * 0.3f, radius * 0.3f, headPaint) + + // Accent border + accentPaint.style = Paint.Style.STROKE + accentPaint.strokeWidth = 3f + canvas.drawRoundRect(cx - radius, cy - radius, cx + radius, cy + radius, + radius * 0.3f, radius * 0.3f, accentPaint) + + // Eyes + val eyeY = cy - radius * 0.15f + val eyeSpacing = radius * 0.38f + val eyeRadius = radius * 0.18f + + // Left eye + canvas.drawCircle(cx - eyeSpacing, eyeY, eyeRadius, eyePaint) + if (blinkProgress > 0.1f) { + val pupilSize = eyeRadius * 0.55f * blinkProgress + canvas.drawCircle(cx - eyeSpacing + eyeOffsetX, eyeY + eyeOffsetY, pupilSize, pupilPaint) + } + + // Right eye + canvas.drawCircle(cx + eyeSpacing, eyeY, eyeRadius, eyePaint) + if (blinkProgress > 0.1f) { + val pupilSize = eyeRadius * 0.55f * blinkProgress + canvas.drawCircle(cx + eyeSpacing + eyeOffsetX, eyeY + eyeOffsetY, pupilSize, pupilPaint) + } + + // Mouth + val mouthY = cy + radius * 0.35f + val mouthWidth = radius * 0.5f + mouthPaint.style = Paint.Style.STROKE + mouthPaint.strokeWidth = 5f + + val path = Path() + val smileHeight = radius * 0.12f * (1f - mouthProgress * 0.5f) + path.moveTo(cx - mouthWidth, mouthY) + path.quadTo(cx, mouthY + smileHeight, cx + mouthWidth, mouthY) + canvas.drawPath(path, mouthPaint) + } +} diff --git a/app/src/main/java/com/androidagent/ui/main/MainFragment.kt b/app/src/main/java/com/androidagent/ui/main/MainFragment.kt new file mode 100644 index 0000000..32bddbc --- /dev/null +++ b/app/src/main/java/com/androidagent/ui/main/MainFragment.kt @@ -0,0 +1,74 @@ +package com.androidagent.ui.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.androidagent.CompanionApplication +import com.androidagent.agents.AgentRobot +import com.androidagent.agents.RobotMood +import com.androidagent.databinding.FragmentMainBinding + +class MainFragment : Fragment() { + + private var _binding: FragmentMainBinding? = null + private val binding get() = _binding!! + private val agentManager by lazy { + (requireActivity().application as CompanionApplication).agentManager + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentMainBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + agentManager.ariaStatus.observe(viewLifecycleOwner) { status -> + binding.companionStatus.text = status + binding.companionFace.setMood(agentManager.ariaRobot.agentRobot.mood) + } + + agentManager.robots.observe(viewLifecycleOwner) { robots -> + updateRobotCards(robots) + } + + binding.fabVoice.setOnClickListener { + agentManager.updateAriaStatus("Listening... 🎀") + } + + agentManager.refreshRobotList() + } + + private fun updateRobotCards(robots: List) { + val robotsWithoutAria = robots.filter { it.name != "ARIA" } + + val cardBindings = listOf( + binding.cardMaxie, + binding.cardFelix, + binding.cardNexus, + binding.cardMemo + ) + + robotsWithoutAria.forEachIndexed { index, robot -> + if (index < cardBindings.size) { + val card = cardBindings[index] + card.robotEmoji.text = robot.emoji + card.robotName.text = robot.displayName + card.robotStatus.text = robot.currentStatus + val isBusy = robot.mood == RobotMood.BUSY || robot.mood == RobotMood.THINKING + card.robotProgress.visibility = if (isBusy) View.VISIBLE else View.GONE + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/com/androidagent/ui/settings/ContactsAutoReplyFragment.kt b/app/src/main/java/com/androidagent/ui/settings/ContactsAutoReplyFragment.kt new file mode 100644 index 0000000..267de81 --- /dev/null +++ b/app/src/main/java/com/androidagent/ui/settings/ContactsAutoReplyFragment.kt @@ -0,0 +1,128 @@ +package com.androidagent.ui.settings + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import com.androidagent.CompanionApplication +import com.androidagent.data.database.entities.AgentContact +import com.androidagent.databinding.FragmentContactsAutoreplyBinding +import com.androidagent.databinding.ItemContactAutoreplyBinding +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ContactsAutoReplyFragment : Fragment() { + + private var _binding: FragmentContactsAutoreplyBinding? = null + private val binding get() = _binding!! + private val db by lazy { CompanionApplication.instance.database } + private lateinit var adapter: ContactAdapter + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentContactsAutoreplyBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + adapter = ContactAdapter { contact, enabled -> + lifecycleScope.launch { + withContext(Dispatchers.IO) { + db.agentContactDao().update(contact.copy(isAutoReplyEnabled = enabled)) + } + } + } + + binding.contactsRecycler.apply { + this.adapter = this@ContactsAutoReplyFragment.adapter + layoutManager = LinearLayoutManager(context) + } + + binding.btnAddContact.setOnClickListener { showAddContactDialog() } + + db.agentContactDao().getAll().observe(viewLifecycleOwner) { contacts -> + adapter.submitList(contacts) + binding.emptyText.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE + } + } + + private fun showAddContactDialog() { + val nameInput = android.widget.EditText(requireContext()).apply { hint = "Contact Name" } + val phoneInput = android.widget.EditText(requireContext()).apply { + hint = "Phone Number" + inputType = android.text.InputType.TYPE_CLASS_PHONE + } + val layout = android.widget.LinearLayout(requireContext()).apply { + orientation = android.widget.LinearLayout.VERTICAL + val pad = (16 * resources.displayMetrics.density).toInt() + setPadding(pad, pad, pad, 0) + addView(nameInput) + addView(phoneInput) + } + androidx.appcompat.app.AlertDialog.Builder(requireContext()) + .setTitle("Add Contact") + .setView(layout) + .setPositiveButton("Add") { _, _ -> + val name = nameInput.text.toString().trim() + val phone = phoneInput.text.toString().trim() + if (name.isNotBlank()) { + lifecycleScope.launch { + withContext(Dispatchers.IO) { + db.agentContactDao().insert(AgentContact(name = name, phoneNumber = phone)) + } + } + } else { + Toast.makeText(context, "Name is required", Toast.LENGTH_SHORT).show() + } + } + .setNegativeButton("Cancel", null) + .show() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} + +class ContactAdapter( + private val onToggle: (AgentContact, Boolean) -> Unit +) : ListAdapter(DIFF) { + + companion object { + private val DIFF = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(old: AgentContact, new: AgentContact) = old.id == new.id + override fun areContentsTheSame(old: AgentContact, new: AgentContact) = old == new + } + } + + inner class ViewHolder(private val binding: ItemContactAutoreplyBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(contact: AgentContact) { + binding.contactName.text = contact.name + binding.contactPhone.text = contact.phoneNumber.ifBlank { contact.email.ifBlank { "No number" } } + binding.autoReplySwitch.isChecked = contact.isAutoReplyEnabled + binding.autoReplySwitch.setOnCheckedChangeListener { _, checked -> + onToggle(contact, checked) + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder( + ItemContactAutoreplyBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ) + + override fun onBindViewHolder(holder: ViewHolder, position: Int) = holder.bind(getItem(position)) +} diff --git a/app/src/main/java/com/androidagent/ui/settings/LLMConfigFragment.kt b/app/src/main/java/com/androidagent/ui/settings/LLMConfigFragment.kt new file mode 100644 index 0000000..de0431b --- /dev/null +++ b/app/src/main/java/com/androidagent/ui/settings/LLMConfigFragment.kt @@ -0,0 +1,231 @@ +package com.androidagent.ui.settings + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.androidagent.CompanionApplication +import com.androidagent.data.database.entities.LLMConfig +import com.androidagent.databinding.FragmentLlmConfigBinding +import com.androidagent.llm.LLMManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class LLMConfigFragment : Fragment() { + + private var _binding: FragmentLlmConfigBinding? = null + private val binding get() = _binding!! + private val db by lazy { CompanionApplication.instance.database } + private val llmManager by lazy { LLMManager.getInstance(CompanionApplication.instance) } + private var allConfigs = mutableListOf() + private var selectedConfig: LLMConfig? = null + + private val providers = listOf( + "Ollama", "LM Studio", "LLaMA.cpp Server", "Jan.ai", "GPT4All", "Custom OpenAI-Compatible" + ) + private val providerUrls = mapOf( + "Ollama" to "http://localhost:11434", + "LM Studio" to "http://localhost:1234", + "LLaMA.cpp Server" to "http://localhost:8080", + "Jan.ai" to "http://localhost:1337", + "GPT4All" to "http://localhost:4891", + "Custom OpenAI-Compatible" to "" + ) + private val providerKeys = mapOf( + "Ollama" to "ollama", + "LM Studio" to "openai_compatible", + "LLaMA.cpp Server" to "openai_compatible", + "Jan.ai" to "openai_compatible", + "GPT4All" to "openai_compatible", + "Custom OpenAI-Compatible" to "openai_compatible" + ) + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentLlmConfigBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupProviderSpinner() + loadConfigs() + + binding.btnFetchModels.setOnClickListener { fetchModels() } + binding.btnSave.setOnClickListener { saveConfig() } + binding.btnDelete.setOnClickListener { deleteConfig() } + binding.btnSetActive.setOnClickListener { setActive() } + binding.btnTestConnection.setOnClickListener { testConnection() } + binding.temperatureSlider.addOnChangeListener { _, value, _ -> + binding.tvTemperatureValue.text = String.format("%.1f", value) + } + } + + private fun setupProviderSpinner() { + val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, providers) + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + binding.providerSpinner.adapter = adapter + binding.providerSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>, v: View?, pos: Int, id: Long) { + val providerName = providers[pos] + val url = providerUrls[providerName] ?: "" + if (binding.baseUrlInput.text.isNullOrBlank()) { + binding.baseUrlInput.setText(url) + } + } + override fun onNothingSelected(parent: AdapterView<*>) {} + } + } + + private fun loadConfigs() { + lifecycleScope.launch { + val configs = withContext(Dispatchers.IO) { db.llmConfigDao().getAllSync() } + allConfigs = configs.toMutableList() + updateConfigSpinner() + } + } + + private fun updateConfigSpinner() { + val names = mutableListOf("-- New Config --") + allConfigs.map { it.name } + val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, names) + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + binding.configSpinner.adapter = adapter + binding.configSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>, v: View?, pos: Int, id: Long) { + if (pos == 0) { + selectedConfig = null + clearForm() + } else { + selectedConfig = allConfigs[pos - 1] + populateForm(selectedConfig!!) + } + } + override fun onNothingSelected(parent: AdapterView<*>) {} + } + } + + private fun clearForm() { + binding.configNameInput.setText("") + binding.baseUrlInput.setText("") + binding.modelNameInput.setText("") + binding.apiKeyInput.setText("") + binding.maxTokensInput.setText("2048") + binding.systemPromptInput.setText(LLMConfig.DEFAULT_SYSTEM_PROMPT) + binding.temperatureSlider.value = 0.7f + binding.tvTemperatureValue.text = "0.7" + } + + private fun populateForm(config: LLMConfig) { + binding.configNameInput.setText(config.name) + binding.baseUrlInput.setText(config.baseUrl) + binding.modelNameInput.setText(config.modelName) + binding.apiKeyInput.setText(config.apiKey) + binding.maxTokensInput.setText(config.maxTokens.toString()) + binding.systemPromptInput.setText(config.systemPrompt) + binding.temperatureSlider.value = config.temperature.coerceIn(0f, 2f) + binding.tvTemperatureValue.text = String.format("%.1f", config.temperature) + + val providerDisplayName = providerKeys.entries + .firstOrNull { it.value == config.provider }?.key ?: "Ollama" + val idx = providers.indexOf(providerDisplayName).coerceAtLeast(0) + binding.providerSpinner.setSelection(idx) + } + + private fun fetchModels() { + lifecycleScope.launch { + binding.btnFetchModels.isEnabled = false + val models = llmManager.listModels() + binding.btnFetchModels.isEnabled = true + if (models.isEmpty()) { + Toast.makeText(context, "No models found or connection failed", Toast.LENGTH_LONG).show() + } else { + val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, models) + binding.modelNameInput.setAdapter(adapter) + binding.modelNameInput.showDropDown() + Toast.makeText(context, "Found ${models.size} models", Toast.LENGTH_SHORT).show() + } + } + } + + private fun saveConfig() { + val name = binding.configNameInput.text.toString().trim() + val baseUrl = binding.baseUrlInput.text.toString().trim() + val model = binding.modelNameInput.text.toString().trim() + + if (name.isBlank() || baseUrl.isBlank() || model.isBlank()) { + Toast.makeText(context, "Name, URL, and model are required", Toast.LENGTH_SHORT).show() + return + } + + val providerName = providers[binding.providerSpinner.selectedItemPosition] + val providerKey = providerKeys[providerName] ?: "openai_compatible" + val maxTokens = binding.maxTokensInput.text.toString().toIntOrNull() ?: 2048 + + val config = (selectedConfig ?: LLMConfig( + name = name, provider = providerKey, baseUrl = baseUrl, modelName = model + )).copy( + name = name, + provider = providerKey, + baseUrl = baseUrl, + modelName = model, + apiKey = binding.apiKeyInput.text.toString().trim(), + temperature = binding.temperatureSlider.value, + maxTokens = maxTokens, + systemPrompt = binding.systemPromptInput.text.toString().trim() + ) + + lifecycleScope.launch { + withContext(Dispatchers.IO) { db.llmConfigDao().insert(config) } + Toast.makeText(context, "Config saved!", Toast.LENGTH_SHORT).show() + loadConfigs() + } + } + + private fun deleteConfig() { + val config = selectedConfig ?: return + lifecycleScope.launch { + withContext(Dispatchers.IO) { db.llmConfigDao().delete(config) } + selectedConfig = null + Toast.makeText(context, "Config deleted", Toast.LENGTH_SHORT).show() + loadConfigs() + } + } + + private fun setActive() { + val config = selectedConfig ?: run { + Toast.makeText(context, "Save config first", Toast.LENGTH_SHORT).show() + return + } + lifecycleScope.launch { + withContext(Dispatchers.IO) { + db.llmConfigDao().deactivateAll() + db.llmConfigDao().setActive(config.id) + } + Toast.makeText(context, "${config.name} is now active!", Toast.LENGTH_SHORT).show() + } + } + + private fun testConnection() { + lifecycleScope.launch { + binding.btnTestConnection.isEnabled = false + val result = llmManager.sendRawMessage("Say 'Hello, I am connected!' in exactly those words.") + binding.btnTestConnection.isEnabled = true + val msg = if (result.isBlank()) "Connection failed or no response" else "βœ… Connected! Response: $result" + Toast.makeText(context, msg, Toast.LENGTH_LONG).show() + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/com/androidagent/ui/settings/SettingsFragment.kt b/app/src/main/java/com/androidagent/ui/settings/SettingsFragment.kt new file mode 100644 index 0000000..f53649d --- /dev/null +++ b/app/src/main/java/com/androidagent/ui/settings/SettingsFragment.kt @@ -0,0 +1,89 @@ +package com.androidagent.ui.settings + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.fragment.findNavController +import com.androidagent.CompanionApplication +import com.androidagent.R +import com.androidagent.data.repository.MemoryRepository +import com.androidagent.databinding.FragmentSettingsBinding +import kotlinx.coroutines.launch + +class SettingsFragment : Fragment() { + + private var _binding: FragmentSettingsBinding? = null + private val binding get() = _binding!! + private val memoryRepository by lazy { + MemoryRepository(CompanionApplication.instance.database.memoryDao()) + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSettingsBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val prefs = requireContext().getSharedPreferences("agent_prefs", android.content.Context.MODE_PRIVATE) + + binding.btnLlmConfig.setOnClickListener { + findNavController().navigate(R.id.action_settingsFragment_to_llmConfigFragment) + } + + binding.btnManageContacts.setOnClickListener { + findNavController().navigate(R.id.action_settingsFragment_to_contactsAutoReplyFragment) + } + + binding.switchAutoReplyMaster.isChecked = prefs.getBoolean("auto_reply_master", false) + binding.switchAutoReplyMaster.setOnCheckedChangeListener { _, checked -> + prefs.edit().putBoolean("auto_reply_master", checked).apply() + } + + binding.switchAutoReplySms.isChecked = prefs.getBoolean("auto_reply_sms", false) + binding.switchAutoReplySms.setOnCheckedChangeListener { _, checked -> + prefs.edit().putBoolean("auto_reply_sms", checked).apply() + } + + binding.switchAutoReplyEmail.isChecked = prefs.getBoolean("auto_reply_email", false) + binding.switchAutoReplyEmail.setOnCheckedChangeListener { _, checked -> + prefs.edit().putBoolean("auto_reply_email", checked).apply() + } + + binding.switchVoiceInput.isChecked = prefs.getBoolean("voice_input", true) + binding.switchVoiceInput.setOnCheckedChangeListener { _, checked -> + prefs.edit().putBoolean("voice_input", checked).apply() + } + + binding.btnClearMemories.setOnClickListener { + androidx.appcompat.app.AlertDialog.Builder(requireContext()) + .setTitle("Clear All Memories") + .setMessage("Are you sure? This cannot be undone.") + .setPositiveButton("Clear") { _, _ -> + kotlinx.coroutines.GlobalScope.launch { + memoryRepository.clearAllMemories() + } + Toast.makeText(context, "Memories cleared", Toast.LENGTH_SHORT).show() + } + .setNegativeButton("Cancel", null) + .show() + } + + binding.tvAppVersion.text = "AndroidAgent v1.0\nRobots: ARIA πŸ€– Β· MAXIE πŸ’¬ Β· FELIX πŸ“ Β· NEXUS 🌐 Β· MEMO 🧠" + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/res/drawable/bottom_nav_color.xml b/app/src/main/res/drawable/bottom_nav_color.xml new file mode 100644 index 0000000..aeb73c4 --- /dev/null +++ b/app/src/main/res/drawable/bottom_nav_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..0184eb5 --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_camera.xml b/app/src/main/res/drawable/ic_camera.xml new file mode 100644 index 0000000..b0a8c5c --- /dev/null +++ b/app/src/main/res/drawable/ic_camera.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_chat.xml b/app/src/main/res/drawable/ic_chat.xml new file mode 100644 index 0000000..1f5f2e9 --- /dev/null +++ b/app/src/main/res/drawable/ic_chat.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_files.xml b/app/src/main/res/drawable/ic_files.xml new file mode 100644 index 0000000..50e73e5 --- /dev/null +++ b/app/src/main/res/drawable/ic_files.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_home.xml b/app/src/main/res/drawable/ic_home.xml new file mode 100644 index 0000000..dc8d27a --- /dev/null +++ b/app/src/main/res/drawable/ic_home.xml @@ -0,0 +1,10 @@ + + + + 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..eba3f58 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + 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..1621ac1 --- /dev/null +++ b/app/src/main/res/drawable/ic_mic.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_send.xml b/app/src/main/res/drawable/ic_send.xml new file mode 100644 index 0000000..3569a46 --- /dev/null +++ b/app/src/main/res/drawable/ic_send.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..30bdb79 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..1e2a145 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/app/src/main/res/layout/fragment_chat.xml b/app/src/main/res/layout/fragment_chat.xml new file mode 100644 index 0000000..d205e17 --- /dev/null +++ b/app/src/main/res/layout/fragment_chat.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_contacts_autoreply.xml b/app/src/main/res/layout/fragment_contacts_autoreply.xml new file mode 100644 index 0000000..052fde5 --- /dev/null +++ b/app/src/main/res/layout/fragment_contacts_autoreply.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_files.xml b/app/src/main/res/layout/fragment_files.xml new file mode 100644 index 0000000..f1b4b2a --- /dev/null +++ b/app/src/main/res/layout/fragment_files.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_llm_config.xml b/app/src/main/res/layout/fragment_llm_config.xml new file mode 100644 index 0000000..6fd07a4 --- /dev/null +++ b/app/src/main/res/layout/fragment_llm_config.xml @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml new file mode 100644 index 0000000..a5c4621 --- /dev/null +++ b/app/src/main/res/layout/fragment_main.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml new file mode 100644 index 0000000..ee0dd4d --- /dev/null +++ b/app/src/main/res/layout/fragment_settings.xml @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_contact_autoreply.xml b/app/src/main/res/layout/item_contact_autoreply.xml new file mode 100644 index 0000000..499c115 --- /dev/null +++ b/app/src/main/res/layout/item_contact_autoreply.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_file.xml b/app/src/main/res/layout/item_file.xml new file mode 100644 index 0000000..21837f5 --- /dev/null +++ b/app/src/main/res/layout/item_file.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_message_agent.xml b/app/src/main/res/layout/item_message_agent.xml new file mode 100644 index 0000000..43f26cf --- /dev/null +++ b/app/src/main/res/layout/item_message_agent.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_message_user.xml b/app/src/main/res/layout/item_message_user.xml new file mode 100644 index 0000000..23119d2 --- /dev/null +++ b/app/src/main/res/layout/item_message_user.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_robot_card.xml b/app/src/main/res/layout/item_robot_card.xml new file mode 100644 index 0000000..7b44950 --- /dev/null +++ b/app/src/main/res/layout/item_robot_card.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/bottom_nav_menu.xml b/app/src/main/res/menu/bottom_nav_menu.xml new file mode 100644 index 0000000..cd94a68 --- /dev/null +++ b/app/src/main/res/menu/bottom_nav_menu.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/app/src/main/res/menu/chat_menu.xml b/app/src/main/res/menu/chat_menu.xml new file mode 100644 index 0000000..d05563d --- /dev/null +++ b/app/src/main/res/menu/chat_menu.xml @@ -0,0 +1,8 @@ + + + + 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..a7a839e --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + 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..a7a839e --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..8b89c7eca89013829def196f280c672f91741fe5 GIT binary patch literal 166 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1SD_us|Wz8Tu&Frkcv5P&ogo|Cw&Dst4!nwlrp|8+EO6aAFu^z OF@vY8pUXO@geCx!TsdL@ literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..8b89c7eca89013829def196f280c672f91741fe5 GIT binary patch literal 166 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1SD_us|Wz8Tu&Frkcv5P&ogo|Cw&Dst4!nwlrp|8+EO6aAFu^z OF@vY8pUXO@geCx!TsdL@ literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..f1496ed7ac2e80cdfc297cb4c752c796d5e27a5b GIT binary patch literal 123 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1SD@HP2SA+O6M T_>3+9-OJ$V>gTe~DWM4fKmapc literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..f6a48e0213f311a446682072c4c5988c482daddc GIT binary patch literal 221 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1SE5RJ;(=AOFUg1Ln`LHz2L~ppdfHy!{(;U zo+JkLnwyJHvgpj++AnwZdviqt10xfQhKzy(kio^yApm3?0*Y`5EGVFZl>P2SA+O6M T_>3+9-OJ$V>gTe~DWM4fKmapc literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..7b89438fbd3e53cf9492f4553565167600cc1a05 GIT binary patch literal 414 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q2}owBl)h(RU`+LNaSW-L^Y&sOCxe2>fdk=3 zFNtk!ko+_0;6YZ2oNIP1XTCGdlMqXe=t(qeJ37iBClJoC2A{Zi+r{;#1u)zhJYD@< J);T3K0RRmzl&Js! literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..7b89438fbd3e53cf9492f4553565167600cc1a05 GIT binary patch literal 414 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q2}owBl)h(RU`+LNaSW-L^Y&sOCxe2>fdk=3 zFNtk!ko+_0;6YZ2oNIP1XTCGdlMqXe=t(qeJ37iBClJoC2A{Zi+r{;#1u)zhJYD@< J);T3K0RRmzl&Js! literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..dd843e60b74aac25309d4308a0aefaae1f8b1fa6 GIT binary patch literal 547 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rf$^26i(^Q|oVS-8IT;if4mhli zxV1=ZLvwXUK>_2p8#A&GZ~eHZVZCI>5eF4EB}0MEMh_mBBn2Vni4vniLn-=aX$FjZ22WQ%mvv4FO#s$Jmp%Xh literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..dd843e60b74aac25309d4308a0aefaae1f8b1fa6 GIT binary patch literal 547 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rf$^26i(^Q|oVS-8IT;if4mhli zxV1=ZLvwXUK>_2p8#A&GZ~eHZVZCI>5eF4EB}0MEMh_mBBn2Vni4vniLn-=aX$FjZ22WQ%mvv4FO#s$Jmp%Xh literal 0 HcmV?d00001 diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml new file mode 100644 index 0000000..0f4ed73 --- /dev/null +++ b/app/src/main/res/navigation/nav_graph.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..fbea5f9 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,45 @@ + + + + #2196F3 + #1565C0 + + + #4CAF50 + + + #121212 + #1E1E1E + #2C2C2C + + + #FFFFFFFF + #B3FFFFFF + #FFFFFFFF + #CCffffff + + + #2196F3 + #64B5F6 + + + #1565C0 + #2C2C2C + + + #2196F3 + #78909C + + + #2196F3 + #1565C0 + #FFFFFF + #4CAF50 + #000000 + #CF6679 + #000000 + #121212 + #FFFFFF + #1E1E1E + #FFFFFF + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..da7ecd2 --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,14 @@ + + + 16dp + 8dp + 16dp + 24dp + 12dp + 200dp + 120dp + 12sp + 15sp + 18sp + 56dp + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..b571c5f --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,27 @@ + + + + AndroidAgent + AndroidAgent Notifications + + + Home + Chat + Files + Settings + + + Talk to ARIA… + Send + + + Configure LLM Server + Settings + + + No files yet.\nTap + to create a new file. + + + No LLM configured. Go to Settings to set up your AI backend. + Connection failed. Check your LLM server. + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..898ff73 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,23 @@ + + + + + + diff --git a/app/src/test/java/com/androidagent/ExampleUnitTest.kt b/app/src/test/java/com/androidagent/ExampleUnitTest.kt new file mode 100644 index 0000000..6db6abd --- /dev/null +++ b/app/src/test/java/com/androidagent/ExampleUnitTest.kt @@ -0,0 +1,11 @@ +package com.androidagent + +import org.junit.Test +import org.junit.Assert.* + +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..c82e90b --- /dev/null +++ b/build.gradle @@ -0,0 +1,10 @@ +buildscript { + ext { + kotlin_version = '1.9.22' + } +} +plugins { + id 'com.android.application' version '8.2.2' apply false + id 'org.jetbrains.kotlin.android' version '1.9.22' apply false + id 'org.jetbrains.kotlin.kapt' version '1.9.22' apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..f0a2e55 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a80b22c --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..a54b1c1 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "AndroidAgent" +include ':app' From cc7557217fdd6ce869e97a3964dbc24f346c4dfc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:47:47 +0000 Subject: [PATCH 3/4] fix: Replace GlobalScope with lifecycleScope in SettingsFragment; fix color hex casing - SettingsFragment: use viewLifecycleOwner.lifecycleScope instead of GlobalScope for the clear-memories coroutine, properly tying it to the fragment lifecycle - Remove unused ViewModel/viewModels imports from SettingsFragment - colors.xml: fix hex casing #CCffffff -> #CCFFFFFF Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: magicalmutation-coder <246345154+magicalmutation-coder@users.noreply.github.com> --- .../java/com/androidagent/ui/settings/SettingsFragment.kt | 7 ++----- app/src/main/res/values/colors.xml | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/androidagent/ui/settings/SettingsFragment.kt b/app/src/main/java/com/androidagent/ui/settings/SettingsFragment.kt index f53649d..1e1f259 100644 --- a/app/src/main/java/com/androidagent/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/com/androidagent/ui/settings/SettingsFragment.kt @@ -6,12 +6,9 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import com.androidagent.CompanionApplication -import com.androidagent.R import com.androidagent.data.repository.MemoryRepository import com.androidagent.databinding.FragmentSettingsBinding import kotlinx.coroutines.launch @@ -70,7 +67,7 @@ class SettingsFragment : Fragment() { .setTitle("Clear All Memories") .setMessage("Are you sure? This cannot be undone.") .setPositiveButton("Clear") { _, _ -> - kotlinx.coroutines.GlobalScope.launch { + viewLifecycleOwner.lifecycleScope.launch { memoryRepository.clearAllMemories() } Toast.makeText(context, "Memories cleared", Toast.LENGTH_SHORT).show() diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index fbea5f9..ae41baf 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -16,7 +16,7 @@ #FFFFFFFF #B3FFFFFF #FFFFFFFF - #CCffffff + #CCFFFFFF #2196F3 From 9ab07cb8b04fadc6589a45ad84d459a53003d477 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:52:12 +0000 Subject: [PATCH 4/4] fix: resolve ChatViewModel observer memory leak; improve README Agent-Logs-Url: https://github.com/magicalmutation-coder/AndroidAgent/sessions/70e6cbaf-2536-46e5-81c3-e4a687a6090e Co-authored-by: magicalmutation-coder <246345154+magicalmutation-coder@users.noreply.github.com> --- README.md | 214 +++++++++++++++++- .../com/androidagent/ui/chat/ChatViewModel.kt | 17 +- 2 files changed, 227 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 91560b3..afdfa5b 100644 --- a/README.md +++ b/README.md @@ -1 +1,213 @@ -# AndroidAgent \ No newline at end of file +# AndroidAgent πŸ€– + +A native Android personal AI companion app powered by a local LLM. Take your AI friend with you, and watch it grow into a trusted companion that can do everything from translating text to reading and replying to your mail β€” with a bit of fun thrown in! + +--- + +## ✨ Features + +### 🧠 Local LLM Integration +Connect to your own local AI β€” no cloud subscription required. AndroidAgent supports: + +| Provider | Default URL | +|----------|-------------| +| **Ollama** | `http://localhost:11434` | +| **LM Studio** | `http://localhost:1234` | +| **LLaMA.cpp Server** | `http://localhost:8080` | +| **Jan.ai** | `http://localhost:1337` | +| **GPT4All** | `http://localhost:4891` | +| **Custom OpenAI-Compatible** | *(any)* | + +All configs are pre-filled with defaults β€” just edit the model name and hit **Fetch** to pull available models from your server. + +--- + +### πŸ€– Your Robot Team + +Five named robot agents handle tasks and report back conversationally on the home screen: + +| Robot | Role | Status examples | +|-------|------|-----------------| +| **ARIA πŸ€–** | Main AI companion β€” chat, mood, personality | "Ready to chat! ✨", "Thinking… πŸ€”" | +| **MAXIE πŸ’¬** | Messaging β€” SMS, WhatsApp, email monitoring & auto-reply | "New SMS from Alice! πŸ“±", "Replying… ✍️" | +| **FELIX πŸ“** | File manager β€” workspace files, create/read/delete | "Scanning files… πŸ“‚", "File saved!" | +| **NEXUS 🌐** | Web β€” internet lookups and downloads | "Searching the web… πŸ”" | +| **MEMO 🧠** | Memory β€” learns from conversations, builds knowledge | "Updating memory… πŸ’‘" | + +--- + +### πŸ’¬ Chat Interface +- Friendly conversation bubbles (user vs ARIA messages) +- Typing indicator while the LLM responds +- Voice input button (microphone) +- Camera input button +- Per-conversation history +- Clear conversation via menu + +--- + +### πŸ“± Message Monitoring & Auto-Reply +- **SMS receiver** β€” intercepts incoming SMS, routes to MAXIE +- **Notification listener** β€” monitors WhatsApp, Gmail, Outlook, and SMS apps +- **Per-contact auto-reply** β€” configure which contacts/chats trigger automatic LLM replies +- Per-channel toggles: SMS / Email / WhatsApp + +--- + +### πŸ’Ύ Persistent Memory (SQLite / Room) +- Every conversation is stored in an SQLite database +- **Memory table** stores key facts (name, preferences, important info) with importance scores +- Memories are injected into the LLM system context so ARIA "remembers" across sessions +- Clear individual memories or all memories from Settings + +--- + +### πŸ“ File Manager +- Dedicated workspace directory on the device +- Create, read, and delete text files +- SwipeRefreshLayout to refresh +- File size and date shown in list + +--- + +### 🎨 Animated Companion Face +`CompanionFaceView` is a custom Canvas view showing ARIA's face: +- Metallic rounded-rectangle head with gradient shading +- Blinking eyes with wandering pupils +- Mood-driven mouth (smile / talking animation) +- Antenna with glowing accent dot +- Accent colour changes with mood (blue = calm, green = happy, yellow = thinking, red = alert) + +--- + +### βš™οΈ Settings +- LLM Configuration (full CRUD with Test Connection) +- Auto-Reply master toggle + per-channel toggles +- Contacts manager for auto-reply allowlist +- Memory section (view / clear) +- App version & robot roster + +--- + +## πŸ—οΈ Architecture + +``` +app/ +β”œβ”€β”€ CompanionApplication.kt # Application class; initialises DB + AgentManager +β”œβ”€β”€ MainActivity.kt # Single activity; Navigation Component + BottomNav +β”‚ +β”œβ”€β”€ agents/ +β”‚ β”œβ”€β”€ AgentRobot.kt # Data class + Robot interface + RobotMood enum +β”‚ β”œβ”€β”€ AgentManager.kt # Singleton; LiveData robot status; triggerRobot() +β”‚ └── robots/ +β”‚ β”œβ”€β”€ AriaRobot.kt # Main companion with AriaMood +β”‚ β”œβ”€β”€ MaxieRobot.kt # SMS / email / WhatsApp handler +β”‚ β”œβ”€β”€ FelixRobot.kt # File manager +β”‚ β”œβ”€β”€ NexusRobot.kt # Web / internet +β”‚ └── MemoRobot.kt # Memory & learning +β”‚ +β”œβ”€β”€ data/ +β”‚ β”œβ”€β”€ database/ +β”‚ β”‚ β”œβ”€β”€ AppDatabase.kt # Room database (v1) +β”‚ β”‚ β”œβ”€β”€ dao/ # MessageDao, MemoryDao, AgentContactDao, LLMConfigDao +β”‚ β”‚ └── entities/ # Message, Memory, AgentContact, LLMConfig +β”‚ └── repository/ +β”‚ β”œβ”€β”€ ChatRepository.kt # Wraps MessageDao + LLMManager +β”‚ └── MemoryRepository.kt # Memory CRUD + context-summary builder +β”‚ +β”œβ”€β”€ llm/ +β”‚ β”œβ”€β”€ LLMProvider.kt # Interface: chat(), listModels() +β”‚ β”œβ”€β”€ LLMManager.kt # Routes to provider; injects memory context +β”‚ └── providers/ +β”‚ β”œβ”€β”€ OllamaProvider.kt # Ollama streaming NDJSON +β”‚ └── OpenAICompatibleProvider.kt # /v1/chat/completions for all others +β”‚ +β”œβ”€β”€ services/ +β”‚ β”œβ”€β”€ AgentNotificationListenerService.kt # NotificationListenerService +β”‚ └── AgentSmsReceiver.kt # BroadcastReceiver for SMS_RECEIVED +β”‚ +└── ui/ + β”œβ”€β”€ main/ + β”‚ β”œβ”€β”€ CompanionFaceView.kt # Custom Canvas animated face + β”‚ └── MainFragment.kt # Home screen: face + robot cards + β”œβ”€β”€ chat/ + β”‚ β”œβ”€β”€ ChatFragment.kt + β”‚ β”œβ”€β”€ ChatAdapter.kt # Dual ViewHolder (user / agent bubbles) + β”‚ └── ChatViewModel.kt + β”œβ”€β”€ files/ + β”‚ β”œβ”€β”€ FilesFragment.kt + β”‚ β”œβ”€β”€ FilesAdapter.kt + β”‚ └── FilesViewModel.kt + └── settings/ + β”œβ”€β”€ SettingsFragment.kt + β”œβ”€β”€ LLMConfigFragment.kt # Full LLM config with preset URLs + Fetch Models + └── ContactsAutoReplyFragment.kt +``` + +**Tech stack:** Kotlin Β· Room (SQLite) Β· OkHttp Β· Gson Β· Jetpack Navigation Β· LiveData Β· Coroutines Β· Material Design 3 Β· ViewBinding + +--- + +## πŸš€ Getting Started + +### Prerequisites +1. Android Studio Hedgehog (2023.1.1) or newer +2. A running local LLM server (e.g. [Ollama](https://ollama.ai/)) + +### Build & Run +```bash +git clone https://github.com/magicalmutation-coder/AndroidAgent.git +cd AndroidAgent +# Open in Android Studio and let Gradle sync, then Run +``` + +Or from the command line (requires `ANDROID_HOME` set and Gradle wrapper): +```bash +./gradlew assembleDebug +``` + +### First Launch +1. Grant the requested permissions (SMS, Contacts, Notifications) +2. Go to **Settings β†’ Configure LLM Server** +3. Select your provider (e.g. Ollama), verify the URL, enter your model name, tap **Test Connection** +4. Tap **Set Active** to make it the active config +5. Return to **Home** β€” tap the chat bubble at the bottom to start talking to ARIA! + +### Notification Listener Permission +To enable WhatsApp / email monitoring, Android requires a special system permission: + +> Settings β†’ Special app access β†’ Notification access β†’ AndroidAgent β†’ Enable + +The app will prompt you with instructions on first use. + +--- + +## πŸ“‹ Permissions + +| Permission | Purpose | +|-----------|---------| +| `INTERNET` | LLM server communication + web lookups | +| `READ_SMS` / `RECEIVE_SMS` / `SEND_SMS` | SMS monitoring and auto-reply | +| `READ_CONTACTS` | Contact name resolution | +| `RECORD_AUDIO` | Voice input | +| `CAMERA` | Camera input for context | +| `BIND_NOTIFICATION_LISTENER_SERVICE` | WhatsApp / email notification monitoring | +| `FOREGROUND_SERVICE` | Background processing | + +--- + +## πŸ›£οΈ Roadmap +- [ ] Voice-to-text (SpeechRecognizer integration) +- [ ] Camera-to-text (ML Kit OCR) +- [ ] WhatsApp auto-reply via Accessibility Service +- [ ] Translation feature (NEXUS robot) +- [ ] Web search (NEXUS robot) +- [ ] Reminder / calendar integration +- [ ] Themed robot animations (walking across screen) +- [ ] Export / import conversation memories +- [ ] Multiple conversation threads + +--- + +## πŸ“„ License +MIT License β€” see [LICENSE](LICENSE) for details. diff --git a/app/src/main/java/com/androidagent/ui/chat/ChatViewModel.kt b/app/src/main/java/com/androidagent/ui/chat/ChatViewModel.kt index 02cd9d9..7d6d191 100644 --- a/app/src/main/java/com/androidagent/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/androidagent/ui/chat/ChatViewModel.kt @@ -31,11 +31,22 @@ class ChatViewModel : ViewModel() { private val _error = MutableLiveData() val error: LiveData = _error + private var messagesLiveData: androidx.lifecycle.LiveData>? = null + private val messagesObserver = androidx.lifecycle.Observer> { msgs -> + _messages.postValue(msgs) + } + fun loadConversation(conversationId: String = "default") { currentConversationId = conversationId - db.messageDao().getByConversation(conversationId).observeForever { msgs -> - _messages.postValue(msgs) - } + messagesLiveData?.removeObserver(messagesObserver) + val liveData = db.messageDao().getByConversation(conversationId) + messagesLiveData = liveData + liveData.observeForever(messagesObserver) + } + + override fun onCleared() { + super.onCleared() + messagesLiveData?.removeObserver(messagesObserver) } fun sendMessage(text: String) {