-
-
+
+
+---
+
+## Demo
+
+
+---
+
+## Table of Contents
+
+- [Demo](#demo)
+- [Overview](#overview)
+- [Features](#features)
+- [Architecture Overview](#architecture-overview)
+- [Request Processing Flow](#request-processing-flow)
+- [Technologies Used](#technologies-used)
+- [Project Structure](#project-structure)
+- [Setup](#setup)
+- [API Configuration](#api-configuration)
+- [Permissions](#permissions)
+- [Supported Features Table](#supported-features-table)
+- [Privacy & Security](#privacy--security)
+- [Known Issues](#known-issues)
+- [Roadmap](#roadmap)
+- [Contributing](#contributing)
+- [License](#license)
+
+---
+
## Overview
-Assistant is an Android application designed to act as a personal voice assistant. It can understand spoken commands, process them, and perform various actions like making calls, playing music, setting alarms, providing weather information, and more. The app integrates with several external services and uses on-device machine learning for intent recognition.
+This app is a private voice assistant for Android. It runs offline as much as possible, encrypts your conversations, and reads your screen when asked. You can connect it to any OpenAI-compatible LLM endpoint.
+
+Unlike standard assistants, it does not send your voice data to external servers. It replaces Android's default assistant, so you can launch it with a long press on the home button.
+
+---
## Features
-* **Voice Commands:** Interact with the assistant using natural language.
-* **Text-to-Speech & Speech-to-Text:** Converts spoken language to text and vice-versa for interaction.
-* **Intent Classification:** Understands user intent and categorizes requests (e.g., Call, Music, Weather, Navigation, Alarm, Reminder).
-* **Phone Calls:** Can initiate phone calls to contacts.
-* **Music Playback:** Plays music via YouTube.
-* **Alarms & Reminders:** Set alarms and reminders.
-* **Navigation:** Provides navigation directions using Google Maps.
-* **Weather Information:** Fetches and displays weather forecasts for specified locations or current location.
-* **Large Language Model (LLM) Integration:** Uses Groq API for advanced query understanding and responses (can be changed as per requirement).
-* **Translation:** Supports language translation for input queries and responses.
-* **Conversation History:** Stores and displays past interactions.
-* **Customizable UI:** Offers options for UI presentation.
+### AI & LLM
+- **Custom LLM Providers:** Works with Groq, OpenAI, Anthropic, Gemini, Ollama, LM Studio, etc.
+- **Vision Auto-Detection:** Automatically tests if your model supports images and enables screen vision and live camera features if it does.
+- **Fast Speech Output:** Starts speaking sentences before the model finishes generating the full response.
-## Default Assistant App
+### Voice Pipeline
+- **Voice Activation (VAD):** Detects when you start and stop talking locally on your device.
+- **Interruption Support:** You can speak over the assistant to interrupt it.
+- **Hands-Free Mode:** Automatically listens for your next response after speaking.
+- **Audio Routing:** Supports Bluetooth headsets and speakerphone.
-This application can be set as the default assistant app on your Android device. This allows you to launch the app by long-pressing the home button or using other system-level gestures for invoking the assistant.
+### Screen Vision
+- **Screen Capture:** Captures screen frames locally in the background.
+- **Smart Frame Syncing:** Matches the captured frame to the exact moment you started speaking.
+- **Camera Sharing:** Share your live camera feed to discuss your real-world surroundings with the assistant.
+- **Privacy Controls:** Easily pause or stop screen capture from a system notification.
-## Process Flow
+### Privacy & Encryption
+- **Secure Storage:** Encrypts conversations and attachments using AES-256-GCM.
+- **Safe Deletion:** Securely deletes files from the device when you delete a message or chat.
-```
-Start (User Input on ChatScreen: Voice/Text)
- │
- V
-MainViewModel (Input Processing, Permissions, Translation, Cleaning)
- │
- ├─+------------------------------------─> Lock Active? (e.g., Call/Song specific flow)
- │ │ (Yes)
- V (No / General Flow) │
-Text Classification (Determine User Intent) Process Input based on Active Lock
- │ │
- V │
-Action Execution <──────────────────────────+
- (Based on Intent or Lock:
- - Make Call
- - Play Song
- - Navigate
- - Get Weather
- - Set Alarm/Reminder
- - Query General AI (Groq)
- - etc.)
- │
- V
-Process Action Result (e.g., Format AI response, Translate back)
- │
- V
-Update UI (ChatScreen displays conversation)
- │
- V
-Speak Response (TextToSpeech, if enabled)
- │
- V
-End
+### Offline Tools
+- **Offline Actions:** Processes commands (like calling or navigation) offline using an on-device model.
+- **Offline Speech & Voice:** Handles speech-to-text and text-to-speech without internet.
+- **Offline Translation:** Translates input to English locally using Google ML Kit.
+
+### Android Integration
+- **Default Assistant:** Launch by long-pressing the home button or swiping.
+- **Lock Screen Support:** Answer voice queries without unlocking your phone.
+- **System Actions:** Set alarms, dial contacts (with fuzzy name matching), search YouTube music, and check the weather.
+
+---
+
+
+## Architecture Overview
+
+The app is built around offline first, modular components. Each layer can be swapped independently.
+
+```mermaid
+graph TD
+ A[User — Voice / Text / File] --> B[MainViewModel]
+
+ subgraph Input Processing
+ B --> C[TranslatorManager\nML Kit Offline]
+ C --> D[TextClassifierHelper\nMediaPipe TFLite]
+ end
+
+ subgraph Screen Context
+ SC[ScreenCaptureService\n~6 FPS] --> VB[VisualBufferManager\nCircular Frame Buffer]
+ VB --> B
+ end
+
+ subgraph Intent Routing
+ D -->|CALL / SONGS / ALARM\nNAVIGATION / WEATHER / REMINDER| E[Native Action Executor]
+ D -->|OTHER / SETTINGS| F[FlexibleLlmAdapter\nStreaming REST]
+ end
+
+ subgraph Lock State Machine
+ E -->|Missing detail| LS[LockState\nMulti-Turn Collector]
+ LS --> B
+ end
+
+ subgraph TTS Layer
+ F -->|Streaming tokens| SB[Sentence Boundary Detector]
+ E --> SB
+ SB --> TTS[TtsEngineSelector]
+ TTS --> T1[EdgeTTS — Free\nNeural WebSocket]
+ TTS --> T2[Google Cloud TTS]
+ TTS --> T3[Offline VITS\nSherpa-ONNX]
+ TTS --> T4[System Native TTS]
+ end
+
+ subgraph Storage
+ B --> DB[(Room DB\nAES-256-GCM\nEncrypted)]
+ end
+
+ subgraph Voice Pipeline
+ MIC[Microphone] --> AH[AudioHygieneProcessor\nAEC · NS · AGC]
+ AH --> VAD[VadIntelligenceProcessor\nSilero VAD]
+ VAD --> VSM[VoiceStateMachine\nIDLE · LISTENING\nPROCESSING · BOT_SPEAKING]
+ VSM --> B
+ end
```
-1. **User Input:** The user provides input via voice or text in the `ChatScreen`.
-2. **Input Handling (`MainViewModel`):**
- * The input is sent to the `MainViewModel`.
- * Permissions (like microphone for voice input) are checked.
- * If the input is not in English, it's translated to English. * The text is cleaned and punctuated.
-3. **Lock State Management (`MainViewModel`):**
- * The current lock state is checked.
- * If a specific lock is active (e.g., "call lock," "song lock"), the input is processed according to that lock.
- * If no lock is active or the input is negative (e.g., "cancel"), the input proceeds to general classification.
-4. **Text Classification (`TextClassifierHelper` via `MainViewModel`):**
- * The input text is classified to determine the user's intent (e.g., make a call, play a song, navigate, get weather, set alarm/reminder, or general query).
-5. **Action Execution (`CallCommand` & `MainViewModel`):**
- * Based on the classified category, the `MainViewModel` (often via a `CallCommand` utility) triggers the appropriate action:
- * **Call:** Initiates a phone call.
- * **Songs:** Plays a song.
- * **Navigation:** Starts navigation.
- * **Weather:** Fetches weather information.
- * **Alarm:** Sets an alarm.
- * **Reminder:** Sets a reminder.
- * **Other/Settings/General AI:** For other queries, or if the intent is unclear or relates to settings, the input is sent to a general AI (Groq API).
-6. **AI Response Processing (if applicable via `MainViewModel`):**
- * The response from the Groq API is received.
- * The response is processed and formatted.
- * If the original input was not in English, the AI's English response is translated back to the original language.
-7. **Updating UI (`ChatScreen`):**
- * The result of the action (e.g., confirmation of a call, song playing, navigation started, weather info, alarm/reminder set, or AI response) is added to the chat list in the `MainViewModel`.
- * The `ChatScreen` observes changes in the chat list and updates to display the conversation.
-8. **Speech Output (if enabled via `MainViewModel`):**
- * The response or confirmation is converted to speech using TextToSpeech.
- * The synthesized speech is played back to the user.
+**Design details:**
+- **Flexible LLM Adapter:** Uses templates to support any OpenAI-compatible API endpoint.
+- **Intent Verification:** Checks user queries against keyword lists to prevent incorrect system actions.
+- **Local Encryption:** All conversation history and files are encrypted before saving to disk.
+
+---
+
+## Request Processing Flow
+
+
+Voice / Text Input
+
+When you speak or type, the app processes the audio locally:
+1. It records and cleans up the audio.
+2. It detects when you start and stop speaking.
+3. If you speak while the assistant is answering, it stops talking and listens to you.
+
+
+
+
+Audio Processing & STT
+
+The app converts your speech to text using either Android's built-in system, an offline voice model, or a cloud API.
+
+
+
+
+Translation & Classification
+
+1. If you speak a non-English language, the app translates it to English offline using Google ML Kit.
+2. It classifies the text to determine your intent (e.g., call, alarm, navigation, or general question).
+3. It double-checks keywords to make sure it doesn't run a system command by mistake.
+
+
+
+
+Native Actions & Lock States
+
+When the app identifies a command, it runs the matching phone action:
+- **Calls:** Searches contacts and dials the number.
+- **Music:** Searches and plays songs on YouTube.
+- **Alarms/Reminders:** Sets system alarms using natural time phrasing.
+- **Navigation:** Launches Google Maps navigation.
+- **Weather:** Gets your location and displays the forecast.
+
+If a command is missing information (like a name for a call), the app will ask you for it directly on the next turn.
+
+
+
+
+LLM & Vision
+
+General questions go to your selected LLM. If you use screen vision or live camera sharing, the app attaches the latest captured frame from your screen or camera when you start speaking, provided your selected LLM supports images.
+
+
+
+
+Streaming TTS
+
+As the LLM generates a response, the app parses it sentence-by-sentence. Once a sentence is ready, the voice engine starts speaking it immediately so you don't have to wait for the full response to load.
+
+
+
+
+Conversation Storage
+
+All messages are encrypted and saved locally. Attached files are also encrypted before saving. Deleting a message or chat deletes all associated files permanently.
+
+
+
+---
## Technologies Used
-* **Kotlin:** Primary programming language.
-* **Jetpack Compose:** For building the user interface.
-* **Android Jetpack:**
- * ViewModel: For managing UI-related data in a lifecycle-conscious way.
-* **Speech Recognition & Synthesis:** Android's built-in `SpeechRecognizer` and `TextToSpeech` engines.
-* **Text Classification:** MediaPipe, utilizing a custom-trained TensorFlow Lite (TFLite) model for on-device text classification.
-* **Offline Translation:** Google ML Kit for on-device language identification and translation, enabling completely offline functionality.
-* **Networking:** OkHttp for making API calls.
-* **APIs:**
- * Groq API (for LLM, can be changed as per requirement)
- * YouTube Data API v3 (for music search)
-* **Coroutines:** For asynchronous programming.
+| Category | Technology |
+|---|---|
+| Language | Kotlin |
+| UI | Jetpack Compose, Material You |
+| Architecture | MVVM, ViewModel, Coroutines, Flow |
+| On-Device ML | MediaPipe Text Classifier, TensorFlow Lite, Sherpa-ONNX (Silero VAD, Parakeet STT, VITS TTS) |
+| Translation | Google ML Kit On-Device Translation |
+| Database | Room (encrypted via Android KeyStore + AES-256-GCM) |
+| Networking | OkHttp, Retrofit |
+| TTS | Edge TTS (WebSocket), Google Cloud TTS, Sherpa-ONNX VITS, Android TextToSpeech |
+| APIs | Groq (default LLM), YouTube Data API v3, Open-Meteo (weather) |
+| Location | GMS Fused Location Provider |
+
+---
+
+## Project Structure
+
+```
+app/src/main/java/com/app/assistant/
+│
+├── AssistActivity.kt # Entry point for system assistant integration
+├── MainActivity.kt # Main UI activity and lifecycle management
+│
+├── api/ # HTTP clients and API services
+│
+├── camera/
+│ ├── ScreenCaptureService.kt # Captures screenshots periodically
+│ └── VisualBufferManager.kt # Stores captured frames with timestamps
+│
+├── classifier/
+│ └── TextClassifierHelper.kt # Local TFLite intent classifier
+│
+├── db/
+│ ├── AppDatabase.kt # Room database setup
+│ ├── EncryptionUtil.kt # Encryption helpers (AES-GCM + KeyStore)
+│ ├── DynamicConversationRepo # Handles encrypted database operations
+│ └── *Entity.kt # Database schemas
+│
+├── llm/
+│ ├── FlexibleLlmAdapter.kt # API adapter for various LLMs
+│ ├── ModelCapabilityProber.kt # Detects model vision capability
+│ └── LlmMessage.kt # LLM data schemas
+│
+├── repository/
+│ ├── ContactsRepository.kt # Matches contact names offline
+│ ├── SettingsRepository.kt # App settings manager
+│ └── WeatherRepository.kt # Location and weather logic
+│
+├── speech/
+│ ├── AudioHygieneProcessor.kt # Audio recording and processing
+│ ├── VadIntelligenceProcessor.kt # On-device speech detection (VAD)
+│ ├── VoiceStateMachine.kt # Manages voice states (listening, speaking)
+│ └── SpeechRecognizerManager.kt # Manages speech-to-text engines
+│
+├── translation/
+│ └── TranslatorManager.kt # Manages offline translation
+│
+├── tts/
+│ ├── TtsEngineSelector.kt # Switches between TTS engines
+│ ├── EdgeTtsApiManager.kt # Microsoft Edge TTS integration
+│ ├── GoogleTtsApiManager.kt # Google Cloud TTS integration
+│ ├── OfflineTtsManager.kt # On-device TTS integration
+│ └── NativeTtsManager.kt # Default system TTS fallback
+│
+└── ui/
+ ├── screen/
+ │ ├── ChatScreen.kt # Main chat screen logic
+ │ ├── ChatLayout.kt # Layout definitions for chat
+ │ ├── ConversationItem.kt # Chat item UI elements
+ │ ├── HandsFreeBar.kt # Voice UI overlay
+ │ ├── SettingsScreen.kt # Settings layout and configuration
+ │ └── UserInputField.kt # Text input and file picker UI
+ └── theme/ # Theme, styling, and typography
+```
+
+---
## Setup
-1. **Clone the repository:**
-2. **API Keys:**
- This project requires API keys for YouTube and Groq services.
+**Prerequisites**
+- Android Studio
+- Device or emulator (Android 8.0+ / API 26+)
+- Groq API key (or another OpenAI-compatible endpoint key)
+- YouTube Data API v3 key
- **Option A: Using the `local.properties` file (Recommended for development)**
- You need to create a `local.properties` file in the root directory of your project (if it doesn't already exist) and add your keys there:
- ```properties
- YOUTUBE_API_KEY=YOUR_YOUTUBE_API_KEY
- GROQ_API_KEY=YOUR_GROQ_API_KEY
- ```
- These keys are then accessed via `BuildConfig` fields in the app.
+**Steps**
+1. Clone this repository:
+ ```bash
+ git clone https://github.com/your-username/ai-assistant-android.git
+ cd ai-assistant-android
+ ```
+2. Open the project in Android Studio and let files sync.
+3. Configure your API keys (see details in the API Configuration section).
+4. Run the app on your device.
+5. (Optional) Set the app as your default assistant under **Settings → Apps → Default Apps → Digital assistant app**.
- **Option B: Entering Keys via the App UI (Recommended for quick testing/user setup)**
- Alternatively, after installing the app, you can enter the API keys directly within the application. Navigate to the **Menu** and then to the **Settings** page within the UI, and input your `YOUTUBE_API_KEY` and `GROQ_API_KEY` there.
-3. **Ensure `local.properties` is in `.gitignore`:** The `.gitignore` file should include `local.properties` to prevent your API keys from being checked into version control.
+---
-## How to Build and Run
+## API Configuration
-1. Open the project in Android Studio.
-2. Let Android Studio sync the project and download necessary dependencies.
-3. Ensure you have an Android device or emulator connected/running.
-4. Click the "Run" button (green play icon) in Android Studio.
+Choose one of these methods to configure your API keys:
-## Notes
+**Option 1: Using `local.properties`**
+Add the keys to your `local.properties` file:
+```properties
+YOUTUBE_API_KEY=YOUR_YOUTUBE_API_KEY
+GROQ_API_KEY=YOUR_GROQ_API_KEY
+EDGE_TTS_SUBSCRIPTION_KEY=YOUR_EDGE_TTS_SUBSCRIPTION_KEY
+```
+Gradle will load these during the build. (Make sure this file is not committed to git).
-* The app requests various permissions at runtime (e.g., Record Audio, Read Contacts, Call Phone, Location) as needed for its features.
-* Bluetooth SCO is managed for better compatibility with Bluetooth headsets during speech recognition.
+**Option 2: In-App Settings**
+Open the app, go to **Settings**, and paste your keys directly into the input fields. This does not require rebuilding the app.
+
+**Customizing the LLM Endpoint**
+In the settings page, you can change the default URL to any OpenAI-compatible server (like Ollama, LM Studio, Anthropic, or Gemini). The app automatically checks if the custom endpoint supports images.
+
+---
+
+## Permissions
+
+| Permission | Required For |
+|---|---|
+| `RECORD_AUDIO` | Listening to voice input |
+| `CAMERA` | Accessing the camera for live camera sharing |
+| `READ_CONTACTS` | Searching contact names to make calls |
+| `CALL_PHONE` | Making phone calls |
+| `ACCESS_FINE_LOCATION` | Checking weather and generating navigation routes |
+| `MEDIA_PROJECTION` | Recording the screen for screen vision |
+| `BLUETOOTH` / `BLUETOOTH_CONNECT` | Supporting Bluetooth headsets |
+| `POST_NOTIFICATIONS` | Showing the screen capture status bar notification |
+
+---
+
+## Supported Features Table
+
+| Feature | Offline | Cloud | Native Android | AI / LLM |
+|---|:---:|:---:|:---:|:---:|
+| Voice activity detection (VAD) | ✅ | | | |
+| Speech-to-text (Parakeet) | ✅ | | | |
+| Speech-to-text (Cloud) | | ✅ | | |
+| Intent classification | ✅ | | | |
+| Language translation | ✅ | | | |
+| Offline TTS (VITS) | ✅ | | | |
+| Neural TTS (Edge) | | ✅ | | |
+| Encrypted storage | ✅ | | | |
+| Phone calls | | | ✅ | |
+| Alarms & reminders | | | ✅ | |
+| Google Maps navigation | | | ✅ | |
+| YouTube music playback | | ✅ | | |
+| Weather forecasts | | ✅ | | |
+| Contact matching | ✅ | | ✅ | |
+| Screen vision | | | ✅ | ✅ |
+| Live camera sharing | | | ✅ | ✅ |
+| General chat | | | | ✅ |
+| Custom LLM endpoints | | ✅ | | ✅ |
+| Image & file attachments | | | | ✅ |
+
+---
+
+## Privacy & Security
+
+Your data stays on your device unless it is sent to your selected LLM provider.
+
+**Database Encryption**
+The app generates an AES-256 encryption key inside Android's secure hardware Keystore. All messages and chat titles are encrypted before saving. Each record uses a unique initialization vector.
+
+**File Encryption**
+All saved images, documents, and screenshots are encrypted before they are written to disk.
+
+**Secure Deletion**
+When you delete a message or chat, the app deletes the database records and removes the associated files from disk.
+
+**Voice Processing**
+Your raw voice audio is never stored or sent anywhere. Voice detection is handled entirely in memory on the device, and only the transcribed text is processed.
+
+---
+
+## Known Issues
+
+- **Barge-In Interruption:** Sometimes the barge-in/interruption feature does not trigger correctly when speaking over the assistant. Work is underway to refine this.
+- **Translation Disabled:** The translation feature is currently disabled due to conflicts with streaming text output. A new approach is being developed.
+- **Settings Classification:** Settings-related commands (unlike Call, Play Song, etc., which work) do not have a separate action yet. Instead, they are classified under `OTHER` and sent directly to the LLM. A new approach is being worked on to support settings actions natively.
+
+---
+
+## Roadmap
+
+- [ ] Android XR support for smart glasses
+- [ ] Wear OS support for wrist control
+- [ ] Category for sending text messages through actions
+- [ ] Automatic summary of older conversations
+- [ ] Screen capture using Accessibility Services to avoid permission prompts
+
+---
+
+## Contributing
+
+Contributions are welcome. Please open an issue to discuss new features before making changes, or open a pull request directly for bug fixes.
+
+1. Fork the repository and create a branch.
+2. Follow the project's coding style (standard Kotlin, MVVM architecture).
+3. Submit a pull request explaining your changes.
+
+Make sure your changes do not store unencrypted user data or bypass security features.
+
+---
## License
-This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
-Copyright (c) 2025 Sourav Anand
+This project is licensed under the [MIT License](LICENSE).
+
+Copyright © 2026 Sourav Anand.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 208fdb2..f8ef375 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -4,7 +4,8 @@ import java.io.FileInputStream
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
- kotlin("plugin.serialization") version "2.0.0"
+ alias(libs.plugins.jetbrains.kotlin.serialization)
+ kotlin("kapt")
}
android {
@@ -15,8 +16,9 @@ android {
applicationId = "com.app.assistant"
minSdk = 24
targetSdk = 35
- versionCode = 1
- versionName = "1.0"
+ val runNumber = System.getenv("GITHUB_RUN_NUMBER")?.toIntOrNull() ?: 0
+ versionCode = 2 + runNumber
+ versionName = if (runNumber > 0) "2.1.$runNumber" else "2.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -39,6 +41,31 @@ android {
buildConfigField("String", "GROQ_API_KEY",
"\"${localProperties.getProperty("GROQ_API_KEY") ?: ""}\"" // Add quotes
)
+
+ buildConfigField("String", "EDGE_TTS_SUBSCRIPTION_KEY",
+ "\"${localProperties.getProperty("EDGE_TTS_SUBSCRIPTION_KEY") ?: ""}\"" // Add quotes
+ )
+ }
+
+ val keystoreFilePath = System.getenv("KEYSTORE_FILE_PATH")
+ val keystorePassword = System.getenv("KEYSTORE_PASSWORD")
+ val keystoreKeyAlias = System.getenv("KEYSTORE_KEY_ALIAS")
+ val keystoreKeyPassword = System.getenv("KEYSTORE_KEY_PASSWORD")
+
+ val hasSigningConfig = !keystoreFilePath.isNullOrEmpty() &&
+ !keystorePassword.isNullOrEmpty() &&
+ !keystoreKeyAlias.isNullOrEmpty() &&
+ !keystoreKeyPassword.isNullOrEmpty()
+
+ signingConfigs {
+ if (hasSigningConfig) {
+ create("release") {
+ storeFile = file(keystoreFilePath)
+ storePassword = keystorePassword
+ keyAlias = keystoreKeyAlias
+ keyPassword = keystoreKeyPassword
+ }
+ }
}
buildTypes {
@@ -48,8 +75,9 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
- // You might want to also define buildConfigFields for release
- // if you have different keys for release builds, or if local.properties is not available in CI
+ if (hasSigningConfig) {
+ signingConfig = signingConfigs.getByName("release")
+ }
}
debug {
// BuildConfigFields from defaultConfig are inherited.
@@ -69,7 +97,7 @@ android {
buildConfig = true // Ensure buildConfig is enabled
}
composeOptions {
- kotlinCompilerExtensionVersion = "1.5.1"
+ kotlinCompilerExtensionVersion = libs.versions.androidxComposeCompiler.get()
}
packaging {
resources {
@@ -88,29 +116,56 @@ dependencies {
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
- implementation("androidx.compose.material3:material3:1.2.1")
- implementation("androidx.compose.material3:material3-window-size-class:1.2.1")
- implementation("androidx.compose.material3:material3-adaptive-navigation-suite:1.3.0-beta02")
- implementation("com.squareup.okhttp3:okhttp:4.12.0")
- implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
- implementation ("com.google.mlkit:translate:17.0.3")
- implementation ("org.commonmark:commonmark:0.24.0")
+ implementation(libs.androidx.material3.window.size)
+ implementation(libs.androidx.material3.adaptive.navigation.suite)
+ implementation(libs.okhttp)
+ constraints {
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") {
+ because("Kotlin 1.9.0 compatibility")
+ }
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.3") {
+ because("Kotlin 1.9.0 compatibility")
+ }
+ }
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.mlkit.translate)
+ implementation(libs.mlkit.barcode.scanning)
+ implementation(libs.commonmark)
//implementation ("com.github.jeziellago:compose-markdown:0.5.4")
- implementation("io.coil-kt.coil3:coil-compose:3.0.0-rc02")
- implementation("io.coil-kt.coil3:coil-network-okhttp:3.0.0-rc02")
- implementation ("org.jetbrains.kotlin:kotlin-reflect")
+ implementation(libs.coil.compose)
+ implementation(libs.coil.network.okhttp)
+ implementation(libs.androidx.room.runtime)
+ implementation(libs.androidx.room.ktx)
+ kapt(libs.androidx.room.compiler)
implementation(libs.play.services.location)
implementation(libs.androidx.ui.test.android)
- implementation("androidx.security:security-crypto:1.1.0")
+ implementation(libs.androidx.security.crypto)
implementation(libs.androidx.runtime.saveable)
testImplementation(libs.junit)
-// androidTestImplementation(libs.androidx.junit)
-// androidTestImplementation(libs.androidx.espresso.core)
-// androidTestImplementation(platform(libs.androidx.compose.bom))
-// androidTestImplementation(libs.androidx.ui.test.junit4)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
//MediaPipe library
- implementation ("com.google.mediapipe:tasks-text:0.10.29")
-}
\ No newline at end of file
+ implementation(libs.mediapipe.tasks.text)
+
+ // Sherpa ONNX Offline STT
+ implementation(files("libs/sherpa-onnx-1.13.2.aar"))
+
+ // CameraX dependencies
+ implementation(libs.androidx.camera.core)
+ implementation(libs.androidx.camera.camera2)
+ implementation(libs.androidx.camera.lifecycle)
+ implementation(libs.androidx.camera.view)
+}
+
+configurations.all {
+ resolutionStrategy {
+ force("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
+ force("org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.3")
+ force("org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.6.3")
+ }
+}
diff --git a/app/libs/sherpa-onnx-1.13.2.aar b/app/libs/sherpa-onnx-1.13.2.aar
new file mode 100644
index 0000000..9686f8b
Binary files /dev/null and b/app/libs/sherpa-onnx-1.13.2.aar differ
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0e180da..45936ff 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,10 @@
+
+
@@ -10,8 +14,12 @@
-
-
+
+
+
+
+
+
@@ -55,14 +63,28 @@
-
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/app/assistant/AssistActivity.kt b/app/src/main/java/com/app/assistant/AssistActivity.kt
index 9a4d31f..34cbd59 100644
--- a/app/src/main/java/com/app/assistant/AssistActivity.kt
+++ b/app/src/main/java/com/app/assistant/AssistActivity.kt
@@ -30,7 +30,7 @@ class AssistActivity : ComponentActivity() {
finish()
}
- private fun moveToMainActivity(){
+ private fun moveToMainActivity() {
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION
intent.putExtra("speak", true)
@@ -47,8 +47,9 @@ class AssistActivity : ComponentActivity() {
} else {
this.window.addFlags(
WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD or
- WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
- WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON)
+ WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
+ WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON,
+ )
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/app/assistant/Classifier/TextClassifierHelper.kt b/app/src/main/java/com/app/assistant/Classifier/TextClassifierHelper.kt
deleted file mode 100644
index 4abe4c1..0000000
--- a/app/src/main/java/com/app/assistant/Classifier/TextClassifierHelper.kt
+++ /dev/null
@@ -1,71 +0,0 @@
-package com.app.assistant.Classifier
-
-import android.content.Context
-import android.os.SystemClock
-import android.util.Log
-import com.google.mediapipe.tasks.core.BaseOptions
-import com.google.mediapipe.tasks.text.textclassifier.TextClassifier
-import com.google.mediapipe.tasks.text.textclassifier.TextClassifierResult
-import java.util.concurrent.ScheduledThreadPoolExecutor
-
-class TextClassifierHelper(
- var currentModel: String = MOBILEBERT,
- val context: Context,
- val listener: TextResultsListener
-) {
- private lateinit var textClassifier: TextClassifier
- private lateinit var executor: ScheduledThreadPoolExecutor
-
- init {
- initClassifier()
- }
-
- fun initClassifier() {
- val baseOptionsBuilder = BaseOptions.builder()
- .setModelAssetPath(currentModel)
-
- try {
- val baseOptions = baseOptionsBuilder.build()
- val optionsBuilder = TextClassifier.TextClassifierOptions.builder()
- .setBaseOptions(baseOptions)
- val options = optionsBuilder.build()
- textClassifier = TextClassifier.createFromOptions(context, options)
- } catch (e: IllegalStateException) {
- listener.onError(
- "Text classifier failed to initialize. See error logs for " +
- "details"
- )
- Log.e(
- TAG, "Text classifier failed to load the task with error: " + e
- .message
- )
- }
- }
-
- // Run text classification using MediaPipe Text Classifier API
- fun classify(text: String, itemId: Long, loadingItemId: Long, speak: Boolean) {
- executor = ScheduledThreadPoolExecutor(1)
-
- executor.execute {
- // inferenceTime is the amount of time, in milliseconds, that it takes to
- // classify the input text.
- var inferenceTime = SystemClock.uptimeMillis()
-
- val results = textClassifier.classify(text)
-
- inferenceTime = SystemClock.uptimeMillis() - inferenceTime
-
- listener.onResult(results, inferenceTime, text, itemId, loadingItemId, speak)
- }
- }
-
- interface TextResultsListener {
- fun onError(error: String)
- fun onResult(results: TextClassifierResult, inferenceTime: Long, inputText: String, itemId: Long, loadingItemId: Long, speak: Boolean)
- }
-
- companion object {
- const val TAG = "TextClassifierHelper"
- const val MOBILEBERT = "model.tflite"
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/app/assistant/MainActivity.kt b/app/src/main/java/com/app/assistant/MainActivity.kt
index 52e5037..cb0d8fd 100644
--- a/app/src/main/java/com/app/assistant/MainActivity.kt
+++ b/app/src/main/java/com/app/assistant/MainActivity.kt
@@ -1,30 +1,506 @@
package com.app.assistant
+import android.Manifest
import android.app.KeyguardManager
+import android.content.ComponentCallbacks2
import android.content.Context
+import android.content.Intent
+import android.content.IntentSender
+import android.content.pm.PackageManager
+import android.content.res.Configuration
+import android.location.LocationManager
+import android.media.AudioDeviceInfo
+import android.media.AudioManager
import android.os.Build
import android.os.Bundle
+import android.os.Looper
+import android.os.Vibrator
+import android.os.VibrationEffect
+import android.speech.RecognitionListener
+import android.speech.RecognizerIntent
+import android.speech.SpeechRecognizer
+import android.speech.tts.TextToSpeech
+import android.speech.tts.UtteranceProgressListener
import android.util.Log
import android.view.WindowManager
+import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
import com.app.assistant.ui.screen.SetupUI
import com.app.assistant.viewmodel.MainViewModel
import com.app.assistant.viewmodel.MainViewModelFactory
+import com.app.assistant.viewmodel.SettingsViewModel
+import com.app.assistant.viewmodel.SettingsViewModelFactory
+import com.app.assistant.viewmodel.UIEvent
+import com.app.assistant.viewmodel.onLocationFailed
+import com.app.assistant.viewmodel.onLocationReceived
+import com.google.android.gms.common.api.ResolvableApiException
+import com.google.android.gms.location.LocationAvailability
+import com.google.android.gms.location.LocationCallback
+import com.google.android.gms.location.LocationRequest
+import com.google.android.gms.location.LocationResult
+import com.google.android.gms.location.LocationServices
+import com.google.android.gms.location.LocationSettingsRequest
+import com.google.android.gms.location.LocationSettingsResponse
+import com.google.android.gms.location.Priority
+import com.google.android.gms.location.SettingsClient
+import com.google.android.gms.tasks.Task
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import java.util.Locale
class MainActivity : ComponentActivity() {
- private val viewModel: MainViewModel by viewModels{
+ private val viewModel: MainViewModel by viewModels {
MainViewModelFactory(application, intent.getBooleanExtra("speak", false))
}
+ private val settingsViewModel: SettingsViewModel by viewModels {
+ SettingsViewModelFactory(
+ com.app.assistant.repository.SettingsRepository(application),
+ com.app.assistant.viewmodel.MainViewModelFactory.okHttpClient
+ )
+ }
+
+ private lateinit var textToSpeechManager: com.app.assistant.tts.TtsManager
+ private lateinit var speechRecognizerManager: com.app.assistant.speech.SpeechRecognizerManager
+
+ private var isScreenServiceRunning = false
+ private var isActivityStarted = false
+ private var activeCollectionJob: kotlinx.coroutines.Job? = null
+
+ private val screenCaptureLauncher = registerForActivityResult(
+ androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ if (result.resultCode == RESULT_OK && result.data != null) {
+ val serviceIntent = Intent(this, com.app.assistant.camera.ScreenCaptureService::class.java).apply {
+ action = com.app.assistant.camera.ScreenCaptureService.ACTION_START
+ putExtra(com.app.assistant.camera.ScreenCaptureService.EXTRA_RESULT_CODE, result.resultCode)
+ putExtra(com.app.assistant.camera.ScreenCaptureService.EXTRA_RESULT_DATA, result.data)
+ }
+ com.app.assistant.camera.ScreenCaptureServiceHelper.isMicMuted = viewModel.isMicMuted.value
+ com.app.assistant.camera.ScreenCaptureServiceHelper.isHandsFreeActive = viewModel.isHandsFreeModeActive.value
+ ContextCompat.startForegroundService(this, serviceIntent)
+ isScreenServiceRunning = true
+ viewModel.setScreenModeActive(true)
+ } else {
+ Toast.makeText(this, "Screen capture permission denied", Toast.LENGTH_SHORT).show()
+ isScreenServiceRunning = false
+ viewModel.setScreenModeActive(false)
+ }
+ }
+
+ private fun startScreenCaptureFlow() {
+ val mediaProjectionManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as android.media.projection.MediaProjectionManager
+ try {
+ val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ val config = android.media.projection.MediaProjectionConfig.createConfigForDefaultDisplay()
+ mediaProjectionManager.createScreenCaptureIntent(config)
+ } else {
+ mediaProjectionManager.createScreenCaptureIntent()
+ }
+ screenCaptureLauncher.launch(intent)
+ } catch (e: Exception) {
+ Log.e("MainActivity", "Failed to launch screen capture intent", e)
+ Toast.makeText(this, "Screen capture not supported or failed to launch", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private fun stopScreenCaptureService() {
+ val serviceIntent = Intent(this, com.app.assistant.camera.ScreenCaptureService::class.java).apply {
+ action = com.app.assistant.camera.ScreenCaptureService.ACTION_STOP
+ }
+ startService(serviceIntent)
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
allowOnLockScreen()
enableEdgeToEdge()
+
+ textToSpeechManager = com.app.assistant.tts.TtsEngineSelector(
+ context = this,
+ settingsRepository = com.app.assistant.repository.SettingsRepository(application)
+ ) { isSpeaking ->
+ viewModel.setSpeaking(isSpeaking)
+ if (::speechRecognizerManager.isInitialized) {
+ speechRecognizerManager.isTtsSpeaking = isSpeaking
+ }
+ }
+ speechRecognizerManager = com.app.assistant.speech.SpeechRecognizerManager(this, lifecycleScope)
+ speechRecognizerManager.preLoadModelAsync()
+ speechRecognizerManager.shouldIgnoreUiHidden = {
+ viewModel.isScreenModeActive.value
+ }
+
+ com.app.assistant.camera.ScreenCaptureServiceHelper.onServiceStopped = {
+ runOnUiThread {
+ isScreenServiceRunning = false
+ viewModel.setScreenModeActive(false)
+ }
+ }
+ com.app.assistant.camera.ScreenCaptureServiceHelper.onToggleMuteRequested = {
+ runOnUiThread {
+ viewModel.toggleMicMute()
+ }
+ }
+
+
+ lifecycleScope.launch {
+ viewModel.isScreenModeActive.collect { active ->
+ if (active) {
+ if (!isScreenServiceRunning) {
+ startScreenCaptureFlow()
+ }
+ } else {
+ if (isScreenServiceRunning) {
+ isScreenServiceRunning = false
+ stopScreenCaptureService()
+ }
+ }
+ updateCollectionState()
+ }
+ }
+
setContent {
- SetupUI(viewModel)
+ SetupUI(
+ viewModel = viewModel,
+ settingsViewModel = settingsViewModel,
+ onToggleVisionMode = {
+ toggleVisionModeWithPermissionCheck()
+ },
+ onToggleScreenMode = {
+ toggleScreenModeWithPermissionCheck()
+ }
+ )
+ }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ isActivityStarted = true
+ updateCollectionState()
+ }
+
+ override fun onStop() {
+ super.onStop()
+ isActivityStarted = false
+ updateCollectionState()
+ }
+
+ private fun updateCollectionState() {
+ val shouldCollect = isActivityStarted || viewModel.isScreenModeActive.value
+ if (shouldCollect) {
+ startActiveCollection()
+ } else {
+ stopActiveCollection()
+ }
+ }
+
+ private fun startActiveCollection() {
+ if (activeCollectionJob != null) return
+
+ Log.d("MainActivity", "Starting active flow collection (Activity started: $isActivityStarted, Screen mode active: ${viewModel.isScreenModeActive.value})")
+ activeCollectionJob = lifecycleScope.launch {
+ // uiEvent collection
+ launch {
+ viewModel.uiEvent.collect { event ->
+ handleUiEvent(event)
+ }
+ }
+
+ // isMicMuted collection
+ launch {
+ viewModel.isMicMuted.collect { muted ->
+ if (::speechRecognizerManager.isInitialized) {
+ speechRecognizerManager.setMicMuted(muted)
+ }
+ com.app.assistant.camera.ScreenCaptureServiceHelper.isMicMuted = muted
+ com.app.assistant.camera.ScreenCaptureServiceHelper.onStateChanged?.invoke()
+ }
+ }
+
+ // isHandsFreeModeActive collection
+ launch {
+ var wasHandsFreeActive = false
+ viewModel.isHandsFreeModeActive.collect { active ->
+ com.app.assistant.camera.ScreenCaptureServiceHelper.isHandsFreeActive = active
+ com.app.assistant.camera.ScreenCaptureServiceHelper.onStateChanged?.invoke()
+
+ if (active) {
+ wasHandsFreeActive = true
+ if (::speechRecognizerManager.isInitialized && !speechRecognizerManager.isListeningActive()) {
+ startSpeechRecognition(isHandsFree = true)
+ }
+ } else {
+ if (wasHandsFreeActive) {
+ wasHandsFreeActive = false
+ speechRecognizerManager.stop()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private fun stopActiveCollection() {
+ if (activeCollectionJob == null) return
+ Log.d("MainActivity", "Stopping active flow collection")
+ activeCollectionJob?.cancel()
+ activeCollectionJob = null
+ }
+
+ private fun handleUiEvent(event: UIEvent) {
+ when (event) {
+ is UIEvent.RequestPermissions -> {
+ ActivityCompat.requestPermissions(this@MainActivity, event.permissions, event.requestCode)
+ }
+
+ is UIEvent.StartIntent -> {
+ try {
+ val intent = event.intent
+ if (intent.action == Intent.ACTION_CALL) {
+ if (!isExternalAudioDeviceConnected()) {
+ intent.putExtra("android.telecom.extra.START_CALL_WITH_SPEAKERPHONE", true)
+ }
+ }
+ startActivity(intent)
+ } catch (e: Exception) {
+ Log.e("MainActivity", "Error starting intent", e)
+ }
+ }
+
+ is UIEvent.ShowToast -> {
+ Toast.makeText(this@MainActivity, event.message, Toast.LENGTH_SHORT).show()
+ }
+
+ is UIEvent.SpeakText -> {
+ if (!viewModel.isListening.value && !viewModel.isVoiceProcessing.value) {
+ textToSpeechManager.speak(event.text, event.queueMode)
+ }
+ }
+
+ is UIEvent.StopSpeaking -> {
+ textToSpeechManager.stop()
+ }
+
+ is UIEvent.StartSpeechRecognition -> {
+ startSpeechRecognition(isHandsFree = viewModel.isHandsFreeModeActive.value)
+ }
+
+ is UIEvent.StopSpeechRecognition -> {
+ speechRecognizerManager.stop()
+ }
+
+ is UIEvent.GetLocationForWeather -> {
+ getCurrentLocationForWeather(event)
+ }
+
+ is UIEvent.ResolveLocationSettings -> {
+ try {
+ event.exception.startResolutionForResult(this@MainActivity, 104)
+ } catch (sendEx: IntentSender.SendIntentException) {
+ Log.e("MainActivity", "Error showing location settings dialog: ${sendEx.message}")
+ }
+ }
+ }
+ }
+
+ private fun toggleScreenModeWithPermissionCheck() {
+ if (viewModel.isScreenModeActive.value) {
+ viewModel.setScreenModeActive(false)
+ } else {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
+ ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 103)
+ return
+ }
+ }
+ startScreenCaptureFlow()
+ }
+ }
+
+ private fun startSpeechRecognition(isHandsFree: Boolean = false) {
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
+ ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.RECORD_AUDIO), 101)
+ return
+ }
+
+ if (textToSpeechManager.isSpeaking()) {
+ textToSpeechManager.stop()
+ }
+
+ speechRecognizerManager.startListening(
+ isHandsFree = isHandsFree,
+ listener = object : com.app.assistant.speech.SpeechRecognizerManager.SpeechListener {
+ override fun onReadyForSpeech() {
+ viewModel.setMicReady(true)
+ }
+
+ override fun onAudioLevelChanged(level: Float) {
+ viewModel.setAudioAmplitude(level)
+ }
+
+ override fun onBeginningOfSpeech() {
+ viewModel.setListening(true)
+ viewModel.setVoiceProcessing(false)
+ if (textToSpeechManager.isSpeaking()) {
+ textToSpeechManager.stop()
+ }
+ }
+
+ override fun onEndOfSpeech() {
+ viewModel.setListening(false)
+ viewModel.setVoiceProcessing(true)
+ viewModel.setAudioAmplitude(0f)
+ triggerVibration()
+ }
+
+ override fun onError(errorCode: Int) {
+ viewModel.setListening(false)
+ viewModel.setVoiceProcessing(false)
+ viewModel.setMicReady(false)
+ viewModel.setAudioAmplitude(0f)
+ if (viewModel.isHandsFreeModeActive.value) {
+ lifecycleScope.launch {
+ delay(500)
+ if (viewModel.isHandsFreeModeActive.value) {
+ startSpeechRecognition(isHandsFree = true)
+ }
+ }
+ }
+ }
+
+ override fun onResults(recognizedText: String) {
+ viewModel.setListening(false)
+ viewModel.setVoiceProcessing(false)
+ viewModel.setAudioAmplitude(0f)
+ if (!viewModel.isHandsFreeModeActive.value) {
+ viewModel.setMicReady(false)
+ }
+ if (recognizedText.isNotBlank()) {
+ if (viewModel.isVisionModeActive.value || viewModel.isScreenModeActive.value) {
+ val startTimestamp = speechRecognizerManager.speechStartTimestamp
+ viewModel.onSpeechRecognizedWithVision(recognizedText, startTimestamp)
+ } else {
+ viewModel.onSpeechRecognized(recognizedText)
+ }
+ }
+ }
+
+ override fun onPartialResults(recognizedText: String) {
+ viewModel.onSpeechPartialResult(recognizedText)
+ }
+ }
+ )
+ }
+
+ private fun getCurrentLocationForWeather(event: UIEvent.GetLocationForWeather) {
+ val locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
+ val gpsEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)
+
+ if (!gpsEnabled) {
+ promptEnableLocation()
+ viewModel.onLocationFailed(event.loadingItemId, event.speak, "GPS_OFF")
+ } else {
+ val fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
+ val locationRequest =
+ LocationRequest
+ .Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000)
+ .setWaitForAccurateLocation(true)
+ .setMaxUpdates(1)
+ .build()
+
+ val locationCallback =
+ object : LocationCallback() {
+ override fun onLocationResult(locationResult: LocationResult) {
+ val location = locationResult.lastLocation
+ if (location != null) {
+ viewModel.onLocationReceived(
+ location.latitude,
+ location.longitude,
+ event.itemId,
+ event.loadingItemId,
+ event.speak,
+ event.categoryName,
+ event.prompt,
+ )
+ } else {
+ viewModel.onLocationFailed(event.loadingItemId, event.speak, "UNAVAILABLE")
+ }
+ fusedLocationClient.removeLocationUpdates(this)
+ }
+
+ override fun onLocationAvailability(locationAvailability: LocationAvailability) {
+ if (!locationAvailability.isLocationAvailable) {
+ viewModel.onLocationFailed(event.loadingItemId, event.speak, "SUGGEST_CITY")
+ }
+ }
+ }
+
+ val permissionGranted =
+ ActivityCompat.checkSelfPermission(
+ this,
+ Manifest.permission.ACCESS_COARSE_LOCATION,
+ ) == PackageManager.PERMISSION_GRANTED
+ if (permissionGranted) {
+ fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, Looper.getMainLooper())
+ } else {
+ viewModel.onLocationFailed(event.loadingItemId, event.speak, "PERMISSION")
+ }
+ }
+ }
+
+ private fun promptEnableLocation() {
+ val locationRequest =
+ LocationRequest
+ .Builder(100, 10000)
+ .setMinUpdateIntervalMillis(5000)
+ .build()
+
+ val builder = LocationSettingsRequest.Builder().addLocationRequest(locationRequest)
+ val settingsClient: SettingsClient = LocationServices.getSettingsClient(this)
+ val task: Task = settingsClient.checkLocationSettings(builder.build())
+
+ task.addOnFailureListener { exception ->
+ if (exception is ResolvableApiException) {
+ try {
+ exception.startResolutionForResult(this@MainActivity, 104)
+ } catch (sendEx: IntentSender.SendIntentException) {
+ Log.d("Location", "Error showing location settings dialog: ${sendEx.message}")
+ }
+ }
+ }
+ }
+
+
+
+ private fun triggerVibration() {
+ try {
+ val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val manager = getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? android.os.VibratorManager
+ manager?.defaultVibrator ?: (getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator)
+ } else {
+ @Suppress("DEPRECATION")
+ getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
+ }
+
+ if (vibrator?.hasVibrator() == true) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ vibrator.vibrate(VibrationEffect.createOneShot(100, VibrationEffect.DEFAULT_AMPLITUDE))
+ } else {
+ @Suppress("DEPRECATION")
+ vibrator.vibrate(100)
+ }
+ }
+ } catch (e: Exception) {
+ Log.e("MainActivity", "Vibration failed", e)
}
}
@@ -35,7 +511,7 @@ class MainActivity : ComponentActivity() {
} else {
window.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
- WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
+ WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON,
)
}
@@ -46,13 +522,87 @@ class MainActivity : ComponentActivity() {
keyguardManager.requestDismissKeyguard(this, null)
}
}
- }catch (ex: Exception){
+ } catch (ex: Exception) {
ex.message?.let { Log.d("Exception Occurred", it) }
}
}
+ private fun toggleVisionModeWithPermissionCheck() {
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
+ ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CAMERA), 102)
+ } else {
+ viewModel.toggleVisionMode()
+ }
+ }
+
+ override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults)
+ when (requestCode) {
+ 101 -> {
+ if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ startSpeechRecognition()
+ } else {
+ Toast.makeText(this, "Audio permission is required for speech recognition", Toast.LENGTH_SHORT).show()
+ }
+ }
+ 102 -> {
+ if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ viewModel.toggleVisionMode()
+ } else {
+ Toast.makeText(this, "Camera permission is required for Vision Mode", Toast.LENGTH_SHORT).show()
+ }
+ }
+ 103 -> {
+ if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ startScreenCaptureFlow()
+ } else {
+ Toast.makeText(this, "Notification permission is required to show controls in the status bar", Toast.LENGTH_LONG).show()
+ startScreenCaptureFlow()
+ }
+ }
+ }
+ }
+
+ private fun isExternalAudioDeviceConnected(): Boolean {
+ val audioManager = getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return false
+ val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
+ for (device in devices) {
+ val type = device.type
+ if (type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
+ type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO ||
+ type == AudioDeviceInfo.TYPE_WIRED_HEADSET ||
+ type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES ||
+ type == AudioDeviceInfo.TYPE_USB_HEADSET
+ ) {
+ return true
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ if (type == AudioDeviceInfo.TYPE_BLE_HEADSET) {
+ return true
+ }
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ if (type == AudioDeviceInfo.TYPE_BLE_SPEAKER ||
+ type == AudioDeviceInfo.TYPE_BLE_BROADCAST
+ ) {
+ return true
+ }
+ }
+ }
+ return false
+ }
+
override fun onDestroy() {
super.onDestroy()
- viewModel.shutdownResources()
+ com.app.assistant.camera.ScreenCaptureServiceHelper.onServiceStopped = null
+ com.app.assistant.camera.ScreenCaptureServiceHelper.onToggleMuteRequested = null
+
+ if (isFinishing) {
+ if (isScreenServiceRunning) {
+ stopScreenCaptureService()
+ }
+ }
+ textToSpeechManager.shutdown()
+ speechRecognizerManager.destroy()
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/app/assistant/camera/ScreenCaptureService.kt b/app/src/main/java/com/app/assistant/camera/ScreenCaptureService.kt
new file mode 100644
index 0000000..b276f43
--- /dev/null
+++ b/app/src/main/java/com/app/assistant/camera/ScreenCaptureService.kt
@@ -0,0 +1,412 @@
+package com.app.assistant.camera
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.graphics.Bitmap
+import android.graphics.PixelFormat
+import android.hardware.display.DisplayManager
+import android.hardware.display.VirtualDisplay
+import android.media.ImageReader
+import android.media.projection.MediaProjection
+import android.media.projection.MediaProjectionManager
+import android.os.Build
+import android.os.Handler
+import android.os.HandlerThread
+import android.os.IBinder
+import android.app.PendingIntent
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import com.app.assistant.R
+import android.widget.RemoteViews
+import android.util.TypedValue
+import android.view.ContextThemeWrapper
+import android.content.res.ColorStateList
+import android.graphics.Color
+import androidx.core.graphics.ColorUtils
+import java.io.ByteArrayOutputStream
+import java.nio.ByteBuffer
+
+object ScreenCaptureServiceHelper {
+ var onServiceStopped: (() -> Unit)? = null
+ var onToggleMuteRequested: (() -> Unit)? = null
+
+
+ @Volatile
+ var isMicMuted: Boolean = false
+ @Volatile
+ var isHandsFreeActive: Boolean = false
+
+ var onStateChanged: (() -> Unit)? = null
+}
+
+class ScreenCaptureService : Service() {
+
+ companion object {
+ const val ACTION_START = "com.app.assistant.action.START_SCREEN_CAPTURE"
+ const val ACTION_STOP = "com.app.assistant.action.STOP_SCREEN_CAPTURE"
+ const val ACTION_TOGGLE_MUTE = "com.app.assistant.action.TOGGLE_MUTE"
+
+ const val EXTRA_RESULT_CODE = "extra_result_code"
+ const val EXTRA_RESULT_DATA = "extra_result_data"
+
+ private const val NOTIFICATION_ID = 5001
+ private const val CHANNEL_ID = "screen_capture_channel"
+ private const val TAG = "ScreenCaptureService"
+ }
+
+ private var mediaProjection: MediaProjection? = null
+ private var virtualDisplay: VirtualDisplay? = null
+ private var imageReader: ImageReader? = null
+ private var handlerThread: HandlerThread? = null
+ private var backgroundHandler: Handler? = null
+ private var lastFrameTime = 0L
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ handlerThread = HandlerThread("ScreenCaptureThread").apply {
+ start()
+ }
+ backgroundHandler = Handler(handlerThread!!.looper)
+
+ ScreenCaptureServiceHelper.onStateChanged = {
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ manager.notify(NOTIFICATION_ID, buildNotification())
+ }
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val action = intent?.action
+ if (action == ACTION_STOP) {
+ stopServiceInternal()
+ return START_NOT_STICKY
+ }
+
+ if (action == ACTION_TOGGLE_MUTE) {
+ ScreenCaptureServiceHelper.onToggleMuteRequested?.invoke()
+ return START_NOT_STICKY
+ }
+
+
+ if (action == ACTION_START) {
+ val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
+ val resultData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ intent.getParcelableExtra(EXTRA_RESULT_DATA)
+ }
+
+ if (resultCode != 0 && resultData != null) {
+ startForegroundNotification()
+ startScreenCapture(resultCode, resultData)
+ } else {
+ Log.e(TAG, "Invalid result code or data, stopping service")
+ stopSelf()
+ }
+ }
+
+ return START_NOT_STICKY
+ }
+
+ private fun startForegroundNotification() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ "Screen Capture Service",
+ NotificationManager.IMPORTANCE_LOW
+ ).apply {
+ description = "Notification for Screen Share Mode"
+ }
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ manager.createNotificationChannel(channel)
+ }
+
+ val notification = buildNotification()
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ startForeground(
+ NOTIFICATION_ID,
+ notification,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
+ )
+ } else {
+ startForeground(NOTIFICATION_ID, notification)
+ }
+ }
+
+ private fun getThemeColor(attr: Int, defaultColor: Int): Int {
+ val typedValue = TypedValue()
+ val themeContext = ContextThemeWrapper(this, R.style.Theme_Assistant)
+ if (themeContext.theme.resolveAttribute(attr, typedValue, true)) {
+ return typedValue.data
+ }
+ return defaultColor
+ }
+
+ private fun buildNotification(): Notification {
+ val isMuted = ScreenCaptureServiceHelper.isMicMuted
+ val isHandsFree = ScreenCaptureServiceHelper.isHandsFreeActive
+
+ val muteText = if (isMuted) "Unmute" else "Mute"
+ val muteIcon = if (isMuted) R.drawable.ic_mic_off else R.drawable.ic_mic
+
+
+ val statusText = when {
+ isMuted -> "Microphone Muted"
+ isHandsFree -> "Hands-Free Active"
+ else -> "Capturing screen for interactions"
+ }
+
+ val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ } else {
+ PendingIntent.FLAG_UPDATE_CURRENT
+ }
+
+ val stopIntent = Intent(this, ScreenCaptureService::class.java).apply {
+ action = ACTION_STOP
+ }
+ val stopPendingIntent = PendingIntent.getService(this, 1, stopIntent, flags)
+
+ val muteIntent = Intent(this, ScreenCaptureService::class.java).apply {
+ action = ACTION_TOGGLE_MUTE
+ }
+ val mutePendingIntent = PendingIntent.getService(this, 2, muteIntent, flags)
+
+
+ // Resolve wallpaper-based Material You accent or app theme accent
+ val dynamicPrimary = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ getColor(android.R.color.system_accent1_600)
+ } else {
+ getThemeColor(android.R.attr.colorAccent, 0xFF6650a4.toInt())
+ }
+
+ // Determine dark mode or light mode from configuration
+ val isDarkMode = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_YES
+
+ // Determine base text and accent colors depending on dark/light configuration
+ val primaryTextColor = if (isDarkMode) Color.WHITE else Color.BLACK
+ val secondaryTextColor = if (isDarkMode) 0xFFB0B0B0.toInt() else 0xFF666666.toInt()
+ val normalTint = if (isDarkMode) 0x1AFFFFFF else 0x1A000000
+ val activeTint = ColorUtils.setAlphaComponent(dynamicPrimary, if (isDarkMode) 0x66 else 0x4D) // higher opacity in dark mode for better visibility
+
+ val collapsedLayout = RemoteViews(packageName, R.layout.notification_screen_capture_collapsed).apply {
+ setTextViewText(R.id.tv_status_text, statusText)
+ setOnClickPendingIntent(R.id.btn_stop_compact, stopPendingIntent)
+
+ // Apply text and stop icon/background colors dynamically
+ setTextColor(R.id.tv_title, primaryTextColor)
+ setTextColor(R.id.tv_status_text, secondaryTextColor)
+ setInt(R.id.btn_stop_compact, "setColorFilter", primaryTextColor)
+ setColorStateList(R.id.btn_stop_compact, "setBackgroundTintList", ColorStateList.valueOf(normalTint))
+ }
+
+ val expandedLayout = RemoteViews(packageName, R.layout.notification_screen_capture_expanded).apply {
+ setTextViewText(R.id.tv_status_text, statusText)
+
+ // Apply header text colors dynamically
+ setTextColor(R.id.tv_title, primaryTextColor)
+ setTextColor(R.id.tv_status_text, secondaryTextColor)
+
+ // Setup Mute Button
+ setImageViewResource(R.id.iv_mute_icon, muteIcon)
+ setTextViewText(R.id.tv_mute_text, muteText)
+ setOnClickPendingIntent(R.id.btn_mute, mutePendingIntent)
+
+ // Set mute button text, icon, and background colors dynamically
+ setTextColor(R.id.tv_mute_text, primaryTextColor)
+ setInt(R.id.iv_mute_icon, "setColorFilter", primaryTextColor)
+ setColorStateList(
+ R.id.btn_mute,
+ "setBackgroundTintList",
+ ColorStateList.valueOf(if (isMuted) activeTint else normalTint)
+ )
+
+
+ // Setup Stop Button
+ setOnClickPendingIntent(R.id.btn_stop, stopPendingIntent)
+
+ // Set stop button text, icon, and background colors dynamically
+ setTextColor(R.id.tv_stop_text, primaryTextColor)
+ setInt(R.id.iv_stop_icon, "setColorFilter", primaryTextColor)
+ setColorStateList(R.id.btn_stop, "setBackgroundTintList", ColorStateList.valueOf(normalTint))
+ }
+
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_screen_share)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setCategory(NotificationCompat.CATEGORY_SERVICE)
+ .setStyle(NotificationCompat.DecoratedCustomViewStyle())
+ .setCustomContentView(collapsedLayout)
+ .setCustomBigContentView(expandedLayout)
+ .build()
+ }
+
+ private fun startScreenCapture(resultCode: Int, resultData: Intent) {
+ val mpManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
+ try {
+ val projection = mpManager.getMediaProjection(resultCode, resultData)
+ mediaProjection = projection
+
+ projection.registerCallback(object : MediaProjection.Callback() {
+ override fun onStop() {
+ Log.d(TAG, "MediaProjection stopped by system/user")
+ stopServiceInternal()
+ }
+ }, backgroundHandler)
+
+ setupCapturePipeline(projection)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to start media projection capture", e)
+ stopServiceInternal()
+ }
+ }
+
+ private fun setupCapturePipeline(projection: MediaProjection) {
+ val metrics = resources.displayMetrics
+ val screenWidth = metrics.widthPixels
+ val screenHeight = metrics.heightPixels
+ val densityDpi = metrics.densityDpi
+
+ // Aspect ratio calculations scaled down to width ~720px
+ val targetWidth = 720
+ val targetHeight = (targetWidth * (screenHeight.toFloat() / screenWidth.toFloat())).toInt()
+ val targetDensityDpi = (densityDpi * (targetWidth.toFloat() / screenWidth.toFloat())).toInt()
+
+ Log.d(TAG, "Screen size: ${screenWidth}x${screenHeight}. Scaling capture to ${targetWidth}x${targetHeight} at ${targetDensityDpi}dpi")
+
+ // Max images capacity of 2. Format: RGBA_8888.
+ val reader = ImageReader.newInstance(
+ targetWidth,
+ targetHeight,
+ PixelFormat.RGBA_8888,
+ 2
+ )
+ imageReader = reader
+
+ reader.setOnImageAvailableListener({ r ->
+ var image: android.media.Image? = null
+ try {
+ image = r.acquireLatestImage()
+ if (image == null) return@setOnImageAvailableListener
+
+ val now = System.currentTimeMillis()
+ if (now - lastFrameTime < 150) {
+ image.close()
+ image = null
+ return@setOnImageAvailableListener
+ }
+ lastFrameTime = now
+
+ val width = image.width
+ val height = image.height
+ val plane = image.planes[0]
+ val buffer = plane.buffer
+ val pixelStride = plane.pixelStride
+ val rowStride = plane.rowStride
+ val rowPadding = rowStride - pixelStride * width
+
+ val bitmapWidth = width + rowPadding / pixelStride
+ val bitmap = Bitmap.createBitmap(
+ bitmapWidth,
+ height,
+ Bitmap.Config.ARGB_8888
+ )
+ buffer.position(0)
+ bitmap.copyPixelsFromBuffer(buffer)
+
+ // Close image immediately to prevent VirtualDisplay stalling
+ image.close()
+ image = null
+
+ val finalBitmap = if (rowPadding > 0) {
+ val cropped = Bitmap.createBitmap(bitmap, 0, 0, width, height)
+ bitmap.recycle()
+ cropped
+ } else {
+ bitmap
+ }
+
+ val out = ByteArrayOutputStream()
+ finalBitmap.compress(Bitmap.CompressFormat.JPEG, 80, out)
+ finalBitmap.recycle()
+
+ val jpegBytes = out.toByteArray()
+ val cachedFrame = CachedFrame(jpegBytes, now)
+ VisualBufferManager.addFrame(cachedFrame)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error converting image frame", e)
+ try {
+ image?.close()
+ } catch (ex: Exception) {
+ // Ignore
+ }
+ }
+ }, backgroundHandler)
+
+ // Create VirtualDisplay using ImageReader surface
+ virtualDisplay = projection.createVirtualDisplay(
+ "ScreenCaptureDisplay",
+ targetWidth,
+ targetHeight,
+ targetDensityDpi,
+ DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
+ reader.surface,
+ null,
+ backgroundHandler
+ )
+ }
+
+ private fun stopServiceInternal() {
+ Log.d(TAG, "Stopping ScreenCaptureService and cleaning up resources")
+ try {
+ virtualDisplay?.release()
+ virtualDisplay = null
+ } catch (e: Exception) {
+ Log.e(TAG, "Error releasing virtual display", e)
+ }
+
+ try {
+ imageReader?.close()
+ imageReader = null
+ } catch (e: Exception) {
+ Log.e(TAG, "Error closing image reader", e)
+ }
+
+ try {
+ mediaProjection?.stop()
+ mediaProjection = null
+ } catch (e: Exception) {
+ Log.e(TAG, "Error stopping media projection", e)
+ }
+
+ try {
+ handlerThread?.quitSafely()
+ handlerThread = null
+ backgroundHandler = null
+ } catch (e: Exception) {
+ Log.e(TAG, "Error quitting handler thread", e)
+ }
+
+ // Clear visual buffer to prevent leaks
+ VisualBufferManager.clear()
+
+ ScreenCaptureServiceHelper.onStateChanged = null
+
+ stopForeground(true)
+ stopSelf()
+ ScreenCaptureServiceHelper.onServiceStopped?.invoke()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ stopServiceInternal()
+ }
+}
diff --git a/app/src/main/java/com/app/assistant/camera/VisualBufferManager.kt b/app/src/main/java/com/app/assistant/camera/VisualBufferManager.kt
new file mode 100644
index 0000000..c811827
--- /dev/null
+++ b/app/src/main/java/com/app/assistant/camera/VisualBufferManager.kt
@@ -0,0 +1,119 @@
+package com.app.assistant.camera
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.ImageFormat
+import android.graphics.Matrix
+import android.graphics.Rect
+import android.graphics.YuvImage
+import androidx.camera.core.ImageProxy
+import java.io.ByteArrayOutputStream
+import java.util.Collections
+import java.util.ArrayList
+
+data class CachedFrame(val jpegBytes: ByteArray, val timestamp: Long)
+
+object VisualBufferManager {
+ private val buffer = Collections.synchronizedList(ArrayList())
+ private const val MAX_SIZE = 15
+
+ fun addFrame(frame: CachedFrame) {
+ synchronized(buffer) {
+ buffer.add(frame)
+ if (buffer.size > MAX_SIZE) {
+ buffer.removeAt(0)
+ }
+ }
+ }
+
+ fun getOptimalFrame(speechStartTimestamp: Long): CachedFrame? {
+ synchronized(buffer) {
+ if (buffer.isEmpty()) return null
+ var optimalFrame = buffer[0]
+ var minDiff = Math.abs(optimalFrame.timestamp - speechStartTimestamp)
+ for (i in 1 until buffer.size) {
+ val frame = buffer[i]
+ val diff = Math.abs(frame.timestamp - speechStartTimestamp)
+ if (diff < minDiff) {
+ minDiff = diff
+ optimalFrame = frame
+ }
+ }
+ return optimalFrame
+ }
+ }
+
+ fun clear() {
+ buffer.clear()
+ }
+}
+
+fun imageProxyToRotatedJpeg(imageProxy: ImageProxy): ByteArray? {
+ try {
+ val width = imageProxy.width
+ val height = imageProxy.height
+
+ val yBuffer = imageProxy.planes[0].buffer
+ val uBuffer = imageProxy.planes[1].buffer
+ val vBuffer = imageProxy.planes[2].buffer
+
+ // Correct size for NV21 format: width * height * 3 / 2
+ val ySize = width * height
+ val nv21 = ByteArray(ySize + ySize / 2)
+
+ // 1. Copy Y plane, stripping row stride padding if present
+ val yRowStride = imageProxy.planes[0].rowStride
+ val yBufferPos = yBuffer.position()
+ if (yRowStride == width) {
+ yBuffer.get(nv21, 0, ySize)
+ } else {
+ for (row in 0 until height) {
+ yBuffer.position(row * yRowStride)
+ yBuffer.get(nv21, row * width, width)
+ }
+ }
+ yBuffer.position(yBufferPos)
+
+ val uRowStride = imageProxy.planes[1].rowStride
+ val uPixelStride = imageProxy.planes[1].pixelStride
+ val vRowStride = imageProxy.planes[2].rowStride
+ val vPixelStride = imageProxy.planes[2].pixelStride
+
+ // 2. Interleave U and V planes into NV21 format (V, U, V, U...)
+ var pos = ySize
+ for (row in 0 until height / 2) {
+ for (col in 0 until width / 2) {
+ val uIndex = row * uRowStride + col * uPixelStride
+ val vIndex = row * vRowStride + col * vPixelStride
+
+ if (vIndex < vBuffer.capacity() && uIndex < uBuffer.capacity()) {
+ nv21[pos++] = vBuffer.get(vIndex)
+ nv21[pos++] = uBuffer.get(uIndex)
+ }
+ }
+ }
+
+ val yuvImage = YuvImage(nv21, ImageFormat.NV21, width, height, null)
+ val out = ByteArrayOutputStream()
+ yuvImage.compressToJpeg(Rect(0, 0, width, height), 80, out)
+ val jpegBytes = out.toByteArray()
+
+ val rotation = imageProxy.imageInfo.rotationDegrees
+ if (rotation == 0) {
+ return jpegBytes
+ }
+
+ val bitmap = BitmapFactory.decodeByteArray(jpegBytes, 0, jpegBytes.size) ?: return null
+ val matrix = Matrix().apply { postRotate(rotation.toFloat()) }
+ val rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
+ bitmap.recycle()
+
+ val rotatedOut = ByteArrayOutputStream()
+ rotatedBitmap.compress(Bitmap.CompressFormat.JPEG, 80, rotatedOut)
+ rotatedBitmap.recycle()
+ return rotatedOut.toByteArray()
+ } catch (e: Exception) {
+ android.util.Log.e("VisualBufferManager", "Error converting ImageProxy to rotated JPEG", e)
+ return null
+ }
+}
diff --git a/app/src/main/java/com/app/assistant/classifier/TextClassifierHelper.kt b/app/src/main/java/com/app/assistant/classifier/TextClassifierHelper.kt
new file mode 100644
index 0000000..4ffdb3e
--- /dev/null
+++ b/app/src/main/java/com/app/assistant/classifier/TextClassifierHelper.kt
@@ -0,0 +1,117 @@
+package com.app.assistant.classifier
+
+import android.content.Context
+import android.os.SystemClock
+import android.util.Log
+import com.google.mediapipe.tasks.core.BaseOptions
+import com.google.mediapipe.tasks.text.textclassifier.TextClassifier
+import com.google.mediapipe.tasks.text.textclassifier.TextClassifierResult
+import java.util.concurrent.ScheduledThreadPoolExecutor
+
+class TextClassifierHelper(
+ var currentModel: String = MOBILEBERT,
+ val context: Context,
+ val listener: TextResultsListener,
+) {
+ private lateinit var textClassifier: TextClassifier
+ private val executor = ScheduledThreadPoolExecutor(1)
+
+ init {
+ executor.execute {
+ initClassifier()
+ }
+ }
+
+ fun initClassifier() {
+ val baseOptionsBuilder =
+ BaseOptions
+ .builder()
+ .setModelAssetPath(currentModel)
+
+ try {
+ val baseOptions = baseOptionsBuilder.build()
+ val optionsBuilder =
+ TextClassifier.TextClassifierOptions
+ .builder()
+ .setBaseOptions(baseOptions)
+ val options = optionsBuilder.build()
+ textClassifier = TextClassifier.createFromOptions(context, options)
+ } catch (e: Exception) {
+ listener.onError(
+ "Text classifier failed to initialize. See error logs for details"
+ )
+ Log.e(
+ TAG,
+ "Text classifier failed to load the task with error: " + e.message,
+ e
+ )
+ }
+ }
+
+ // Run text classification using MediaPipe Text Classifier API
+ fun classify(
+ text: String,
+ itemId: Long,
+ loadingItemId: Long,
+ speak: Boolean,
+ ) {
+ executor.execute {
+ if (!::textClassifier.isInitialized) {
+ listener.onError("Text classifier not initialized", itemId, loadingItemId, speak)
+ return@execute
+ }
+
+ // inferenceTime is the amount of time, in milliseconds, that it takes to
+ // classify the input text.
+ var inferenceTime = SystemClock.uptimeMillis()
+
+ // Remove all punctuation before classifying
+ val cleanedText = text.replace(Regex("[.,?!]"), "")
+ try {
+ val results = textClassifier.classify(cleanedText)
+
+ inferenceTime = SystemClock.uptimeMillis() - inferenceTime
+
+ listener.onResult(results, inferenceTime, text, itemId, loadingItemId, speak)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error running classification: ${e.message}", e)
+ listener.onError("Classification failed: ${e.message}", itemId, loadingItemId, speak)
+ }
+ }
+ }
+
+ fun shutDown() {
+ try {
+ executor.shutdown()
+ } catch (e: Exception) {
+ Log.e(TAG, "Error shutting down executor", e)
+ }
+ }
+
+ interface TextResultsListener {
+ fun onError(error: String)
+
+ fun onError(
+ error: String,
+ itemId: Long,
+ loadingItemId: Long,
+ speak: Boolean
+ ) {
+ onError(error)
+ }
+
+ fun onResult(
+ results: TextClassifierResult,
+ inferenceTime: Long,
+ inputText: String,
+ itemId: Long,
+ loadingItemId: Long,
+ speak: Boolean,
+ )
+ }
+
+ companion object {
+ const val TAG = "TextClassifierHelper"
+ const val MOBILEBERT = "model.tflite"
+ }
+}
diff --git a/app/src/main/java/com/app/assistant/config/SpeechConfig.kt b/app/src/main/java/com/app/assistant/config/SpeechConfig.kt
new file mode 100644
index 0000000..5a00794
--- /dev/null
+++ b/app/src/main/java/com/app/assistant/config/SpeechConfig.kt
@@ -0,0 +1,71 @@
+package com.app.assistant.config
+
+import com.app.assistant.BuildConfig
+import com.app.assistant.repository.SettingsRepository
+
+import com.app.assistant.llm.LlmProvider
+
+enum class TtsApiProvider {
+ EDGE_TTS,
+ GOOGLE_TTS
+}
+
+enum class SttApiProvider {
+ GROQ_WHISPER,
+ GOOGLE_STT
+}
+
+object SpeechConfig {
+ // Current Active Providers - change these to switch implementations and rebuild
+ var ACTIVE_TTS_PROVIDER = TtsApiProvider.EDGE_TTS
+ val ACTIVE_STT_PROVIDER = SttApiProvider.GROQ_WHISPER
+
+ // Edge TTS Configurations
+ object EdgeTts {
+ const val WSS_URL = "wss://api.msedgeservices.com/tts/cognitiveservices/websocket/v1"
+ fun getSubscriptionKey(settingsRepository: SettingsRepository): String {
+ val key = settingsRepository.getEdgeTtsSubscriptionKey()
+ return if (key.isNullOrBlank()) BuildConfig.EDGE_TTS_SUBSCRIPTION_KEY else key
+ }
+ const val VOICE = "en-GB-SoniaNeural"
+ const val OUTPUT_FORMAT = "audio-24khz-48kbitrate-mono-mp3"
+ }
+
+ // Google TTS Configurations
+ object GoogleTts {
+ const val BASE_URL = "https://texttospeech.googleapis.com/v1/text:synthesize"
+ const val VOICE = "en-US-Wavenet-D"
+ const val AUDIO_ENCODING = "MP3"
+
+ fun getApiKey(settingsRepository: SettingsRepository): String {
+ return settingsRepository.getChatApiKey() ?: ""
+ }
+ }
+
+ // Groq Whisper Configurations
+ object GroqWhisper {
+ const val BASE_URL = "https://api.groq.com/openai/v1/audio/transcriptions"
+ const val MODEL = "whisper-large-v3"
+
+ fun getApiKey(settingsRepository: SettingsRepository): String {
+ var apiKey = settingsRepository.getChatApiKey()
+ val providerStr = settingsRepository.getLlmProvider()
+ val provider = try {
+ LlmProvider.valueOf(providerStr)
+ } catch (e: Exception) {
+ LlmProvider.GROQ
+ }
+ if (apiKey.isNullOrBlank() && provider == LlmProvider.GROQ) {
+ apiKey = BuildConfig.GROQ_API_KEY
+ }
+ return apiKey ?: ""
+ }
+ }
+
+ // Google STT Configurations
+ object GoogleStt {
+ const val BASE_URL = "https://speech.googleapis.com/v1/speech:recognize"
+ const val ENCODING = "LINEAR16"
+ const val SAMPLE_RATE_HERTZ = 16000
+ }
+}
diff --git a/app/src/main/java/com/app/assistant/db/AppDatabase.kt b/app/src/main/java/com/app/assistant/db/AppDatabase.kt
new file mode 100644
index 0000000..9465e20
--- /dev/null
+++ b/app/src/main/java/com/app/assistant/db/AppDatabase.kt
@@ -0,0 +1,32 @@
+package com.app.assistant.db
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+
+@Database(entities = [GroupEntity::class, MessageEntity::class, AttachmentEntity::class], version = 4, exportSchema = false)
+@TypeConverters(Converters::class)
+abstract class AppDatabase : RoomDatabase() {
+ abstract fun conversationDao(): ConversationDao
+
+ companion object {
+ @Volatile
+ private var INSTANCE: AppDatabase? = null
+
+ fun getDatabase(context: Context): AppDatabase {
+ return INSTANCE ?: synchronized(this) {
+ val instance = Room.databaseBuilder(
+ context.applicationContext,
+ AppDatabase::class.java,
+ "conversation.db"
+ )
+ .fallbackToDestructiveMigration()
+ .build()
+ INSTANCE = instance
+ instance
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/app/assistant/db/AttachmentEntity.kt b/app/src/main/java/com/app/assistant/db/AttachmentEntity.kt
new file mode 100644
index 0000000..0780a53
--- /dev/null
+++ b/app/src/main/java/com/app/assistant/db/AttachmentEntity.kt
@@ -0,0 +1,39 @@
+package com.app.assistant.db
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(
+ tableName = "attachments",
+ foreignKeys = [
+ ForeignKey(
+ entity = MessageEntity::class,
+ parentColumns = ["id"],
+ childColumns = ["message_id"],
+ onDelete = ForeignKey.CASCADE
+ )
+ ],
+ indices = [Index(value = ["message_id"])]
+)
+data class AttachmentEntity(
+ @PrimaryKey(autoGenerate = true)
+ val id: Long = 0,
+
+ @ColumnInfo(name = "message_id")
+ val messageId: Long,
+
+ @ColumnInfo(name = "file_path")
+ val filePath: String,
+
+ @ColumnInfo(name = "mime_type")
+ val mimeType: String,
+
+ @ColumnInfo(name = "file_name")
+ val fileName: String,
+
+ @ColumnInfo(name = "iv")
+ val iv: String
+)
diff --git a/app/src/main/java/com/app/assistant/db/ConversationDao.kt b/app/src/main/java/com/app/assistant/db/ConversationDao.kt
new file mode 100644
index 0000000..60f4cf2
--- /dev/null
+++ b/app/src/main/java/com/app/assistant/db/ConversationDao.kt
@@ -0,0 +1,52 @@
+package com.app.assistant.db
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
+
+@Dao
+interface ConversationDao {
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertGroup(group: GroupEntity): Long
+
+ @Query("SELECT * FROM `groups` ORDER BY group_id DESC")
+ suspend fun getAllGroups(): List
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertMessage(message: MessageEntity)
+
+ @Query("DELETE FROM messages WHERE id = :messageId")
+ suspend fun deleteMessageById(messageId: Long)
+
+ @Update
+ suspend fun updateMessage(message: MessageEntity)
+
+ @Query("DELETE FROM messages WHERE id IN (:ids)")
+ suspend fun deleteMessagesByIds(ids: List)
+
+ @Query("SELECT file_path FROM attachments WHERE message_id IN (SELECT id FROM messages WHERE group_id = :groupId)")
+ suspend fun getAttachmentFilePathsForGroup(groupId: Long): List
+
+ @Query("DELETE FROM attachments WHERE message_id IN (SELECT id FROM messages WHERE group_id = :groupId)")
+ suspend fun deleteAttachmentsForGroup(groupId: Long)
+
+ @Query("DELETE FROM messages WHERE group_id = :groupId")
+ suspend fun deleteMessagesForGroup(groupId: Long)
+
+ @Query("DELETE FROM `groups` WHERE group_id = :groupId")
+ suspend fun deleteGroupById(groupId: Long)
+
+ @Query("SELECT * FROM messages WHERE group_id = :groupId ORDER BY id ASC")
+ suspend fun getMessagesForGroup(groupId: Long): List
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertAttachment(attachment: AttachmentEntity)
+
+ @Query("SELECT * FROM attachments WHERE message_id = :messageId ORDER BY id ASC")
+ suspend fun getAttachmentsForMessage(messageId: Long): List
+
+ @Query("DELETE FROM attachments WHERE message_id = :messageId")
+ suspend fun deleteAttachmentsForMessage(messageId: Long)
+}
diff --git a/app/src/main/java/com/app/assistant/db/Converters.kt b/app/src/main/java/com/app/assistant/db/Converters.kt
new file mode 100644
index 0000000..f971691
--- /dev/null
+++ b/app/src/main/java/com/app/assistant/db/Converters.kt
@@ -0,0 +1,16 @@
+package com.app.assistant.db
+
+import androidx.room.TypeConverter
+import java.net.URI
+
+class Converters {
+ @TypeConverter
+ fun fromString(value: String?): URI? {
+ return value?.let { URI(it) }
+ }
+
+ @TypeConverter
+ fun uriToString(uri: URI?): String? {
+ return uri?.toString()
+ }
+}
diff --git a/app/src/main/java/com/app/assistant/db/DynamicConversationDbHelper.kt b/app/src/main/java/com/app/assistant/db/DynamicConversationDbHelper.kt
deleted file mode 100644
index 9f04d63..0000000
--- a/app/src/main/java/com/app/assistant/db/DynamicConversationDbHelper.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-package com.app.assistant.db
-
-import android.content.Context
-import android.database.sqlite.SQLiteDatabase
-import android.database.sqlite.SQLiteOpenHelper
-import com.app.assistant.model.Conversation
-import kotlin.reflect.full.memberProperties
-
-
-class DynamicConversationDbHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
-
- companion object {
- private const val DATABASE_NAME = "conversation.db"
- private const val DATABASE_VERSION = 1
-
- private const val COLUMN_IV = "iv"
-
- // Groups Table
- private const val TABLE_GROUPS = "groups"
- private const val COLUMN_GROUP_ID = "group_id"
- private const val COLUMN_TITLE = "title"
- private const val COLUMN_CREATED_AT = "created_at"
-
- // Messages Table
- private const val TABLE_MESSAGES = "messages"
- }
-
- override fun onCreate(db: SQLiteDatabase?) {
- val createGroupsTableQuery = """
- CREATE TABLE $TABLE_GROUPS (
- $COLUMN_GROUP_ID INTEGER PRIMARY KEY AUTOINCREMENT,
- $COLUMN_TITLE TEXT,
- $COLUMN_CREATED_AT INTEGER
- )
- """.trimIndent()
- db?.execSQL(createGroupsTableQuery)
-
- val createMessageTableQuery = buildCreateTableQuery()
- db?.execSQL(createMessageTableQuery)
- }
-
- override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
- db?.execSQL("DROP TABLE IF EXISTS $TABLE_MESSAGES")
- db?.execSQL("DROP TABLE IF EXISTS $TABLE_GROUPS")
- onCreate(db)
- }
-
- private fun buildCreateTableQuery(): String {
- val fields = Conversation::class.memberProperties
- val columns = fields.filterNot { it.name == "isLoading" }.joinToString(", ") { field ->
- val fieldType = mapFieldTypeToSQLiteType(field.returnType.toString())
- if (field.name == "id") {
- "${field.name} INTEGER PRIMARY KEY"
- } else {
- "${field.name} $fieldType"
- }
- }
-
- return """
- CREATE TABLE $TABLE_MESSAGES (
- $columns,
- iv TEXT,
- $COLUMN_GROUP_ID INTEGER,
- FOREIGN KEY ($COLUMN_GROUP_ID) REFERENCES $TABLE_GROUPS($COLUMN_GROUP_ID)
- )
- """.trimIndent()
- }
-
- private fun mapFieldTypeToSQLiteType(kotlinType: String): String {
- return when (kotlinType) {
- "kotlin.String" -> "TEXT"
- "kotlin.Int", "kotlin.Long", "kotlin.Boolean" -> "INTEGER"
- "java.net.URI" -> "TEXT"
- else -> "TEXT"
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/app/assistant/db/DynamicConversationRepository.kt b/app/src/main/java/com/app/assistant/db/DynamicConversationRepository.kt
index e651fd1..3452e2e 100644
--- a/app/src/main/java/com/app/assistant/db/DynamicConversationRepository.kt
+++ b/app/src/main/java/com/app/assistant/db/DynamicConversationRepository.kt
@@ -1,46 +1,32 @@
package com.app.assistant.db
-import android.content.ContentValues
import android.content.Context
-import android.database.Cursor
-import android.text.format.DateFormat
-import android.util.Log
-import androidx.compose.runtime.snapshots.SnapshotStateList
import com.app.assistant.model.Conversation
import com.app.assistant.model.Group
+import com.app.assistant.model.Attachment
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
-import java.net.URI
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
-import kotlin.reflect.full.memberProperties
-import kotlin.reflect.full.primaryConstructor
-class DynamicConversationRepository(private val context: Context) {
-
- companion object {
- private const val TABLE_GROUPS = "groups"
- private const val COLUMN_GROUP_ID = "group_id"
- private const val COLUMN_TITLE = "title"
- private const val COLUMN_CREATED_AT = "created_at"
- private const val TABLE_MESSAGES = "messages"
- private const val COLUMN_MESSAGE_ID = "id"
- }
-
- private val dbHelper = DynamicConversationDbHelper(context)
+class DynamicConversationRepository(
+ context: Context,
+) {
+ private val db = AppDatabase.getDatabase(context)
+ private val dao = db.conversationDao()
var currentGroupId: Long = -1L
+ private val writeMutex = Mutex()
private suspend fun startNewChat(msg: String): Long {
- withContext(Dispatchers.IO) {
- val db = dbHelper.writableDatabase
- val contentValues = ContentValues().apply {
- put(COLUMN_TITLE, generateChatTitle(msg))
- put(COLUMN_CREATED_AT, System.nanoTime())
- }
- currentGroupId = db.insert(TABLE_GROUPS, null, contentValues)
+ return withContext(Dispatchers.IO) {
+ val title = generateChatTitle(msg)
+ val group = GroupEntity(title = title, createdAt = System.nanoTime())
+ currentGroupId = dao.insertGroup(group)
+ currentGroupId
}
- return currentGroupId
}
private fun generateChatTitle(msg: String): String {
@@ -51,151 +37,181 @@ class DynamicConversationRepository(private val context: Context) {
}
suspend fun addMessage(conversation: Conversation) {
- withContext(Dispatchers.IO) {
- if (currentGroupId == -1L) {
- currentGroupId = startNewChat(conversation.englishText.takeIf { it.isNotEmpty() } ?: conversation.translatedText)
- }
+ writeMutex.withLock {
+ withContext(Dispatchers.IO) {
+ if (currentGroupId == -1L) {
+ currentGroupId = startNewChat(conversation.text)
+ }
- val db = dbHelper.writableDatabase
- val (encryptedMap, iv) = encryptConversation(conversation)
- val values = ContentValues().apply {
- encryptedMap.forEach { (key, value) -> put(key, value) }
- put("iv", iv)
- put(COLUMN_GROUP_ID, currentGroupId)
- }
- try{
- db.insert(TABLE_MESSAGES, null, values)
- }catch (e: Exception){
- e.message?.let { Log.d("Test", it) }
+ val iv = EncryptionUtil.generateIV()
+ val (encryptedText, _) = EncryptionUtil.encrypt(conversation.text, iv)
+
+ val messageEntity = MessageEntity(
+ id = conversation.id,
+ text = encryptedText,
+ isMe = conversation.isMe,
+ category = conversation.category,
+ contentURL = conversation.contentURL,
+ navigationURI = conversation.navigationURI,
+ iv = iv,
+ groupId = currentGroupId
+ )
+ dao.insertMessage(messageEntity)
+
+ // Insert attachments
+ conversation.attachments.forEach { attachment ->
+ val attachmentEntity = AttachmentEntity(
+ id = attachment.id,
+ messageId = conversation.id,
+ filePath = attachment.filePath,
+ mimeType = attachment.mimeType,
+ fileName = attachment.fileName,
+ iv = attachment.iv
+ )
+ dao.insertAttachment(attachmentEntity)
+ }
}
}
}
suspend fun deleteMessage(id: Long) {
- withContext(Dispatchers.IO) {
- val db = dbHelper.writableDatabase
- db.delete(
- TABLE_MESSAGES,
- "id = ?",
- arrayOf(id.toString())
- )
+ writeMutex.withLock {
+ withContext(Dispatchers.IO) {
+ val attachmentEntities = dao.getAttachmentsForMessage(id)
+ attachmentEntities.forEach { att ->
+ try {
+ val file = java.io.File(att.filePath)
+ if (file.exists()) {
+ file.delete()
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+ dao.deleteAttachmentsForMessage(id)
+ dao.deleteMessageById(id)
+ }
}
}
- suspend fun updateMessage(oldConversation: Conversation, newConversation: Conversation) {
- withContext(Dispatchers.IO) {
- val db = dbHelper.writableDatabase
- val (encryptedMap, iv) = encryptConversation(newConversation)
- val values = ContentValues().apply {
- encryptedMap.forEach { (key, value) -> put(key, value) }
- put("iv", iv)
+ suspend fun updateMessage(
+ oldConversation: Conversation,
+ newConversation: Conversation,
+ ) {
+ writeMutex.withLock {
+ withContext(Dispatchers.IO) {
+ val iv = EncryptionUtil.generateIV()
+ val (encryptedText, _) = EncryptionUtil.encrypt(newConversation.text, iv)
+
+ val messageEntity = MessageEntity(
+ id = newConversation.id,
+ text = encryptedText,
+ isMe = newConversation.isMe,
+ category = newConversation.category,
+ contentURL = newConversation.contentURL,
+ navigationURI = newConversation.navigationURI,
+ iv = iv,
+ groupId = currentGroupId
+ )
+ dao.updateMessage(messageEntity)
+
+ // Delete old attachments first and insert new ones
+ dao.deleteAttachmentsForMessage(newConversation.id)
+ newConversation.attachments.forEach { attachment ->
+ val attachmentEntity = AttachmentEntity(
+ id = attachment.id,
+ messageId = newConversation.id,
+ filePath = attachment.filePath,
+ mimeType = attachment.mimeType,
+ fileName = attachment.fileName,
+ iv = attachment.iv
+ )
+ dao.insertAttachment(attachmentEntity)
+ }
}
- db.update(
- TABLE_MESSAGES,
- values,
- "id = ?",
- arrayOf(oldConversation.id.toString())
- )
}
}
suspend fun clearMessages(conversations: List) {
- withContext(Dispatchers.IO) {
- val db = dbHelper.writableDatabase
- val ids = conversations.joinToString(",") { it.id.toString() }
- db.execSQL("DELETE FROM $TABLE_MESSAGES WHERE id IN ($ids)")
- db.execSQL("DELETE FROM $TABLE_GROUPS WHERE $COLUMN_GROUP_ID == $currentGroupId")
- currentGroupId = -1L
+ writeMutex.withLock {
+ withContext(Dispatchers.IO) {
+ conversations.forEach { conversation ->
+ val attachmentEntities = dao.getAttachmentsForMessage(conversation.id)
+ attachmentEntities.forEach { att ->
+ try {
+ val file = java.io.File(att.filePath)
+ if (file.exists()) {
+ file.delete()
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+ dao.deleteAttachmentsForMessage(conversation.id)
+ }
+ val ids = conversations.map { it.id }
+ dao.deleteMessagesByIds(ids)
+ dao.deleteGroupById(currentGroupId)
+ currentGroupId = -1L
+ }
+ }
+ }
+
+ suspend fun deleteGroup(groupId: Long) {
+ writeMutex.withLock {
+ withContext(Dispatchers.IO) {
+ val filePaths = dao.getAttachmentFilePathsForGroup(groupId)
+ filePaths.forEach { filePath ->
+ try {
+ val file = java.io.File(filePath)
+ if (file.exists()) {
+ file.delete()
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+ dao.deleteAttachmentsForGroup(groupId)
+ dao.deleteMessagesForGroup(groupId)
+ dao.deleteGroupById(groupId)
+ }
}
}
suspend fun loadAllGroups(): MutableList {
- val groupList = mutableListOf()
- withContext(Dispatchers.IO) {
- val db = dbHelper.readableDatabase
- val cursor: Cursor = db.query(
- TABLE_GROUPS,
- arrayOf(COLUMN_GROUP_ID, COLUMN_TITLE),
- null,
- null,
- null,
- null,
- "$COLUMN_GROUP_ID DESC" // Sort by latest created
- )
-
- while (cursor.moveToNext()) {
- val groupId = cursor.getLong(cursor.getColumnIndexOrThrow(COLUMN_GROUP_ID))
- val title = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_TITLE))
- groupList.add(Group(groupId, title))
- }
- cursor.close()
+ return withContext(Dispatchers.IO) {
+ dao.getAllGroups().map { entity ->
+ Group(groupId = entity.groupId, title = entity.title)
+ }.toMutableList()
}
- return groupList
}
-
suspend fun loadMessagesForGroup(groupId: Long): MutableList {
- val tempList = mutableListOf()
- withContext(Dispatchers.IO) {
- val db = dbHelper.readableDatabase
- val cursor: Cursor = db.query(
- TABLE_MESSAGES,
- null,
- "$COLUMN_GROUP_ID = ?",
- arrayOf(groupId.toString()),
- null,
- null,
- COLUMN_MESSAGE_ID // Sort by latest created
- )
+ return withContext(Dispatchers.IO) {
+ dao.getMessagesForGroup(groupId).map { entity ->
+ val decryptedText = EncryptionUtil.decrypt(entity.text, entity.iv)
+ val attachmentEntities = dao.getAttachmentsForMessage(entity.id)
+ val attachments = attachmentEntities.map { attEntity ->
+ Attachment(
+ id = attEntity.id,
+ filePath = attEntity.filePath,
+ mimeType = attEntity.mimeType,
+ fileName = attEntity.fileName,
+ iv = attEntity.iv
+ )
+ }
- while (cursor.moveToNext()) {
- val conversation = cursorToConversation(cursor)
- tempList.add(conversation)
- }
- cursor.close()
+ Conversation(
+ id = entity.id,
+ text = decryptedText,
+ isMe = entity.isMe,
+ isLoading = false,
+ category = entity.category,
+ contentURL = entity.contentURL,
+ navigationURI = entity.navigationURI,
+ attachments = attachments
+ )
+ }.toMutableList()
}
- return tempList
}
-
- private fun cursorToConversation(cursor: Cursor): Conversation {
- val properties = Conversation::class.memberProperties
- val fieldMap = mutableMapOf()
- val iv = cursor.getString(cursor.getColumnIndexOrThrow("iv"))
-
- properties.filter { it.name != "isLoading" }.forEach { property ->
- val columnName = property.name
- val columnIndex = cursor.getColumnIndexOrThrow(columnName)
- val value = when (property.returnType.toString()) {
- "kotlin.String" -> cursor.getString(columnIndex)//?.let { EncryptionUtil.decrypt(it, iv) }
- "kotlin.Int" -> cursor.getInt(columnIndex)
- "kotlin.Long" -> cursor.getLong(columnIndex)
- "kotlin.Boolean" -> cursor.getString(columnIndex).toBooleanStrictOrNull() ?: false
- "java.net.URI" -> URI(cursor.getString(columnIndex))
- else -> null
- }
- fieldMap[columnName] = value
- }
-
- return Conversation::class.constructors.first().callBy(
- Conversation::class.primaryConstructor!!.parameters
- .filter { it.name != "isLoading" } // Ignore "isLoading"
- .associateWith { parameter -> fieldMap[parameter.name] }
- )
- }
-
- private fun encryptConversation(conversation: Conversation): Pair |