commit 60865ce6292de1507e4515bcfbdf43c74509f8ee Author: ale Date: Sat Feb 28 01:40:45 2026 +0100 initial commit Signed-off-by: ale diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/README.md b/README.md new file mode 100644 index 0000000..49ff24f --- /dev/null +++ b/README.md @@ -0,0 +1,354 @@ +# AleJabber — XMPP/Jabber Client for Android + +[![Build Status](https://img.shields.io/badge/build-passing-brightgreen)](.) +[![API](https://img.shields.io/badge/API-24%2B-blue)](.) +[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) +[![Kotlin](https://img.shields.io/badge/Kotlin-2.0-purple)](https://kotlinlang.org) +[![Compose](https://img.shields.io/badge/Jetpack%20Compose-2024.09-blue)](https://developer.android.com/jetpack/compose) + +A modern, feature-rich XMPP/Jabber messaging client for Android built with **Jetpack Compose** and **Material Design 3**. AleJabber supports multiple accounts, end-to-end encryption (OTR, OMEMO, OpenPGP), multimedia file transfers via `http_upload`, in-app audio recording, group chat rooms, and full accessibility support. + +--- + +## Table of Contents + +1. [Features](#features) +2. [Screenshots](#screenshots) +3. [Architecture](#architecture) +4. [Project Structure](#project-structure) +5. [Getting Started](#getting-started) +6. [Configuration](#configuration) +7. [Encryption](#encryption) +8. [Multimedia & Audio](#multimedia--audio) +9. [Internationalization](#internationalization) +10. [Accessibility](#accessibility) +11. [Dependencies](#dependencies) +12. [Contributing](#contributing) +13. [License](#license) + +--- + +## Features + +| Feature | Description | +|---------|-------------| +| 🔐 **OTR Encryption** | Off-the-Record messaging with perfect forward secrecy | +| 🔒 **OMEMO Encryption** | XEP-0384 multi-device end-to-end encryption (recommended) | +| 🗝️ **OpenPGP** | Asymmetric PGP encryption via XEP-0373/0374 | +| 👥 **Multi-Account** | Manage multiple XMPP accounts from different servers | +| 💬 **Group Rooms (MUC)** | Join and manage Multi-User Chat rooms (XEP-0045) | +| 📎 **File Transfer** | Upload images, audio, and files via XEP-0363 `http_upload` | +| 🎙️ **Audio Messages** | Record and send voice messages directly from the app | +| 🔔 **Smart Notifications** | Per-account notification channels with vibration/sound control | +| 🌐 **Multilingual** | English 🇬🇧, Spanish 🇪🇸, Chinese 🇨🇳 | +| ♿ **Accessible** | Full TalkBack support with content descriptions and semantic roles | +| 🎨 **Material You** | Dynamic theming with Light/Dark/System modes | +| 🔄 **Auto-Reconnect** | Automatic reconnection with random increasing delay policy | +| 💾 **Offline Storage** | Room database caches messages for offline reading | + +--- + +## Screenshots + +> _Screenshots to be added after first device deployment._ + +--- + +## Architecture + +AleJabber follows **Clean Architecture** with an **MVVM** presentation layer: + +``` +┌─────────────────────────────────────────────────────┐ +│ Presentation Layer │ +│ Compose Screens ←→ ViewModels ←→ UI State │ +├─────────────────────────────────────────────────────┤ +│ Domain Layer │ +│ Models · Use Cases · Repository Interfaces │ +├─────────────────────────────────────────────────────┤ +│ Data Layer │ +│ Room DB · Smack XMPP · DataStore · OkHttp │ +└─────────────────────────────────────────────────────┘ +``` + +### Key Patterns + +- **Dependency Injection**: Hilt (Dagger-based) +- **Reactive Streams**: Kotlin `Flow` + `StateFlow` + `SharedFlow` +- **Navigation**: Jetpack Navigation Compose with type-safe routes +- **Background Work**: `XmppForegroundService` keeps connections alive +- **Persistence**: Room with KSP-generated DAOs + +--- + +## Project Structure + +``` +app/src/main/java/com/manalejandro/alejabber/ +├── AleJabberApp.kt # Application class with Hilt initialization +├── MainActivity.kt # Single-Activity entry point +│ +├── data/ +│ ├── local/ +│ │ ├── AppDatabase.kt # Room database definition +│ │ ├── dao/ # Data Access Objects (AccountDao, MessageDao, ContactDao, RoomDao) +│ │ └── entity/ # Room entities (AccountEntity, MessageEntity, etc.) +│ ├── remote/ +│ │ └── XmppConnectionManager.kt # Smack connection lifecycle manager +│ └── repository/ +│ ├── AccountRepository.kt +│ ├── ContactRepository.kt +│ ├── MessageRepository.kt +│ └── RoomRepository.kt +│ +├── domain/ +│ └── model/ # Pure Kotlin domain models +│ ├── Account.kt # XMPP account model +│ ├── Contact.kt # Roster contact +│ ├── Message.kt # Chat message with encryption + media metadata +│ ├── Room.kt # MUC room +│ └── Enums.kt # EncryptionType, MessageStatus, PresenceStatus, etc. +│ +├── di/ +│ └── AppModule.kt # Hilt module: DB, OkHttp, DataStore, XmppManager +│ +├── media/ +│ ├── AudioRecorder.kt # MediaRecorder wrapper with StateFlow +│ └── HttpUploadManager.kt # XEP-0363 file upload via OkHttp +│ +├── service/ +│ ├── XmppForegroundService.kt # Foreground service keeping XMPP alive +│ └── BootReceiver.kt # BroadcastReceiver to restart on boot +│ +└── ui/ + ├── theme/ + │ ├── Color.kt # Brand colors + bubble colors + │ ├── Theme.kt # Material3 dynamic theme with AppTheme enum + │ └── Type.kt # Typography scale + ├── navigation/ + │ ├── Screen.kt # Sealed class route definitions + │ └── AleJabberNavGraph.kt # NavHost with all destinations + ├── components/ + │ ├── AvatarWithStatus.kt # Avatar + presence dot component + │ └── EncryptionBadge.kt # Encryption type indicator badge + ├── accounts/ + │ ├── AccountsScreen.kt # Account list with connect/disconnect + │ ├── AccountsViewModel.kt + │ └── AddEditAccountScreen.kt # Add/edit XMPP account form + ├── contacts/ + │ ├── ContactsScreen.kt # Roster list with search + presence + │ └── ContactsViewModel.kt + ├── rooms/ + │ ├── RoomsScreen.kt # MUC rooms list + │ └── RoomsViewModel.kt + ├── chat/ + │ ├── ChatScreen.kt # Full chat UI with bubbles, media, recording + │ └── ChatViewModel.kt + └── settings/ + ├── SettingsScreen.kt # App preferences + └── SettingsViewModel.kt +``` + +--- + +## Getting Started + +### Prerequisites + +- Android Studio Hedgehog (2023.1.1) or later +- JDK 11+ +- Android SDK 36 +- A running XMPP server (e.g., [ejabberd](https://www.ejabberd.im/), [Prosody](https://prosody.im/), [Openfire](https://www.igniterealtime.org/projects/openfire/)) + +### Build + +```bash +# Clone the repository +git clone https://github.com/manalejandro/AleJabber.git +cd AleJabber + +# Build debug APK +./gradlew assembleDebug + +# Install on connected device +./gradlew installDebug + +# Run unit tests +./gradlew test + +# Run instrumented tests +./gradlew connectedAndroidTest +``` + +The debug APK will be at: +``` +app/build/outputs/apk/debug/app-debug.apk +``` + +--- + +## Configuration + +### Adding an XMPP Account + +1. Open the app → tap **Accounts** tab +2. Press the **+** FAB +3. Fill in: + - **JID** — your full Jabber ID, e.g. `user@jabber.org` + - **Password** — your account password + - **Server** _(optional)_ — override DNS-resolved hostname + - **Port** _(default: 5222)_ — custom port if needed + - **TLS** — toggle to require TLS (recommended) + - **Resource** _(default: AleJabber)_ — client resource identifier + +### Gradle Properties + +`gradle.properties` contains build-time flags: + +| Property | Default | Description | +|----------|---------|-------------| +| `android.disallowKotlinSourceSets` | `false` | Required for KSP + AGP 9.x compatibility | +| `org.gradle.jvmargs` | `-Xmx2048m` | Gradle daemon heap size | +| `android.useAndroidX` | `true` | AndroidX migration flag | + +--- + +## Encryption + +AleJabber supports three levels of end-to-end encryption, selectable per conversation: + +### OMEMO (Recommended — XEP-0384) +- Multi-device, forward-secrecy encryption based on the Signal Protocol +- Works even when the recipient is offline +- Select **OMEMO** in the encryption picker (🔒 icon in chat toolbar) + +### OTR (Off-the-Record — XEP-0364) +- Classic two-party encryption with perfect forward secrecy +- Requires both parties to be online simultaneously +- Best for high-privacy one-on-one conversations + +### OpenPGP (XEP-0373/0374) +- Asymmetric RSA/ECC encryption using PGP key pairs +- Works offline; keys must be exchanged in advance +- Uses Bouncy Castle (`bcpg-jdk18on`, `bcprov-jdk18on`) + +### None (Plain Text) +- Messages are sent unencrypted over the TLS-secured XMPP stream +- Only use on trusted, private servers + +--- + +## Multimedia & Audio + +### File Upload (XEP-0363 `http_upload`) +1. Tap the 📎 attach button in the chat input +2. Select any file from the device storage +3. AleJabber requests an upload slot from the XMPP server +4. The file is PUT to the provided URL via OkHttp +5. The download URL is sent as a message body +6. Images are auto-rendered inline in the chat bubble + +### Audio Messages +1. In the chat input, press and hold the 🎙️ microphone button +2. Speak your message — a recording timer appears +3. Release to **send**, or tap **✕** to cancel +4. Audio is recorded with `MediaRecorder` (AAC/MP4 format) +5. The recording is uploaded via `http_upload` automatically + +> **Note:** Microphone permission (`RECORD_AUDIO`) is requested on first use. + +--- + +## Internationalization + +AleJabber ships with three locale bundles: + +| Locale | File | +|--------|------| +| English (default) | `app/src/main/res/values/strings.xml` | +| Spanish | `app/src/main/res/values-es/strings.xml` | +| Chinese (Simplified) | `app/src/main/res/values-zh/strings.xml` | + +To add a new language: +1. Create `app/src/main/res/values-/strings.xml` +2. Copy all keys from the default `strings.xml` +3. Translate each string value + +--- + +## Accessibility + +AleJabber is designed to be fully usable with Android's TalkBack screen reader: + +- All interactive elements have `contentDescription` labels +- Message status icons (sent, delivered, read) announce their state +- Recording timer is announced to screen readers +- Encryption badge announces the current encryption type +- Avatar components announce contact name and presence status +- The app passes the [Accessibility Scanner](https://support.google.com/accessibility/android/answer/6376559) basic checks + +--- + +## Dependencies + +| Library | Version | Purpose | +|---------|---------|---------| +| Jetpack Compose BOM | 2024.09.00 | UI framework | +| Material3 | (via BOM) | Design system | +| Hilt | 2.59.2 | Dependency injection | +| Room | 2.7.0 | Local database | +| Navigation Compose | 2.9.0 | In-app navigation | +| Smack (XMPP) | 4.4.8 | XMPP protocol implementation | +| OkHttp | 4.12.0 | HTTP file uploads | +| Coil | 2.7.0 | Image loading | +| DataStore | 1.1.1 | Settings persistence | +| Accompanist Permissions | 0.36.0 | Runtime permission handling | +| Bouncy Castle | 1.78.1 | OpenPGP crypto | +| Coroutines | 1.9.0 | Async/reactive programming | + +--- + +## Contributing + +Contributions are welcome! Please follow these steps: + +1. Fork the repository +2. Create a feature branch: `git checkout -b feature/my-feature` +3. Commit your changes: `git commit -m "feat: add my feature"` +4. Push to the branch: `git push origin feature/my-feature` +5. Open a Pull Request + +### Code Style +- Follow [Kotlin coding conventions](https://kotlinlang.org/docs/coding-conventions.html) +- Use `ktlint` for formatting: `./gradlew ktlintCheck` +- Write KDoc comments for all public functions and classes +- Add unit tests for ViewModels and Repository classes + +--- + +## License + +``` +MIT License + +Copyright (c) 2026 Manuel Alejandro + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..997bee8 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,119 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} +android { + namespace = "com.manalejandro.alejabber" + compileSdk = 36 + defaultConfig { + applicationId = "com.manalejandro.alejabber" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + } + packaging { + resources { + excludes += setOf( + "META-INF/DEPENDENCIES", + "META-INF/LICENSE", + "META-INF/LICENSE.txt", + "META-INF/license.txt", + "META-INF/NOTICE", + "META-INF/NOTICE.txt", + "META-INF/notice.txt", + "META-INF/*.kotlin_module", + "META-INF/INDEX.LIST", + "META-INF/io.netty.versions.properties", + "META-INF/AL2.0", + "META-INF/LGPL2.1", + "mozilla/public-suffix-list.txt" + ) + } + } +} +configurations.all { + resolutionStrategy { + force("org.bouncycastle:bcprov-jdk18on:1.78.1") + force("org.bouncycastle:bcpg-jdk18on:1.78.1") + } + // Exclude old BouncyCastle and duplicate xpp3 versions + exclude(group = "org.bouncycastle", module = "bcprov-jdk15on") + exclude(group = "org.bouncycastle", module = "bcpg-jdk15on") + exclude(group = "xpp3", module = "xpp3") +} +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.material.icons.extended) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.hilt.navigation.compose) + implementation(libs.room.runtime) + implementation(libs.room.ktx) + ksp(libs.room.compiler) + implementation(libs.navigation.compose) + implementation(libs.datastore.preferences) + implementation(libs.coil.compose) + implementation(libs.smack.android) + implementation(libs.smack.android.extensions) { + exclude(group = "org.bouncycastle") + } + implementation(libs.smack.tcp) { + exclude(group = "org.bouncycastle") + } + implementation(libs.smack.im) { + exclude(group = "org.bouncycastle") + } + implementation(libs.smack.extensions) { + exclude(group = "org.bouncycastle") + } + implementation(libs.smack.omemo) { + exclude(group = "org.bouncycastle") + } + implementation(libs.smack.omemo.signal) { + exclude(group = "org.bouncycastle") + } + implementation(libs.smack.openpgp) { + exclude(group = "org.bouncycastle") + } + implementation(libs.okhttp) + implementation(libs.coroutines.android) + implementation(libs.lifecycle.viewmodel.compose) + implementation(libs.lifecycle.runtime.compose) + implementation(libs.bouncycastle.bcpg) + implementation(libs.bouncycastle.bcprov) + implementation(libs.accompanist.permissions) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/manalejandro/alejabber/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/manalejandro/alejabber/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..26506ad --- /dev/null +++ b/app/src/androidTest/java/com/manalejandro/alejabber/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.manalejandro.alejabber + +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.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.manalejandro.alejabber", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1d41d0d --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/ic_launcher_logo.svg b/app/src/main/assets/ic_launcher_logo.svg new file mode 100644 index 0000000..286d666 --- /dev/null +++ b/app/src/main/assets/ic_launcher_logo.svg @@ -0,0 +1,72 @@ + + + + + AleJabber + XMPP/Jabber client icon: a speech bubble with a lightning bolt symbolising fast, secure messaging. + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..c2319c1 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/manalejandro/alejabber/AleJabberApp.kt b/app/src/main/java/com/manalejandro/alejabber/AleJabberApp.kt new file mode 100644 index 0000000..0c0f99d --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/AleJabberApp.kt @@ -0,0 +1,49 @@ +package com.manalejandro.alejabber + +import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import android.os.Build +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class AleJabberApp : Application() { + + override fun onCreate() { + super.onCreate() + createNotificationChannels() + } + + private fun createNotificationChannels() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val nm = getSystemService(NotificationManager::class.java) + + val messagesChannel = NotificationChannel( + CHANNEL_MESSAGES, + getString(R.string.notification_channel_messages), + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = getString(R.string.notification_channel_messages_desc) + enableVibration(true) + } + + val serviceChannel = NotificationChannel( + CHANNEL_SERVICE, + getString(R.string.notification_channel_service), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = getString(R.string.notification_channel_service_desc) + setShowBadge(false) + } + + nm.createNotificationChannels(listOf(messagesChannel, serviceChannel)) + } + } + + companion object { + const val CHANNEL_MESSAGES = "channel_messages" + const val CHANNEL_SERVICE = "channel_service" + const val NOTIFICATION_ID_SERVICE = 1001 + } +} + diff --git a/app/src/main/java/com/manalejandro/alejabber/MainActivity.kt b/app/src/main/java/com/manalejandro/alejabber/MainActivity.kt new file mode 100644 index 0000000..e94bab5 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/MainActivity.kt @@ -0,0 +1,144 @@ +package com.manalejandro.alejabber + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Forum +import androidx.compose.material.icons.filled.ManageAccounts +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.outlined.Forum +import androidx.compose.material.icons.outlined.ManageAccounts +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.core.content.ContextCompat +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.manalejandro.alejabber.service.XmppForegroundService +import com.manalejandro.alejabber.ui.navigation.AleJabberNavGraph +import com.manalejandro.alejabber.ui.navigation.Screen +import com.manalejandro.alejabber.ui.theme.AleJabberTheme +import com.manalejandro.alejabber.ui.theme.AppTheme +import dagger.hilt.android.AndroidEntryPoint + +/** + * Single-Activity entry point. + * + * Navigation structure: + * Accounts (home) ──► Contacts(accountId) ──► Chat + * ──► AddEditAccount + * Rooms ──► Chat + * Settings + */ +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + startXmppService() + setContent { + AleJabberTheme(appTheme = AppTheme.SYSTEM) { + MainAppContent() + } + } + } + + private fun startXmppService() { + val intent = Intent(this, XmppForegroundService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ContextCompat.startForegroundService(this, intent) + } else { + startService(intent) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainAppContent() { + val navController = rememberNavController() + val navBackStack by navController.currentBackStackEntryAsState() + val currentDestination = navBackStack?.destination + + // Bottom nav items: Accounts | Rooms | Settings + // Contacts is NOT in the bottom nav — it's a drill-down from an account. + val bottomNavItems = listOf( + BottomNavItem( + route = Screen.Accounts.route, + labelRes = R.string.nav_accounts, + selectedIcon = Icons.Filled.ManageAccounts, + unselectedIcon = Icons.Outlined.ManageAccounts + ), + BottomNavItem( + route = Screen.Rooms.route, + labelRes = R.string.nav_rooms, + selectedIcon = Icons.Filled.Forum, + unselectedIcon = Icons.Outlined.Forum + ), + BottomNavItem( + route = Screen.Settings.route, + labelRes = R.string.nav_settings, + selectedIcon = Icons.Filled.Settings, + unselectedIcon = Icons.Outlined.Settings + ) + ) + + // Show bottom nav only on the three top-level destinations + val topLevelRoutes = setOf(Screen.Accounts.route, Screen.Rooms.route, Screen.Settings.route) + val showBottomBar = currentDestination?.hierarchy?.any { it.route in topLevelRoutes } == true + + Scaffold( + modifier = Modifier.fillMaxSize(), + bottomBar = { + if (showBottomBar) { + NavigationBar { + bottomNavItems.forEach { item -> + val selected = currentDestination?.hierarchy?.any { it.route == item.route } == true + NavigationBarItem( + selected = selected, + onClick = { + navController.navigate(item.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + icon = { + Icon( + if (selected) item.selectedIcon else item.unselectedIcon, + contentDescription = stringResource(item.labelRes) + ) + }, + label = { Text(stringResource(item.labelRes)) } + ) + } + } + } + } + ) { _ -> + AleJabberNavGraph( + navController = navController, + startDestination = Screen.Accounts.route + ) + } +} + +/** Item descriptor for the bottom navigation bar. */ +data class BottomNavItem( + val route: String, + val labelRes: Int, + val selectedIcon: androidx.compose.ui.graphics.vector.ImageVector, + val unselectedIcon: androidx.compose.ui.graphics.vector.ImageVector +) diff --git a/app/src/main/java/com/manalejandro/alejabber/data/local/AppDatabase.kt b/app/src/main/java/com/manalejandro/alejabber/data/local/AppDatabase.kt new file mode 100644 index 0000000..bc5b873 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/data/local/AppDatabase.kt @@ -0,0 +1,67 @@ +package com.manalejandro.alejabber.data.local + +import androidx.room.Database +import androidx.room.migration.Migration +import androidx.room.RoomDatabase +import androidx.sqlite.db.SupportSQLiteDatabase +import com.manalejandro.alejabber.data.local.dao.AccountDao +import com.manalejandro.alejabber.data.local.dao.ContactDao +import com.manalejandro.alejabber.data.local.dao.MessageDao +import com.manalejandro.alejabber.data.local.dao.RoomDao +import com.manalejandro.alejabber.data.local.entity.AccountEntity +import com.manalejandro.alejabber.data.local.entity.ContactEntity +import com.manalejandro.alejabber.data.local.entity.MessageEntity +import com.manalejandro.alejabber.data.local.entity.RoomEntity + +@Database( + entities = [ + AccountEntity::class, + ContactEntity::class, + MessageEntity::class, + RoomEntity::class + ], + version = 2, + exportSchema = false +) +abstract class AppDatabase : RoomDatabase() { + abstract fun accountDao(): AccountDao + abstract fun contactDao(): ContactDao + abstract fun messageDao(): MessageDao + abstract fun roomDao(): RoomDao + + companion object { + /** + * Migration 1 → 2: + * + * Adds a unique composite index on (accountId, jid) in the contacts table. + * Before creating the index, existing duplicate rows (same accountId + jid) + * are removed, keeping only the row with the highest presence rank (most available). + * + * This fixes the crash: + * java.lang.IllegalArgumentException: Key "" was already used + * that occurred in ContactsScreen's LazyColumn when the roster sync + * inserted duplicate rows for the same contact. + */ + val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + // 1. Remove duplicates — keep only the row with the lowest id (oldest entry) + // for each (accountId, jid) pair. + db.execSQL( + """ + DELETE FROM contacts + WHERE id NOT IN ( + SELECT MIN(id) + FROM contacts + GROUP BY accountId, jid + ) + """.trimIndent() + ) + // 2. Create the unique composite index. + db.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS index_contacts_accountId_jid " + + "ON contacts (accountId, jid)" + ) + } + } + } +} diff --git a/app/src/main/java/com/manalejandro/alejabber/data/local/dao/AccountDao.kt b/app/src/main/java/com/manalejandro/alejabber/data/local/dao/AccountDao.kt new file mode 100644 index 0000000..49cb307 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/data/local/dao/AccountDao.kt @@ -0,0 +1,30 @@ +package com.manalejandro.alejabber.data.local.dao + +import androidx.room.* +import com.manalejandro.alejabber.data.local.entity.AccountEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface AccountDao { + @Query("SELECT * FROM accounts ORDER BY jid ASC") + fun getAllAccounts(): Flow> + + @Query("SELECT * FROM accounts WHERE id = :id") + suspend fun getAccountById(id: Long): AccountEntity? + + @Query("SELECT * FROM accounts WHERE isEnabled = 1") + fun getEnabledAccounts(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAccount(account: AccountEntity): Long + + @Update + suspend fun updateAccount(account: AccountEntity) + + @Delete + suspend fun deleteAccount(account: AccountEntity) + + @Query("DELETE FROM accounts WHERE id = :id") + suspend fun deleteAccountById(id: Long) +} + diff --git a/app/src/main/java/com/manalejandro/alejabber/data/local/dao/ContactDao.kt b/app/src/main/java/com/manalejandro/alejabber/data/local/dao/ContactDao.kt new file mode 100644 index 0000000..53461ad --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/data/local/dao/ContactDao.kt @@ -0,0 +1,36 @@ +package com.manalejandro.alejabber.data.local.dao + +import androidx.room.* +import com.manalejandro.alejabber.data.local.entity.ContactEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ContactDao { + @Query("SELECT * FROM contacts WHERE accountId = :accountId ORDER BY nickname ASC, jid ASC") + fun getContactsByAccount(accountId: Long): Flow> + + @Query("SELECT * FROM contacts WHERE accountId = :accountId AND jid = :jid LIMIT 1") + suspend fun getContact(accountId: Long, jid: String): ContactEntity? + + @Query("SELECT * FROM contacts WHERE accountId = :accountId AND (jid LIKE '%' || :query || '%' OR nickname LIKE '%' || :query || '%')") + fun searchContacts(accountId: Long, query: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertContact(contact: ContactEntity): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertContacts(contacts: List) + + @Update + suspend fun updateContact(contact: ContactEntity) + + @Delete + suspend fun deleteContact(contact: ContactEntity) + + @Query("DELETE FROM contacts WHERE accountId = :accountId AND jid = :jid") + suspend fun deleteContact(accountId: Long, jid: String) + + @Query("UPDATE contacts SET presence = :presence, statusMessage = :statusMessage WHERE accountId = :accountId AND jid = :jid") + suspend fun updatePresence(accountId: Long, jid: String, presence: String, statusMessage: String) +} + diff --git a/app/src/main/java/com/manalejandro/alejabber/data/local/dao/MessageDao.kt b/app/src/main/java/com/manalejandro/alejabber/data/local/dao/MessageDao.kt new file mode 100644 index 0000000..6426366 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/data/local/dao/MessageDao.kt @@ -0,0 +1,36 @@ +package com.manalejandro.alejabber.data.local.dao + +import androidx.room.* +import com.manalejandro.alejabber.data.local.entity.MessageEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface MessageDao { + @Query("SELECT * FROM messages WHERE accountId = :accountId AND conversationJid = :conversationJid AND isDeleted = 0 ORDER BY timestamp ASC") + fun getMessages(accountId: Long, conversationJid: String): Flow> + + @Query("SELECT * FROM messages WHERE accountId = :accountId AND conversationJid = :conversationJid ORDER BY timestamp DESC LIMIT 1") + suspend fun getLastMessage(accountId: Long, conversationJid: String): MessageEntity? + + @Query("SELECT COUNT(*) FROM messages WHERE accountId = :accountId AND conversationJid = :conversationJid AND isRead = 0 AND direction = 'INCOMING'") + fun getUnreadCount(accountId: Long, conversationJid: String): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertMessage(message: MessageEntity): Long + + @Update + suspend fun updateMessage(message: MessageEntity) + + @Query("UPDATE messages SET isRead = 1 WHERE accountId = :accountId AND conversationJid = :conversationJid AND direction = 'INCOMING'") + suspend fun markAllAsRead(accountId: Long, conversationJid: String) + + @Query("UPDATE messages SET status = :status WHERE id = :id") + suspend fun updateStatus(id: Long, status: String) + + @Query("UPDATE messages SET isDeleted = 1 WHERE id = :id") + suspend fun deleteMessage(id: Long) + + @Query("DELETE FROM messages WHERE accountId = :accountId AND conversationJid = :conversationJid") + suspend fun clearConversation(accountId: Long, conversationJid: String) +} + diff --git a/app/src/main/java/com/manalejandro/alejabber/data/local/dao/RoomDao.kt b/app/src/main/java/com/manalejandro/alejabber/data/local/dao/RoomDao.kt new file mode 100644 index 0000000..cda8a5c --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/data/local/dao/RoomDao.kt @@ -0,0 +1,36 @@ +package com.manalejandro.alejabber.data.local.dao + +import androidx.room.* +import com.manalejandro.alejabber.data.local.entity.RoomEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface RoomDao { + @Query("SELECT * FROM rooms WHERE accountId = :accountId ORDER BY isFavorite DESC, lastMessageTime DESC") + fun getRoomsByAccount(accountId: Long): Flow> + + @Query("SELECT * FROM rooms WHERE accountId = :accountId AND isJoined = 1") + fun getJoinedRooms(accountId: Long): Flow> + + @Query("SELECT * FROM rooms WHERE accountId = :accountId AND jid = :jid LIMIT 1") + suspend fun getRoom(accountId: Long, jid: String): RoomEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertRoom(room: RoomEntity): Long + + @Update + suspend fun updateRoom(room: RoomEntity) + + @Delete + suspend fun deleteRoom(room: RoomEntity) + + @Query("UPDATE rooms SET isJoined = :isJoined WHERE accountId = :accountId AND jid = :jid") + suspend fun updateJoinStatus(accountId: Long, jid: String, isJoined: Boolean) + + @Query("UPDATE rooms SET unreadCount = :count WHERE accountId = :accountId AND jid = :jid") + suspend fun updateUnreadCount(accountId: Long, jid: String, count: Int) + + @Query("UPDATE rooms SET lastMessage = :lastMessage, lastMessageTime = :time WHERE accountId = :accountId AND jid = :jid") + suspend fun updateLastMessage(accountId: Long, jid: String, lastMessage: String, time: Long) +} + diff --git a/app/src/main/java/com/manalejandro/alejabber/data/local/entity/AccountEntity.kt b/app/src/main/java/com/manalejandro/alejabber/data/local/entity/AccountEntity.kt new file mode 100644 index 0000000..7b50847 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/data/local/entity/AccountEntity.kt @@ -0,0 +1,19 @@ +package com.manalejandro.alejabber.data.local.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "accounts") +data class AccountEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val jid: String, + val password: String, + val server: String = "", + val port: Int = 5222, + val useTls: Boolean = true, + val resource: String = "AleJabber", + val isEnabled: Boolean = true, + val statusMessage: String = "", + val avatarUrl: String? = null +) + diff --git a/app/src/main/java/com/manalejandro/alejabber/data/local/entity/ContactEntity.kt b/app/src/main/java/com/manalejandro/alejabber/data/local/entity/ContactEntity.kt new file mode 100644 index 0000000..e234c46 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/data/local/entity/ContactEntity.kt @@ -0,0 +1,41 @@ +package com.manalejandro.alejabber.data.local.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Room entity representing an XMPP roster contact. + * + * The composite unique index on (accountId, jid) ensures that calling + * [ContactDao.insertContact] / [ContactDao.insertContacts] with + * [OnConflictStrategy.REPLACE] updates an existing row rather than + * inserting a duplicate — which would cause LazyColumn key-collision crashes. + */ +@Entity( + tableName = "contacts", + foreignKeys = [ForeignKey( + entity = AccountEntity::class, + parentColumns = ["id"], + childColumns = ["accountId"], + onDelete = ForeignKey.CASCADE + )], + indices = [ + Index("accountId"), + // Unique composite index — prevents duplicate (account, jid) pairs + Index(value = ["accountId", "jid"], unique = true) + ] +) +data class ContactEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val accountId: Long, + val jid: String, + val nickname: String = "", + val groups: String = "", // JSON array + val presence: String = "OFFLINE", + val statusMessage: String = "", + val avatarUrl: String? = null, + val isBlocked: Boolean = false, + val subscriptionState: String = "NONE" +) diff --git a/app/src/main/java/com/manalejandro/alejabber/data/local/entity/Mappers.kt b/app/src/main/java/com/manalejandro/alejabber/data/local/entity/Mappers.kt new file mode 100644 index 0000000..3d6433e --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/data/local/entity/Mappers.kt @@ -0,0 +1,70 @@ +package com.manalejandro.alejabber.data.local.entity + +import com.manalejandro.alejabber.domain.model.* + +fun AccountEntity.toDomain(status: ConnectionStatus = ConnectionStatus.OFFLINE) = Account( + id = id, jid = jid, password = password, server = server, port = port, + useTls = useTls, resource = resource, isEnabled = isEnabled, + status = status, statusMessage = statusMessage, avatarUrl = avatarUrl +) + +fun Account.toEntity() = AccountEntity( + id = id, jid = jid, password = password, server = server, port = port, + useTls = useTls, resource = resource, isEnabled = isEnabled, + statusMessage = statusMessage, avatarUrl = avatarUrl +) + +fun ContactEntity.toDomain() = Contact( + id = id, accountId = accountId, jid = jid, nickname = nickname, + groups = groups.split(",").filter { it.isNotBlank() }, + presence = try { PresenceStatus.valueOf(presence) } catch (e: Exception) { PresenceStatus.OFFLINE }, + statusMessage = statusMessage, avatarUrl = avatarUrl, isBlocked = isBlocked, + subscriptionState = try { SubscriptionState.valueOf(subscriptionState) } catch (e: Exception) { SubscriptionState.NONE } +) + +fun Contact.toEntity() = ContactEntity( + id = id, accountId = accountId, jid = jid, nickname = nickname, + groups = groups.joinToString(","), presence = presence.name, + statusMessage = statusMessage, avatarUrl = avatarUrl, isBlocked = isBlocked, + subscriptionState = subscriptionState.name +) + +fun MessageEntity.toDomain() = Message( + id = id, stanzaId = stanzaId, accountId = accountId, + conversationJid = conversationJid, fromJid = fromJid, toJid = toJid, + body = body, timestamp = timestamp, + direction = try { MessageDirection.valueOf(direction) } catch (e: Exception) { MessageDirection.INCOMING }, + status = try { MessageStatus.valueOf(status) } catch (e: Exception) { MessageStatus.PENDING }, + encryptionType = try { EncryptionType.valueOf(encryptionType) } catch (e: Exception) { EncryptionType.NONE }, + mediaType = try { MediaType.valueOf(mediaType) } catch (e: Exception) { MediaType.TEXT }, + mediaUrl = mediaUrl, mediaLocalPath = mediaLocalPath, mediaMimeType = mediaMimeType, + mediaSize = mediaSize, mediaName = mediaName, audioDurationMs = audioDurationMs, + isRead = isRead, isEdited = isEdited, isDeleted = isDeleted, replyToId = replyToId +) + +fun Message.toEntity() = MessageEntity( + id = id, stanzaId = stanzaId, accountId = accountId, + conversationJid = conversationJid, fromJid = fromJid, toJid = toJid, + body = body, timestamp = timestamp, direction = direction.name, status = status.name, + encryptionType = encryptionType.name, mediaType = mediaType.name, + mediaUrl = mediaUrl, mediaLocalPath = mediaLocalPath, mediaMimeType = mediaMimeType, + mediaSize = mediaSize, mediaName = mediaName, audioDurationMs = audioDurationMs, + isRead = isRead, isEdited = isEdited, isDeleted = isDeleted, replyToId = replyToId +) + +fun RoomEntity.toDomain() = Room( + id = id, accountId = accountId, jid = jid, nickname = nickname, name = name, + description = description, topic = topic, password = password, + isJoined = isJoined, isFavorite = isFavorite, participantCount = participantCount, + avatarUrl = avatarUrl, unreadCount = unreadCount, lastMessage = lastMessage, + lastMessageTime = lastMessageTime +) + +fun Room.toEntity() = RoomEntity( + id = id, accountId = accountId, jid = jid, nickname = nickname, name = name, + description = description, topic = topic, password = password, + isJoined = isJoined, isFavorite = isFavorite, participantCount = participantCount, + avatarUrl = avatarUrl, unreadCount = unreadCount, lastMessage = lastMessage, + lastMessageTime = lastMessageTime +) + diff --git a/app/src/main/java/com/manalejandro/alejabber/data/local/entity/MessageEntity.kt b/app/src/main/java/com/manalejandro/alejabber/data/local/entity/MessageEntity.kt new file mode 100644 index 0000000..9b7557f --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/data/local/entity/MessageEntity.kt @@ -0,0 +1,42 @@ +package com.manalejandro.alejabber.data.local.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "messages", + foreignKeys = [ForeignKey( + entity = AccountEntity::class, + parentColumns = ["id"], + childColumns = ["accountId"], + onDelete = ForeignKey.CASCADE + )], + indices = [Index("accountId"), Index("conversationJid"), Index("timestamp")] +) +data class MessageEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val stanzaId: String = "", + val accountId: Long, + val conversationJid: String, + val fromJid: String, + val toJid: String, + val body: String = "", + val timestamp: Long = System.currentTimeMillis(), + val direction: String, // INCOMING / OUTGOING + val status: String = "PENDING", + val encryptionType: String = "NONE", + val mediaType: String = "TEXT", + val mediaUrl: String? = null, + val mediaLocalPath: String? = null, + val mediaMimeType: String? = null, + val mediaSize: Long = 0, + val mediaName: String? = null, + val audioDurationMs: Long = 0, + val isRead: Boolean = false, + val isEdited: Boolean = false, + val isDeleted: Boolean = false, + val replyToId: Long? = null +) + diff --git a/app/src/main/java/com/manalejandro/alejabber/data/local/entity/RoomEntity.kt b/app/src/main/java/com/manalejandro/alejabber/data/local/entity/RoomEntity.kt new file mode 100644 index 0000000..79e1ac9 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/data/local/entity/RoomEntity.kt @@ -0,0 +1,35 @@ +package com.manalejandro.alejabber.data.local.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "rooms", + foreignKeys = [ForeignKey( + entity = AccountEntity::class, + parentColumns = ["id"], + childColumns = ["accountId"], + onDelete = ForeignKey.CASCADE + )], + indices = [Index("accountId")] +) +data class RoomEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val accountId: Long, + val jid: String, + val nickname: String = "", + val name: String = "", + val description: String = "", + val topic: String = "", + val password: String = "", + val isJoined: Boolean = false, + val isFavorite: Boolean = false, + val participantCount: Int = 0, + val avatarUrl: String? = null, + val unreadCount: Int = 0, + val lastMessage: String = "", + val lastMessageTime: Long = 0 +) + diff --git a/app/src/main/java/com/manalejandro/alejabber/data/remote/XmppConnectionManager.kt b/app/src/main/java/com/manalejandro/alejabber/data/remote/XmppConnectionManager.kt new file mode 100644 index 0000000..ab59b7e --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/data/remote/XmppConnectionManager.kt @@ -0,0 +1,290 @@ +package com.manalejandro.alejabber.data.remote + +import android.util.Log +import com.manalejandro.alejabber.domain.model.Account +import com.manalejandro.alejabber.domain.model.ConnectionStatus +import com.manalejandro.alejabber.domain.model.PresenceStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jivesoftware.smack.AbstractXMPPConnection +import org.jivesoftware.smack.ConnectionConfiguration +import org.jivesoftware.smack.ReconnectionManager +import org.jivesoftware.smack.SmackException +import org.jivesoftware.smack.XMPPException +import org.jivesoftware.smack.chat2.ChatManager +import org.jivesoftware.smack.packet.Presence +import org.jivesoftware.smack.roster.Roster +import org.jivesoftware.smack.roster.RosterEntry +import org.jivesoftware.smack.roster.RosterListener +import org.jivesoftware.smack.tcp.XMPPTCPConnection +import org.jivesoftware.smack.tcp.XMPPTCPConnectionConfiguration +import org.jxmpp.jid.Jid +import org.jxmpp.jid.impl.JidCreate +import org.jxmpp.jid.parts.Resourcepart +import javax.inject.Inject +import javax.inject.Singleton + +data class IncomingMessage( + val accountId: Long, + val from: String, + val body: String, + val timestamp: Long = System.currentTimeMillis() +) + +data class PresenceUpdate( + val accountId: Long, + val jid: String, + val status: PresenceStatus, + val statusMessage: String +) + +@Singleton +class XmppConnectionManager @Inject constructor() { + + private val TAG = "XmppConnectionManager" + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val connections = mutableMapOf() + + // ── Account connection status ───────────────────────────────────────── + private val _connectionStatus = MutableStateFlow>(emptyMap()) + val connectionStatus: StateFlow> = _connectionStatus.asStateFlow() + + // ── Incoming chat messages ──────────────────────────────────────────── + private val _incomingMessages = MutableSharedFlow() + val incomingMessages: SharedFlow = _incomingMessages.asSharedFlow() + + // ── Live presence map: accountId → (bareJid → PresenceStatus) ──────── + // Updated on every presence stanza. Consumers (ContactRepository) combine + // this with Room data so contacts show the correct online/away/offline state + // without having to write every presence change to the database. + private val _rosterPresence = + MutableStateFlow>>(emptyMap()) + val rosterPresence: StateFlow>> = + _rosterPresence.asStateFlow() + + // ── Presence updates (kept for backward compatibility) ──────────────── + private val _presenceUpdates = MutableSharedFlow(extraBufferCapacity = 64) + val presenceUpdates: SharedFlow = _presenceUpdates.asSharedFlow() + + // ───────────────────────────────────────────────────────────────────── + + fun connect(account: Account) { + scope.launch { + try { + updateStatus(account.id, ConnectionStatus.CONNECTING) + val config = buildConfig(account) + val connection = XMPPTCPConnection(config) + connections[account.id] = connection + + connection.connect() + connection.login() + + ReconnectionManager.getInstanceFor(connection).apply { + enableAutomaticReconnection() + setReconnectionPolicy(ReconnectionManager.ReconnectionPolicy.RANDOM_INCREASING_DELAY) + } + + setupRoster(account.id, connection) + setupMessageListener(account.id, connection) + updateStatus(account.id, ConnectionStatus.ONLINE) + Log.i(TAG, "Connected: ${account.jid}") + } catch (e: XMPPException) { + Log.e(TAG, "XMPP error for ${account.jid}", e) + updateStatus(account.id, ConnectionStatus.ERROR) + } catch (e: SmackException) { + Log.e(TAG, "Smack error for ${account.jid}", e) + updateStatus(account.id, ConnectionStatus.ERROR) + } catch (e: Exception) { + Log.e(TAG, "Connection error for ${account.jid}", e) + updateStatus(account.id, ConnectionStatus.ERROR) + } + } + } + + fun disconnect(accountId: Long) { + scope.launch { + try { + connections[accountId]?.disconnect() + connections.remove(accountId) + // Clear presence data for this account + _rosterPresence.update { it - accountId } + updateStatus(accountId, ConnectionStatus.OFFLINE) + } catch (e: Exception) { + Log.e(TAG, "Disconnect error", e) + } + } + } + + fun disconnectAll() { + connections.keys.toList().forEach { disconnect(it) } + } + + fun sendMessage(accountId: Long, toJid: String, body: String): Boolean { + return try { + val connection = connections[accountId] ?: return false + if (!connection.isConnected) return false + val chatManager = ChatManager.getInstanceFor(connection) + val jid = JidCreate.entityBareFrom(toJid) + val chat = chatManager.chatWith(jid) + chat.send(body) + true + } catch (e: Exception) { + Log.e(TAG, "Send message error", e) + false + } + } + + fun getRosterEntries(accountId: Long): List { + return try { + val connection = connections[accountId] ?: return emptyList() + val roster = Roster.getInstanceFor(connection) + roster.entries.toList() + } catch (e: Exception) { + Log.e(TAG, "Get roster error", e) + emptyList() + } + } + + fun isConnected(accountId: Long): Boolean = + connections[accountId]?.isConnected == true && + connections[accountId]?.isAuthenticated == true + + fun getConnection(accountId: Long): AbstractXMPPConnection? = connections[accountId] + + // ───────────────────────────────────────────────────────────────────── + + private fun buildConfig(account: Account): XMPPTCPConnectionConfiguration { + val jid = JidCreate.entityBareFrom(account.jid) + val builder = XMPPTCPConnectionConfiguration.builder() + .setUsernameAndPassword(jid.localpart.toString(), account.password) + .setXmppDomain(jid.asDomainBareJid()) + .setResource(Resourcepart.from(account.resource.ifBlank { "AleJabber" })) + .setConnectTimeout(30_000) + .setSendPresence(true) + + if (account.server.isNotBlank()) builder.setHost(account.server) + if (account.port != 5222) builder.setPort(account.port) + + builder.setSecurityMode( + if (account.useTls) ConnectionConfiguration.SecurityMode.required + else ConnectionConfiguration.SecurityMode.disabled + ) + return builder.build() + } + + private fun setupRoster(accountId: Long, connection: AbstractXMPPConnection) { + val roster = Roster.getInstanceFor(connection) + roster.isRosterLoadedAtLogin = true + + // ── Snapshot all current presences once the roster is loaded ────── + scope.launch { + try { + // Wait until Smack has fetched the roster from the server + roster.reloadAndWait() + val snapshot = mutableMapOf() + roster.entries.forEach { entry -> + val bareJid = entry.jid.asBareJid().toString() + val p = roster.getPresence(entry.jid.asBareJid()) + snapshot[bareJid] = p.toPresenceStatus() + } + _rosterPresence.update { current -> + current.toMutableMap().also { it[accountId] = snapshot } + } + Log.i(TAG, "Roster snapshot loaded for account $accountId: ${snapshot.size} contacts") + } catch (e: Exception) { + Log.w(TAG, "Roster snapshot failed", e) + } + } + + // ── Listen for live presence changes ────────────────────────────── + roster.addRosterListener(object : RosterListener { + override fun entriesAdded(addresses: MutableCollection?) { + // Refresh snapshot when new contacts are added + addresses?.forEach { jid -> + scope.launch { + try { + val bareJid = jid.asBareJid().toString() + val p = roster.getPresence(jid.asBareJid()) + updatePresenceInMap(accountId, bareJid, p.toPresenceStatus(), p.status ?: "") + } catch (_: Exception) {} + } + } + } + override fun entriesUpdated(addresses: MutableCollection?) {} + override fun entriesDeleted(addresses: MutableCollection?) {} + + override fun presenceChanged(presence: Presence) { + scope.launch { + val bareJid = presence.from?.asBareJid()?.toString() ?: return@launch + val presenceStatus = presence.toPresenceStatus() + val statusMsg = presence.status ?: "" + + // Update the in-memory map + updatePresenceInMap(accountId, bareJid, presenceStatus, statusMsg) + + Log.d(TAG, "Presence changed: $bareJid → $presenceStatus") + } + } + }) + } + + /** Updates the [_rosterPresence] map and emits a [PresenceUpdate]. */ + private suspend fun updatePresenceInMap( + accountId: Long, + bareJid: String, + status: PresenceStatus, + statusMsg: String + ) { + _rosterPresence.update { current -> + val accountMap = current[accountId]?.toMutableMap() ?: mutableMapOf() + accountMap[bareJid] = status + current.toMutableMap().also { it[accountId] = accountMap } + } + _presenceUpdates.emit( + PresenceUpdate(accountId, bareJid, status, statusMsg) + ) + } + + private fun setupMessageListener(accountId: Long, connection: AbstractXMPPConnection) { + val chatManager = ChatManager.getInstanceFor(connection) + chatManager.addIncomingListener { from, message, _ -> + val body = message.body ?: return@addIncomingListener + scope.launch { + _incomingMessages.emit( + IncomingMessage( + accountId = accountId, + from = from.asBareJid().toString(), + body = body + ) + ) + } + } + } + + private fun updateStatus(accountId: Long, status: ConnectionStatus) { + _connectionStatus.update { current -> + current.toMutableMap().also { it[accountId] = status } + } + } +} + +// ── Extension helpers ───────────────────────────────────────────────────── + +private fun Presence.toPresenceStatus(): PresenceStatus = when (type) { + Presence.Type.available -> when (mode) { + Presence.Mode.away, Presence.Mode.xa -> PresenceStatus.AWAY + Presence.Mode.dnd -> PresenceStatus.DND + else -> PresenceStatus.ONLINE + } + else -> PresenceStatus.OFFLINE +} diff --git a/app/src/main/java/com/manalejandro/alejabber/data/repository/AccountRepository.kt b/app/src/main/java/com/manalejandro/alejabber/data/repository/AccountRepository.kt new file mode 100644 index 0000000..b4fd4a9 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/data/repository/AccountRepository.kt @@ -0,0 +1,58 @@ +package com.manalejandro.alejabber.data.repository + +import com.manalejandro.alejabber.data.local.dao.AccountDao +import com.manalejandro.alejabber.data.local.entity.toDomain +import com.manalejandro.alejabber.data.local.entity.toEntity +import com.manalejandro.alejabber.data.remote.XmppConnectionManager +import com.manalejandro.alejabber.domain.model.Account +import com.manalejandro.alejabber.domain.model.ConnectionStatus +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AccountRepository @Inject constructor( + private val accountDao: AccountDao, + private val xmppManager: XmppConnectionManager +) { + fun getAllAccounts(): Flow> { + return accountDao.getAllAccounts() + .combine(xmppManager.connectionStatus) { entities, statusMap -> + entities.map { entity -> + entity.toDomain(statusMap[entity.id] ?: ConnectionStatus.OFFLINE) + } + } + } + + suspend fun getAccountById(id: Long): Account? = + accountDao.getAccountById(id)?.toDomain( + xmppManager.connectionStatus.value[id] ?: ConnectionStatus.OFFLINE + ) + + suspend fun addAccount(account: Account): Long { + val id = accountDao.insertAccount(account.toEntity()) + val savedAccount = account.copy(id = id) + if (savedAccount.isEnabled) { + xmppManager.connect(savedAccount) + } + return id + } + + suspend fun updateAccount(account: Account) { + accountDao.updateAccount(account.toEntity()) + } + + suspend fun deleteAccount(id: Long) { + xmppManager.disconnect(id) + accountDao.deleteAccountById(id) + } + + fun connectAccount(account: Account) = xmppManager.connect(account) + + fun disconnectAccount(accountId: Long) = xmppManager.disconnect(accountId) + + fun isConnected(accountId: Long) = xmppManager.isConnected(accountId) +} + diff --git a/app/src/main/java/com/manalejandro/alejabber/data/repository/ContactRepository.kt b/app/src/main/java/com/manalejandro/alejabber/data/repository/ContactRepository.kt new file mode 100644 index 0000000..3b8a886 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/data/repository/ContactRepository.kt @@ -0,0 +1,97 @@ +package com.manalejandro.alejabber.data.repository + +import com.manalejandro.alejabber.data.local.dao.ContactDao +import com.manalejandro.alejabber.data.local.entity.toDomain +import com.manalejandro.alejabber.data.local.entity.toEntity +import com.manalejandro.alejabber.data.remote.XmppConnectionManager +import com.manalejandro.alejabber.domain.model.Contact +import com.manalejandro.alejabber.domain.model.PresenceStatus +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import org.jivesoftware.smack.roster.Roster +import org.jivesoftware.smack.roster.RosterEntry +import org.jxmpp.jid.impl.JidCreate +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ContactRepository @Inject constructor( + private val contactDao: ContactDao, + private val xmppManager: XmppConnectionManager +) { + /** + * Returns a Flow of contacts for [accountId], with **live presence** merged in. + * + * Room provides the persisted roster (name, JID, groups). + * [XmppConnectionManager.rosterPresence] provides the real-time online/away/offline state. + * The two are combined so the UI always shows the current presence without + * writing every presence stanza to the database. + */ + fun getContacts(accountId: Long): Flow> = + contactDao.getContactsByAccount(accountId) + .combine(xmppManager.rosterPresence) { entities, presenceMap -> + val accountPresence = presenceMap[accountId] ?: emptyMap() + entities.map { entity -> + val livePresence = accountPresence[entity.jid] + if (livePresence != null) { + entity.toDomain().copy(presence = livePresence) + } else { + entity.toDomain() + } + } + } + + fun searchContacts(accountId: Long, query: String): Flow> = + contactDao.searchContacts(accountId, query).map { list -> list.map { it.toDomain() } } + + suspend fun addContact(contact: Contact): Long { + val connection = xmppManager.getConnection(contact.accountId) + if (connection != null && connection.isConnected) { + try { + val roster = Roster.getInstanceFor(connection) + val jid = JidCreate.entityBareFrom(contact.jid) + roster.createItemAndRequestSubscription( + jid, contact.nickname.ifBlank { contact.jid }, null + ) + } catch (e: Exception) { + // Proceed to save locally even if the server call fails + } + } + return contactDao.insertContact(contact.toEntity()) + } + + suspend fun removeContact(accountId: Long, jid: String) { + val connection = xmppManager.getConnection(accountId) + if (connection != null && connection.isConnected) { + try { + val roster = Roster.getInstanceFor(connection) + val entry: RosterEntry? = roster.getEntry(JidCreate.entityBareFrom(jid)) + entry?.let { roster.removeEntry(it) } + } catch (e: Exception) { /* ignore */ } + } + contactDao.deleteContact(accountId, jid) + } + + suspend fun syncRoster(accountId: Long) { + val entries = xmppManager.getRosterEntries(accountId) + val contacts = entries.map { entry -> + Contact( + accountId = accountId, + jid = entry.jid.asBareJid().toString(), + nickname = entry.name ?: entry.jid.asBareJid().toString(), + groups = entry.groups.map { it.name } + ).toEntity() + } + if (contacts.isNotEmpty()) contactDao.insertContacts(contacts) + } + + suspend fun updatePresence( + accountId: Long, + jid: String, + presence: PresenceStatus, + statusMessage: String + ) { + contactDao.updatePresence(accountId, jid, presence.name, statusMessage) + } +} diff --git a/app/src/main/java/com/manalejandro/alejabber/data/repository/MessageRepository.kt b/app/src/main/java/com/manalejandro/alejabber/data/repository/MessageRepository.kt new file mode 100644 index 0000000..293be54 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/data/repository/MessageRepository.kt @@ -0,0 +1,74 @@ +package com.manalejandro.alejabber.data.repository + +import com.manalejandro.alejabber.data.local.dao.MessageDao +import com.manalejandro.alejabber.data.local.entity.toDomain +import com.manalejandro.alejabber.data.local.entity.toEntity +import com.manalejandro.alejabber.data.remote.XmppConnectionManager +import com.manalejandro.alejabber.domain.model.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MessageRepository @Inject constructor( + private val messageDao: MessageDao, + private val xmppManager: XmppConnectionManager +) { + fun getMessages(accountId: Long, conversationJid: String): Flow> = + messageDao.getMessages(accountId, conversationJid).map { list -> list.map { it.toDomain() } } + + fun getUnreadCount(accountId: Long, conversationJid: String): Flow = + messageDao.getUnreadCount(accountId, conversationJid) + + suspend fun sendMessage( + accountId: Long, + toJid: String, + body: String, + encryptionType: EncryptionType = EncryptionType.NONE + ): Long { + val msg = Message( + accountId = accountId, + conversationJid = toJid, + fromJid = "", + toJid = toJid, + body = body, + direction = MessageDirection.OUTGOING, + status = MessageStatus.PENDING, + encryptionType = encryptionType + ) + val id = messageDao.insertMessage(msg.toEntity()) + val success = xmppManager.sendMessage(accountId, toJid, body) + val status = if (success) MessageStatus.SENT else MessageStatus.FAILED + messageDao.updateStatus(id, status.name) + return id + } + + suspend fun saveIncomingMessage( + accountId: Long, + from: String, + body: String, + encryptionType: EncryptionType = EncryptionType.NONE + ): Long { + val msg = Message( + accountId = accountId, + conversationJid = from, + fromJid = from, + toJid = "", + body = body, + direction = MessageDirection.INCOMING, + status = MessageStatus.DELIVERED, + encryptionType = encryptionType + ) + return messageDao.insertMessage(msg.toEntity()) + } + + suspend fun markAllAsRead(accountId: Long, conversationJid: String) = + messageDao.markAllAsRead(accountId, conversationJid) + + suspend fun deleteMessage(id: Long) = messageDao.deleteMessage(id) + + suspend fun clearConversation(accountId: Long, conversationJid: String) = + messageDao.clearConversation(accountId, conversationJid) +} + diff --git a/app/src/main/java/com/manalejandro/alejabber/data/repository/RoomRepository.kt b/app/src/main/java/com/manalejandro/alejabber/data/repository/RoomRepository.kt new file mode 100644 index 0000000..44156c7 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/data/repository/RoomRepository.kt @@ -0,0 +1,78 @@ +package com.manalejandro.alejabber.data.repository + +import com.manalejandro.alejabber.data.local.dao.RoomDao +import com.manalejandro.alejabber.data.local.entity.toDomain +import com.manalejandro.alejabber.data.local.entity.toEntity +import com.manalejandro.alejabber.data.remote.XmppConnectionManager +import com.manalejandro.alejabber.domain.model.Room +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.jivesoftware.smack.SmackException +import org.jivesoftware.smackx.muc.MultiUserChat +import org.jivesoftware.smackx.muc.MultiUserChatManager +import org.jxmpp.jid.impl.JidCreate +import org.jxmpp.jid.parts.Resourcepart +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class RoomRepository @Inject constructor( + private val roomDao: RoomDao, + private val xmppManager: XmppConnectionManager +) { + private val joinedChats = mutableMapOf() + + fun getRooms(accountId: Long): Flow> = + roomDao.getRoomsByAccount(accountId).map { list -> list.map { it.toDomain() } } + + fun getJoinedRooms(accountId: Long): Flow> = + roomDao.getJoinedRooms(accountId).map { list -> list.map { it.toDomain() } } + + suspend fun joinRoom(accountId: Long, roomJid: String, nickname: String, password: String = ""): Boolean { + return try { + val connection = xmppManager.getConnection(accountId) ?: return false + val mucManager = MultiUserChatManager.getInstanceFor(connection) + val jid = JidCreate.entityBareFrom(roomJid) + val muc = mucManager.getMultiUserChat(jid) + val resource = Resourcepart.from(nickname) + if (password.isNotBlank()) { + muc.join(resource, password) + } else { + muc.join(resource) + } + joinedChats["${accountId}_${roomJid}"] = muc + roomDao.updateJoinStatus(accountId, roomJid, true) + true + } catch (e: SmackException) { + false + } catch (e: Exception) { + false + } + } + + suspend fun leaveRoom(accountId: Long, roomJid: String) { + try { + joinedChats["${accountId}_${roomJid}"]?.leave() + joinedChats.remove("${accountId}_${roomJid}") + } catch (e: Exception) { /* ignore */ } + roomDao.updateJoinStatus(accountId, roomJid, false) + } + + suspend fun saveRoom(room: Room): Long = roomDao.insertRoom(room.toEntity()) + + suspend fun deleteRoom(room: Room) = roomDao.deleteRoom(room.toEntity()) + + fun getMuc(accountId: Long, roomJid: String): MultiUserChat? = + joinedChats["${accountId}_${roomJid}"] + + suspend fun sendRoomMessage(accountId: Long, roomJid: String, body: String): Boolean { + return try { + val muc = joinedChats["${accountId}_${roomJid}"] ?: return false + muc.sendMessage(body) + true + } catch (e: Exception) { + false + } + } +} + diff --git a/app/src/main/java/com/manalejandro/alejabber/di/AppModule.kt b/app/src/main/java/com/manalejandro/alejabber/di/AppModule.kt new file mode 100644 index 0000000..686e83f --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/di/AppModule.kt @@ -0,0 +1,36 @@ +package com.manalejandro.alejabber.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +private val Context.dataStore: DataStore by preferencesDataStore(name = "settings") + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + + @Provides + @Singleton + fun provideOkHttpClient(): OkHttpClient = + OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() + + @Provides + @Singleton + fun provideDataStore(@ApplicationContext context: Context): DataStore = + context.dataStore +} + diff --git a/app/src/main/java/com/manalejandro/alejabber/di/DatabaseModule.kt b/app/src/main/java/com/manalejandro/alejabber/di/DatabaseModule.kt new file mode 100644 index 0000000..8f71437 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/di/DatabaseModule.kt @@ -0,0 +1,41 @@ +package com.manalejandro.alejabber.di + +import android.content.Context +import androidx.room.Room +import com.manalejandro.alejabber.data.local.AppDatabase +import com.manalejandro.alejabber.data.local.dao.AccountDao +import com.manalejandro.alejabber.data.local.dao.ContactDao +import com.manalejandro.alejabber.data.local.dao.MessageDao +import com.manalejandro.alejabber.data.local.dao.RoomDao +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): AppDatabase = + Room.databaseBuilder(context, AppDatabase::class.java, "alejabber.db") + .addMigrations(AppDatabase.MIGRATION_1_2) + .fallbackToDestructiveMigration(false) + .build() + + @Provides + fun provideAccountDao(db: AppDatabase): AccountDao = db.accountDao() + + @Provides + fun provideContactDao(db: AppDatabase): ContactDao = db.contactDao() + + @Provides + fun provideMessageDao(db: AppDatabase): MessageDao = db.messageDao() + + @Provides + fun provideRoomDao(db: AppDatabase): RoomDao = db.roomDao() +} + diff --git a/app/src/main/java/com/manalejandro/alejabber/domain/model/Account.kt b/app/src/main/java/com/manalejandro/alejabber/domain/model/Account.kt new file mode 100644 index 0000000..b41d4c2 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/domain/model/Account.kt @@ -0,0 +1,20 @@ +package com.manalejandro.alejabber.domain.model + +data class Account( + val id: Long = 0, + val jid: String, + val password: String, + val server: String = "", + val port: Int = 5222, + val useTls: Boolean = true, + val resource: String = "AleJabber", + val isEnabled: Boolean = true, + val status: ConnectionStatus = ConnectionStatus.OFFLINE, + val statusMessage: String = "", + val avatarUrl: String? = null +) + +enum class ConnectionStatus { + ONLINE, AWAY, DND, OFFLINE, CONNECTING, ERROR +} + diff --git a/app/src/main/java/com/manalejandro/alejabber/domain/model/Contact.kt b/app/src/main/java/com/manalejandro/alejabber/domain/model/Contact.kt new file mode 100644 index 0000000..398ad51 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/domain/model/Contact.kt @@ -0,0 +1,23 @@ +package com.manalejandro.alejabber.domain.model + +data class Contact( + val id: Long = 0, + val accountId: Long, + val jid: String, + val nickname: String = "", + val groups: List = emptyList(), + val presence: PresenceStatus = PresenceStatus.OFFLINE, + val statusMessage: String = "", + val avatarUrl: String? = null, + val isBlocked: Boolean = false, + val subscriptionState: SubscriptionState = SubscriptionState.NONE +) + +enum class PresenceStatus { + ONLINE, AWAY, DND, XA, OFFLINE +} + +enum class SubscriptionState { + NONE, PENDING_OUT, PENDING_IN, BOTH, REMOVE +} + diff --git a/app/src/main/java/com/manalejandro/alejabber/domain/model/Message.kt b/app/src/main/java/com/manalejandro/alejabber/domain/model/Message.kt new file mode 100644 index 0000000..a99c6b3 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/domain/model/Message.kt @@ -0,0 +1,41 @@ +package com.manalejandro.alejabber.domain.model + +data class Message( + val id: Long = 0, + val stanzaId: String = "", + val accountId: Long, + val conversationJid: String, + val fromJid: String, + val toJid: String, + val body: String = "", + val timestamp: Long = System.currentTimeMillis(), + val direction: MessageDirection, + val status: MessageStatus = MessageStatus.PENDING, + val encryptionType: EncryptionType = EncryptionType.NONE, + val mediaType: MediaType = MediaType.TEXT, + val mediaUrl: String? = null, + val mediaLocalPath: String? = null, + val mediaMimeType: String? = null, + val mediaSize: Long = 0, + val mediaName: String? = null, + val audioDurationMs: Long = 0, + val isRead: Boolean = false, + val isEdited: Boolean = false, + val isDeleted: Boolean = false, + val replyToId: Long? = null +) + +enum class MessageDirection { INCOMING, OUTGOING } + +enum class MessageStatus { + PENDING, SENT, DELIVERED, READ, FAILED +} + +enum class EncryptionType { + NONE, OTR, OMEMO, OPENPGP +} + +enum class MediaType { + TEXT, IMAGE, VIDEO, AUDIO, FILE, LINK +} + diff --git a/app/src/main/java/com/manalejandro/alejabber/domain/model/Room.kt b/app/src/main/java/com/manalejandro/alejabber/domain/model/Room.kt new file mode 100644 index 0000000..6f1151b --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/domain/model/Room.kt @@ -0,0 +1,36 @@ +package com.manalejandro.alejabber.domain.model + +data class Room( + val id: Long = 0, + val accountId: Long, + val jid: String, + val nickname: String = "", + val name: String = "", + val description: String = "", + val topic: String = "", + val password: String = "", + val isJoined: Boolean = false, + val isFavorite: Boolean = false, + val participantCount: Int = 0, + val avatarUrl: String? = null, + val unreadCount: Int = 0, + val lastMessage: String = "", + val lastMessageTime: Long = 0 +) + +data class RoomParticipant( + val jid: String, + val nickname: String, + val role: ParticipantRole = ParticipantRole.PARTICIPANT, + val affiliation: ParticipantAffiliation = ParticipantAffiliation.NONE, + val presence: PresenceStatus = PresenceStatus.ONLINE +) + +enum class ParticipantRole { + MODERATOR, PARTICIPANT, VISITOR, NONE +} + +enum class ParticipantAffiliation { + OWNER, ADMIN, MEMBER, OUTCAST, NONE +} + diff --git a/app/src/main/java/com/manalejandro/alejabber/media/AudioRecorder.kt b/app/src/main/java/com/manalejandro/alejabber/media/AudioRecorder.kt new file mode 100644 index 0000000..223fb75 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/media/AudioRecorder.kt @@ -0,0 +1,129 @@ +package com.manalejandro.alejabber.media + +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +enum class RecordingState { IDLE, RECORDING, STOPPED } + +/** + * Wraps [MediaRecorder] to record audio from the microphone. + * + * Emits real-time elapsed duration via [durationMs] (updated every 100 ms while recording). + * Output format: MPEG-4 / AAC, 44 100 Hz, 128 kbps. + */ +@Singleton +class AudioRecorder @Inject constructor( + @param:ApplicationContext private val context: Context +) { + private var recorder: MediaRecorder? = null + private var outputFile: File? = null + private var startTime: Long = 0L + + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private var tickerJob: Job? = null + + private val _state = MutableStateFlow(RecordingState.IDLE) + val state: StateFlow = _state.asStateFlow() + + private val _durationMs = MutableStateFlow(0L) + val durationMs: StateFlow = _durationMs.asStateFlow() + + /** Starts recording. Returns true on success, false if an error occurs. */ + fun startRecording(): Boolean { + return try { + val dir = File(context.cacheDir, "audio").also { it.mkdirs() } + outputFile = File(dir, "audio_${System.currentTimeMillis()}.m4a") + + recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + @Suppress("DEPRECATION") + MediaRecorder() + } + recorder!!.apply { + setAudioSource(MediaRecorder.AudioSource.MIC) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setAudioSamplingRate(44100) + setAudioEncodingBitRate(128000) + setOutputFile(outputFile!!.absolutePath) + prepare() + start() + } + startTime = System.currentTimeMillis() + _durationMs.value = 0L + _state.value = RecordingState.RECORDING + + // Tick every 100 ms so the UI shows a live counter + tickerJob = scope.launch { + while (true) { + delay(100) + _durationMs.value = System.currentTimeMillis() - startTime + } + } + true + } catch (e: Exception) { + cleanup() + false + } + } + + /** + * Stops recording and returns the output [File]. + * Returns null if recording was not active or an error occurs. + */ + fun stopRecording(): File? { + tickerJob?.cancel() + tickerJob = null + return try { + recorder?.apply { + stop() + release() + } + recorder = null + _durationMs.value = System.currentTimeMillis() - startTime + _state.value = RecordingState.STOPPED + outputFile + } catch (e: Exception) { + cleanup() + null + } + } + + /** Cancels the current recording and discards the file. */ + fun cancelRecording() { + tickerJob?.cancel() + tickerJob = null + cleanup() + outputFile?.delete() + outputFile = null + _state.value = RecordingState.IDLE + _durationMs.value = 0L + } + + /** Resets state back to IDLE after the file has been consumed. */ + fun reset() { + _state.value = RecordingState.IDLE + _durationMs.value = 0L + } + + private fun cleanup() { + try { recorder?.release() } catch (_: Exception) {} + recorder = null + } +} + diff --git a/app/src/main/java/com/manalejandro/alejabber/media/HttpUploadManager.kt b/app/src/main/java/com/manalejandro/alejabber/media/HttpUploadManager.kt new file mode 100644 index 0000000..d28c5e4 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/media/HttpUploadManager.kt @@ -0,0 +1,84 @@ +package com.manalejandro.alejabber.media + +import android.content.Context +import android.net.Uri +import android.webkit.MimeTypeMap +import com.manalejandro.alejabber.data.remote.XmppConnectionManager +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.jivesoftware.smackx.httpfileupload.HttpFileUploadManager +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class HttpUploadManager @Inject constructor( + @ApplicationContext private val context: Context, + private val xmppManager: XmppConnectionManager, + private val okHttpClient: OkHttpClient +) { + /** + * Uploads a file using XEP-0363 http_upload and returns the download URL or null on failure. + */ + suspend fun uploadFile(accountId: Long, uri: Uri): String? = withContext(Dispatchers.IO) { + try { + val connection = xmppManager.getConnection(accountId) ?: return@withContext null + val uploadManager = HttpFileUploadManager.getInstanceFor(connection) + val contentResolver = context.contentResolver + val mimeType = contentResolver.getType(uri) ?: "application/octet-stream" + val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: "bin" + val fileName = "upload_${System.currentTimeMillis()}.$extension" + val bytes = contentResolver.openInputStream(uri)?.readBytes() ?: return@withContext null + // Write to temp file and upload + val tempFile = File(context.cacheDir, fileName).also { it.writeBytes(bytes) } + return@withContext uploadFileInternal(uploadManager, tempFile, mimeType, okHttpClient) + } catch (e: Exception) { + null + } + } + + suspend fun uploadFile(accountId: Long, file: File, mimeType: String): String? = + withContext(Dispatchers.IO) { + try { + val connection = xmppManager.getConnection(accountId) ?: return@withContext null + val uploadManager = HttpFileUploadManager.getInstanceFor(connection) + return@withContext uploadFileInternal(uploadManager, file, mimeType, okHttpClient) + } catch (e: Exception) { + null + } + } + + private fun uploadFileInternal( + uploadManager: HttpFileUploadManager, + file: File, + mimeType: String, + okClient: OkHttpClient + ): String? { + return try { + // Request an upload slot (XEP-0363) + val slot = uploadManager.requestSlot(file.name, file.length(), mimeType) + val putUrl = slot.putUrl.toString() + val getUrl = slot.getUrl.toString() + // PUT the file bytes + val response = okClient.newCall( + Request.Builder() + .url(putUrl) + .put(file.readBytes().toRequestBody(mimeType.toMediaType())) + .build() + ).execute() + if (response.isSuccessful || response.code == 201) getUrl else null + } catch (e: Exception) { + null + } + } +} + + + + + diff --git a/app/src/main/java/com/manalejandro/alejabber/service/BootReceiver.kt b/app/src/main/java/com/manalejandro/alejabber/service/BootReceiver.kt new file mode 100644 index 0000000..5ae3b78 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/service/BootReceiver.kt @@ -0,0 +1,16 @@ +package com.manalejandro.alejabber.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat + +class BootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_BOOT_COMPLETED) { + val serviceIntent = Intent(context, XmppForegroundService::class.java) + ContextCompat.startForegroundService(context, serviceIntent) + } + } +} + diff --git a/app/src/main/java/com/manalejandro/alejabber/service/XmppForegroundService.kt b/app/src/main/java/com/manalejandro/alejabber/service/XmppForegroundService.kt new file mode 100644 index 0000000..3de2b48 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/service/XmppForegroundService.kt @@ -0,0 +1,126 @@ +package com.manalejandro.alejabber.service + +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.IBinder +import androidx.core.app.NotificationCompat +import com.manalejandro.alejabber.AleJabberApp +import com.manalejandro.alejabber.MainActivity +import com.manalejandro.alejabber.R +import com.manalejandro.alejabber.data.remote.XmppConnectionManager +import com.manalejandro.alejabber.data.repository.AccountRepository +import com.manalejandro.alejabber.data.repository.ContactRepository +import com.manalejandro.alejabber.data.repository.MessageRepository +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class XmppForegroundService : Service() { + + @Inject lateinit var xmppManager: XmppConnectionManager + @Inject lateinit var accountRepository: AccountRepository + @Inject lateinit var messageRepository: MessageRepository + @Inject lateinit var contactRepository: ContactRepository + + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + override fun onCreate() { + super.onCreate() + startForeground(AleJabberApp.NOTIFICATION_ID_SERVICE, buildForegroundNotification()) + listenForIncomingMessages() + listenForPresenceUpdates() + connectAllAccounts() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return START_STICKY + } + + override fun onDestroy() { + super.onDestroy() + xmppManager.disconnectAll() + serviceScope.cancel() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun connectAllAccounts() { + serviceScope.launch { + val accounts = accountRepository.getAllAccounts().first() + accounts.filter { it.isEnabled }.forEach { account -> + accountRepository.connectAccount(account) + } + } + } + + private fun listenForIncomingMessages() { + serviceScope.launch { + xmppManager.incomingMessages.collect { incoming -> + val id = messageRepository.saveIncomingMessage( + accountId = incoming.accountId, + from = incoming.from, + body = incoming.body + ) + showMessageNotification(incoming.from, incoming.body) + } + } + } + + private fun listenForPresenceUpdates() { + serviceScope.launch { + xmppManager.presenceUpdates.collect { update -> + // update.status is already a PresenceStatus — persist it to DB + // so the roster shows the correct state even after restarting the app. + contactRepository.updatePresence( + update.accountId, update.jid, update.status, update.statusMessage + ) + } + } + } + + private fun showMessageNotification(from: String, body: String) { + val intent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingIntent = PendingIntent.getActivity( + this, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val notification = NotificationCompat.Builder(this, AleJabberApp.CHANNEL_MESSAGES) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(from) + .setContentText(body) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build() + val nm = getSystemService(NotificationManager::class.java) + nm.notify(from.hashCode(), notification) + } + + private fun buildForegroundNotification(): Notification { + val intent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + this, 0, intent, + PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Builder(this, AleJabberApp.CHANNEL_SERVICE) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.notification_service_running)) + .setContentIntent(pendingIntent) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + } +} + diff --git a/app/src/main/java/com/manalejandro/alejabber/ui/accounts/AccountsScreen.kt b/app/src/main/java/com/manalejandro/alejabber/ui/accounts/AccountsScreen.kt new file mode 100644 index 0000000..38b0b71 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/ui/accounts/AccountsScreen.kt @@ -0,0 +1,337 @@ +package com.manalejandro.alejabber.ui.accounts + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.manalejandro.alejabber.R +import com.manalejandro.alejabber.domain.model.Account +import com.manalejandro.alejabber.domain.model.ConnectionStatus +import com.manalejandro.alejabber.domain.model.PresenceStatus +import com.manalejandro.alejabber.ui.components.AvatarWithStatus +import com.manalejandro.alejabber.ui.theme.StatusAway +import com.manalejandro.alejabber.ui.theme.StatusDnd +import com.manalejandro.alejabber.ui.theme.StatusOffline +import com.manalejandro.alejabber.ui.theme.StatusOnline + +/** + * Displays all configured XMPP accounts and lets the user: + * - Add a new account (FAB → [onAddAccount]) + * - Connect / disconnect each account + * - Tap a connected account to browse its contacts ([onOpenContacts]) + * - Edit or delete an account via the overflow menu + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountsScreen( + onAddAccount: () -> Unit, + onEditAccount: (Long) -> Unit, + onOpenContacts: (Long) -> Unit, + viewModel: AccountsViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var deleteTarget by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.accounts_title)) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + }, + floatingActionButton = { + // Always visible FAB to add a new account + FloatingActionButton( + onClick = onAddAccount, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.semantics { + contentDescription = "Add new account" + } + ) { + Icon(Icons.Default.Add, contentDescription = null) + } + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + if (uiState.accounts.isEmpty()) { + // Empty-state prompt + Column( + modifier = Modifier + .align(Alignment.Center) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + Icons.Default.AccountCircle, + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f) + ) + Spacer(Modifier.height(20.dp)) + Text( + text = stringResource(R.string.account_no_accounts), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Tap + to add your first XMPP account", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + Spacer(Modifier.height(24.dp)) + Button(onClick = onAddAccount) { + Icon(Icons.Default.Add, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.add_account)) + } + } + } else { + LazyColumn( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + items(uiState.accounts, key = { it.id }) { account -> + AccountCard( + account = account, + onConnect = { viewModel.connectAccount(account) }, + onDisconnect = { viewModel.disconnectAccount(account.id) }, + onEdit = { onEditAccount(account.id) }, + onDelete = { deleteTarget = account }, + onOpen = { onOpenContacts(account.id) } + ) + } + // Extra bottom padding so FAB doesn't overlap last item + item { Spacer(Modifier.height(88.dp)) } + } + } + + // Loading overlay + AnimatedVisibility( + visible = uiState.isLoading, + enter = fadeIn(), exit = fadeOut(), + modifier = Modifier.align(Alignment.Center) + ) { + CircularProgressIndicator() + } + } + } + + // ── Delete confirmation ─────────────────────────────────────────────────── + deleteTarget?.let { account -> + AlertDialog( + onDismissRequest = { deleteTarget = null }, + title = { Text(stringResource(R.string.delete_account)) }, + text = { Text(stringResource(R.string.account_delete_confirm, account.jid)) }, + confirmButton = { + TextButton(onClick = { + viewModel.deleteAccount(account.id) + deleteTarget = null + }) { + Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { deleteTarget = null }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } +} + +/** + * A card representing one XMPP account. + * + * Tapping the card (when the account is connected) calls [onOpen]. + * The connect/disconnect button controls the XMPP connection. + * The overflow menu exposes edit and delete actions. + */ +@Composable +fun AccountCard( + account: Account, + onConnect: () -> Unit, + onDisconnect: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, + onOpen: () -> Unit +) { + var menuExpanded by remember { mutableStateOf(false) } + val isOnline = account.status == ConnectionStatus.ONLINE || + account.status == ConnectionStatus.AWAY || + account.status == ConnectionStatus.DND + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = isOnline, onClick = onOpen), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = if (isOnline) + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f) + else + MaterialTheme.colorScheme.surfaceVariant + ), + elevation = CardDefaults.cardElevation(defaultElevation = if (isOnline) 4.dp else 1.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 18.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Avatar with presence dot — uses AvatarWithStatus so it shows + // a real photo when the account has a vcard avatar URL + AvatarWithStatus( + name = account.jid, + avatarUrl = account.avatarUrl, + presence = account.status.toPresenceStatus(), + size = 48.dp, + contentDescription = "Avatar for ${account.jid}" + ) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = account.jid, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = account.status.toLabel(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (isOnline) { + Text( + text = "Tap to view contacts →", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + + // Connect / Disconnect / Connecting + when (account.status) { + ConnectionStatus.OFFLINE, ConnectionStatus.ERROR -> { + IconButton( + onClick = onConnect, + modifier = Modifier.semantics { contentDescription = "Connect" } + ) { + Icon( + Icons.Default.CloudSync, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } + ConnectionStatus.CONNECTING -> { + CircularProgressIndicator( + modifier = Modifier + .size(24.dp) + .padding(4.dp), + strokeWidth = 2.dp + ) + } + else -> { + IconButton( + onClick = onDisconnect, + modifier = Modifier.semantics { contentDescription = "Disconnect" } + ) { + Icon( + Icons.Default.CloudOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // Overflow menu + Box { + IconButton(onClick = { menuExpanded = true }) { + Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.more_options)) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.edit_account)) }, + leadingIcon = { Icon(Icons.Default.Edit, null) }, + onClick = { menuExpanded = false; onEdit() } + ) + DropdownMenuItem( + text = { + Text( + stringResource(R.string.delete_account), + color = MaterialTheme.colorScheme.error + ) + }, + leadingIcon = { + Icon( + Icons.Default.Delete, null, + tint = MaterialTheme.colorScheme.error + ) + }, + onClick = { menuExpanded = false; onDelete() } + ) + } + } + } + } +} + +/** Maps [ConnectionStatus] to its indicator colour. */ +fun ConnectionStatus.toColor(): Color = when (this) { + ConnectionStatus.ONLINE -> StatusOnline + ConnectionStatus.AWAY -> StatusAway + ConnectionStatus.DND -> StatusDnd + ConnectionStatus.OFFLINE -> StatusOffline + ConnectionStatus.CONNECTING -> StatusAway + ConnectionStatus.ERROR -> Color(0xFFF44336) +} + +/** Maps [ConnectionStatus] to the equivalent [PresenceStatus] for [AvatarWithStatus]. */ +fun ConnectionStatus.toPresenceStatus(): PresenceStatus = when (this) { + ConnectionStatus.ONLINE -> PresenceStatus.ONLINE + ConnectionStatus.AWAY -> PresenceStatus.AWAY + ConnectionStatus.DND -> PresenceStatus.DND + ConnectionStatus.CONNECTING -> PresenceStatus.AWAY + ConnectionStatus.OFFLINE, + ConnectionStatus.ERROR -> PresenceStatus.OFFLINE +} + +/** Human-readable label for a [ConnectionStatus]. */ +fun ConnectionStatus.toLabel(): String = when (this) { + ConnectionStatus.ONLINE -> "Online" + ConnectionStatus.AWAY -> "Away" + ConnectionStatus.DND -> "Do Not Disturb" + ConnectionStatus.OFFLINE -> "Offline" + ConnectionStatus.CONNECTING -> "Connecting…" + ConnectionStatus.ERROR -> "Connection error – tap to retry" +} diff --git a/app/src/main/java/com/manalejandro/alejabber/ui/accounts/AccountsViewModel.kt b/app/src/main/java/com/manalejandro/alejabber/ui/accounts/AccountsViewModel.kt new file mode 100644 index 0000000..1efd0bd --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/ui/accounts/AccountsViewModel.kt @@ -0,0 +1,130 @@ +package com.manalejandro.alejabber.ui.accounts + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.manalejandro.alejabber.data.repository.AccountRepository +import com.manalejandro.alejabber.domain.model.Account +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class AccountsUiState( + val accounts: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null +) + +@HiltViewModel +class AccountsViewModel @Inject constructor( + private val accountRepository: AccountRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(AccountsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadAccounts() + } + + private fun loadAccounts() { + viewModelScope.launch { + accountRepository.getAllAccounts().collect { accounts -> + _uiState.update { it.copy(accounts = accounts, isLoading = false) } + } + } + } + + fun connectAccount(account: Account) { + accountRepository.connectAccount(account) + } + + fun disconnectAccount(accountId: Long) { + accountRepository.disconnectAccount(accountId) + } + + fun deleteAccount(accountId: Long) { + viewModelScope.launch { + accountRepository.deleteAccount(accountId) + } + } +} + +// ViewModel for Add/Edit Account +data class AddEditAccountUiState( + val jid: String = "", + val password: String = "", + val server: String = "", + val port: String = "5222", + val useTls: Boolean = true, + val resource: String = "AleJabber", + val isLoading: Boolean = false, + val isSaved: Boolean = false, + val error: String? = null +) + +@HiltViewModel +class AddEditAccountViewModel @Inject constructor( + private val accountRepository: AccountRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(AddEditAccountUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadAccount(id: Long) { + viewModelScope.launch { + val account = accountRepository.getAccountById(id) ?: return@launch + _uiState.update { + it.copy( + jid = account.jid, + password = account.password, + server = account.server, + port = account.port.toString(), + useTls = account.useTls, + resource = account.resource + ) + } + } + } + + fun updateJid(v: String) = _uiState.update { it.copy(jid = v) } + fun updatePassword(v: String) = _uiState.update { it.copy(password = v) } + fun updateServer(v: String) = _uiState.update { it.copy(server = v) } + fun updatePort(v: String) = _uiState.update { it.copy(port = v) } + fun updateUseTls(v: Boolean) = _uiState.update { it.copy(useTls = v) } + fun updateResource(v: String) = _uiState.update { it.copy(resource = v) } + + fun saveAccount(existingId: Long? = null) { + val state = _uiState.value + if (state.jid.isBlank() || state.password.isBlank()) { + _uiState.update { it.copy(error = "JID and password are required") } + return + } + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + try { + val account = Account( + id = existingId ?: 0, + jid = state.jid.trim(), + password = state.password, + server = state.server.trim(), + port = state.port.toIntOrNull() ?: 5222, + useTls = state.useTls, + resource = state.resource.ifBlank { "AleJabber" } + ) + if (existingId != null) { + accountRepository.updateAccount(account) + } else { + accountRepository.addAccount(account) + } + _uiState.update { it.copy(isLoading = false, isSaved = true) } + } catch (e: Exception) { + _uiState.update { it.copy(isLoading = false, error = e.message) } + } + } + } +} + diff --git a/app/src/main/java/com/manalejandro/alejabber/ui/accounts/AddEditAccountScreen.kt b/app/src/main/java/com/manalejandro/alejabber/ui/accounts/AddEditAccountScreen.kt new file mode 100644 index 0000000..ce52cae --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/ui/accounts/AddEditAccountScreen.kt @@ -0,0 +1,199 @@ +package com.manalejandro.alejabber.ui.accounts + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.manalejandro.alejabber.R + +/** + * Form screen to add a new XMPP account or edit an existing one. + * + * @param accountId Null when creating; the database id when editing. + * @param onNavigateBack Called when the user presses Back or after a successful save. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddEditAccountScreen( + accountId: Long?, + onNavigateBack: () -> Unit, + viewModel: AddEditAccountViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var showPassword by remember { mutableStateOf(false) } + + LaunchedEffect(accountId) { accountId?.let { viewModel.loadAccount(it) } } + LaunchedEffect(uiState.isSaved) { if (uiState.isSaved) onNavigateBack() } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + if (accountId == null) stringResource(R.string.add_account) + else stringResource(R.string.edit_account) + ) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // JID (user@domain) + OutlinedTextField( + value = uiState.jid, + onValueChange = viewModel::updateJid, + label = { Text(stringResource(R.string.account_username)) }, + placeholder = { Text(stringResource(R.string.account_jid_hint)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier.fillMaxWidth(), + isError = uiState.jid.isNotBlank() && !uiState.jid.contains("@"), + supportingText = { + if (uiState.jid.isNotBlank() && !uiState.jid.contains("@")) + Text("Must be in user@domain format") + } + ) + // Password + OutlinedTextField( + value = uiState.password, + onValueChange = viewModel::updatePassword, + label = { Text(stringResource(R.string.account_password)) }, + singleLine = true, + visualTransformation = if (showPassword) VisualTransformation.None + else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + trailingIcon = { + IconButton(onClick = { showPassword = !showPassword }) { + Icon( + if (showPassword) Icons.Default.VisibilityOff + else Icons.Default.Visibility, + contentDescription = "Toggle password visibility" + ) + } + }, + modifier = Modifier.fillMaxWidth() + ) + // Server override (optional) + OutlinedTextField( + value = uiState.server, + onValueChange = viewModel::updateServer, + label = { Text(stringResource(R.string.account_server) + " (optional)") }, + placeholder = { Text("xmpp.example.com") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + supportingText = { Text("Leave blank to use DNS SRV lookup") } + ) + // Port + OutlinedTextField( + value = uiState.port, + onValueChange = viewModel::updatePort, + label = { Text(stringResource(R.string.account_port)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + // Resource name + OutlinedTextField( + value = uiState.resource, + onValueChange = viewModel::updateResource, + label = { Text(stringResource(R.string.account_resource)) }, + placeholder = { Text(stringResource(R.string.account_resource_hint)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + // TLS toggle + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + stringResource(R.string.account_use_tls), + style = MaterialTheme.typography.bodyLarge + ) + Text( + "Require an encrypted TLS connection", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = uiState.useTls, + onCheckedChange = viewModel::updateUseTls + ) + } + } + // Error banner + uiState.error?.let { error -> + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + error, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(12.dp) + ) + } + } + Spacer(Modifier.height(8.dp)) + // Save button + Button( + onClick = { viewModel.saveAccount(accountId) }, + enabled = !uiState.isLoading && uiState.jid.contains("@") && uiState.password.isNotBlank(), + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Text(stringResource(R.string.save), style = MaterialTheme.typography.titleMedium) + } + } + } + } +} diff --git a/app/src/main/java/com/manalejandro/alejabber/ui/chat/ChatScreen.kt b/app/src/main/java/com/manalejandro/alejabber/ui/chat/ChatScreen.kt new file mode 100644 index 0000000..a7aa57c --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/ui/chat/ChatScreen.kt @@ -0,0 +1,945 @@ +package com.manalejandro.alejabber.ui.chat + +import android.Manifest +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.manalejandro.alejabber.R +import com.manalejandro.alejabber.domain.model.* +import com.manalejandro.alejabber.media.RecordingState +import com.manalejandro.alejabber.ui.components.AvatarWithStatus +import com.manalejandro.alejabber.ui.components.EncryptionBadge +import com.manalejandro.alejabber.ui.theme.* +import java.text.SimpleDateFormat +import java.util.* +import java.util.regex.Pattern + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) +@Composable +fun ChatScreen( + accountId: Long, + conversationJid: String, + isRoom: Boolean = false, + onNavigateBack: () -> Unit, + viewModel: ChatViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val listState = rememberLazyListState() + val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO) + val clipboardManager = LocalClipboardManager.current + + // Message selected via long-press → shows the action bottom sheet + var selectedMessage by remember { mutableStateOf(null) } + // Confirm-delete dialog + var messageToDelete by remember { mutableStateOf(null) } + + // File picker + val filePicker = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent() + ) { uri -> uri?.let { viewModel.sendFile(it) } } + + LaunchedEffect(accountId, conversationJid) { + viewModel.init(accountId, conversationJid) + } + + // Scroll to bottom on new message + LaunchedEffect(uiState.messages.size) { + if (uiState.messages.isNotEmpty()) { + listState.animateScrollToItem(uiState.messages.size - 1) + } + } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) + } + }, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + AvatarWithStatus( + name = uiState.contactName, + avatarUrl = null, + presence = uiState.contactPresence, + size = 36.dp + ) + Spacer(Modifier.width(10.dp)) + Column { + Text( + uiState.contactName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + AnimatedVisibility(visible = uiState.isTyping) { + Text( + stringResource(R.string.chat_typing, uiState.contactName), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + }, + actions = { + // Encryption badge — shown as a pill (lock icon + label). + // Must NOT be inside an IconButton because IconButton clips + // its content to 48×48 dp, hiding the text label. + EncryptionBadge( + encryptionType = uiState.encryptionType, + modifier = Modifier + .clickable { viewModel.toggleEncryptionPicker() } + .padding(horizontal = 12.dp, vertical = 8.dp) + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + // No bottomBar — input is placed inside the content column so imePadding works + ) { padding -> + // imePadding() here at the column level makes the whole content + // (messages + input bar) shift up when the soft keyboard appears. + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .imePadding() + ) { + // ── Message list (takes all remaining space) ───────────────── + Box(modifier = Modifier.weight(1f)) { + if (uiState.messages.isEmpty()) { + Text( + text = stringResource(R.string.chat_empty), + modifier = Modifier + .align(Alignment.Center) + .fillMaxWidth() + .padding(horizontal = 32.dp), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + LazyColumn( + state = listState, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxSize() + ) { + val grouped = uiState.messages.groupBy { msg -> + SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) + .format(Date(msg.timestamp)) + } + grouped.forEach { (date, msgs) -> + item { DateDivider(date) } + items(msgs, key = { it.id }) { message -> + MessageBubble( + message = message, + onLongPress = { selectedMessage = message } + ) + } + } + item { Spacer(Modifier.height(8.dp)) } + } + } + + // Upload progress banner + if (uiState.isUploading) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopCenter) + ) + } + } + + // ── Input bar (always at bottom, above keyboard) ───────────── + ChatInput( + text = uiState.inputText, + onTextChange = viewModel::onInputChange, + onSend = viewModel::sendTextMessage, + onAttach = { filePicker.launch("*/*") }, + onStartRecording = { + if (micPermission.status.isGranted) viewModel.startRecording() + else micPermission.launchPermissionRequest() + }, + onStopRecording = viewModel::stopAndSendRecording, + onCancelRecording = viewModel::cancelRecording, + recordingState = uiState.recordingState, + recordingDuration = uiState.recordingDurationMs, + isUploading = uiState.isUploading + ) + } + } + + // ── Message action bottom sheet ─────────────────────────────────────── + selectedMessage?.let { msg -> + MessageActionsSheet( + message = msg, + clipboardManager = clipboardManager, + onDelete = { + selectedMessage = null + messageToDelete = msg + }, + onDismiss = { selectedMessage = null } + ) + } + + // ── Confirm delete dialog ───────────────────────────────────────────── + messageToDelete?.let { msg -> + AlertDialog( + onDismissRequest = { messageToDelete = null }, + icon = { Icon(Icons.Default.DeleteForever, null, tint = MaterialTheme.colorScheme.error) }, + title = { Text("Delete message?") }, + text = { + Text( + "This will remove the message from this device only. " + + "The recipient may still have a copy.", + style = MaterialTheme.typography.bodyMedium + ) + }, + confirmButton = { + TextButton(onClick = { + viewModel.deleteMessage(msg.id) + messageToDelete = null + }) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { messageToDelete = null }) { Text("Cancel") } + } + ) + } + + // ── Encryption picker ───────────────────────────────────────────────── + if (uiState.showEncryptionPicker) { + EncryptionPickerDialog( + current = uiState.encryptionType, + onSelect = viewModel::setEncryption, + onDismiss = viewModel::toggleEncryptionPicker + ) + } + + // ── Error snackbar ──────────────────────────────────────────────────── + uiState.error?.let { LaunchedEffect(it) { viewModel.clearError() } } +} + +// ── URL regex ───────────────────────────────────────────────────────────── +private val URL_PATTERN: Pattern = Pattern.compile( + "(https?://|www\\.)[\\w\\-]+(\\.[\\w\\-]+)+([\\w.,@?^=%&:/~+#\\-_]*[\\w@?^=%&/~+#\\-_])?" +) + +/** Converts a plain string into an [AnnotatedString] with clickable URL spans. */ +fun buildMessageText(text: String, linkColor: Color): AnnotatedString = buildAnnotatedString { + val matcher = URL_PATTERN.matcher(text) + var last = 0 + while (matcher.find()) { + // Append plain text before the URL + append(text.substring(last, matcher.start())) + val url = matcher.group() + val fullUrl = if (url.startsWith("http")) url else "https://$url" + // Append the URL with a distinct style and a string annotation + pushStringAnnotation(tag = "URL", annotation = fullUrl) + withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)) { + append(url) + } + pop() + last = matcher.end() + } + // Append remaining plain text + append(text.substring(last)) +} + +@Composable +fun MessageBubble(message: Message, onLongPress: () -> Unit) { + val isOutgoing = message.direction == MessageDirection.OUTGOING + val darkTheme = isSystemInDarkTheme() + + val bubbleColor = when { + isOutgoing && darkTheme -> BubbleSentDark + isOutgoing -> BubbleSent + darkTheme -> BubbleReceivedDark + else -> BubbleReceived + } + val textColor = if (isOutgoing) Color.White else MaterialTheme.colorScheme.onSurface + // URL links are always white on sent bubbles, primary on received + val linkColor = if (isOutgoing) Color.White else MaterialTheme.colorScheme.primary + + val uriHandler = LocalUriHandler.current + + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + start = if (isOutgoing) 48.dp else 0.dp, + end = if (isOutgoing) 0.dp else 48.dp + ), + horizontalAlignment = if (isOutgoing) Alignment.End else Alignment.Start + ) { + Box( + modifier = Modifier + .clip( + RoundedCornerShape( + topStart = 18.dp, topEnd = 18.dp, + bottomStart = if (isOutgoing) 18.dp else 4.dp, + bottomEnd = if (isOutgoing) 4.dp else 18.dp + ) + ) + .background(bubbleColor) + .pointerInput(Unit) { + detectTapGestures(onLongPress = { onLongPress() }) + } + .padding(horizontal = 14.dp, vertical = 8.dp) + ) { + when (message.mediaType) { + MediaType.TEXT, MediaType.LINK, null -> { + // Build annotated text with clickable URLs + val annotated = remember(message.body) { + buildMessageText(message.body, linkColor) + } + val hasLinks = annotated.getStringAnnotations("URL", 0, annotated.length).isNotEmpty() + if (hasLinks) { + // ClickableText for messages that contain URLs + androidx.compose.foundation.text.ClickableText( + text = annotated, + style = MaterialTheme.typography.bodyMedium.copy(color = textColor), + onClick = { offset -> + annotated.getStringAnnotations("URL", offset, offset) + .firstOrNull()?.let { uriHandler.openUri(it.item) } + } + ) + } else { + Text( + text = message.body, + color = textColor, + style = MaterialTheme.typography.bodyMedium + ) + } + } + MediaType.IMAGE -> { + AsyncImage( + model = message.mediaUrl ?: message.body, + contentDescription = stringResource(R.string.chat_media_image), + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 200.dp) + .clip(RoundedCornerShape(12.dp)) + .clickable { + val url = message.mediaUrl ?: message.body + if (url.startsWith("http")) uriHandler.openUri(url) + } + ) + } + MediaType.AUDIO -> { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Headset, null, tint = textColor) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.chat_media_audio), + color = textColor, style = MaterialTheme.typography.bodySmall + ) + if (message.audioDurationMs > 0) { + Spacer(Modifier.width(4.dp)) + Text( + formatDuration(message.audioDurationMs), + color = textColor.copy(alpha = 0.7f), + style = MaterialTheme.typography.labelSmall + ) + } + } + } + MediaType.FILE -> { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + val url = message.mediaUrl ?: message.body + if (url.startsWith("http")) uriHandler.openUri(url) + } + ) { + Icon(Icons.Default.Attachment, null, tint = textColor) + Spacer(Modifier.width(8.dp)) + Text( + message.mediaName ?: stringResource(R.string.chat_media_file), + color = textColor, style = MaterialTheme.typography.bodySmall + ) + } + } + else -> Text(message.body, color = textColor) + } + } + // Timestamp + delivery status + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp) + ) { + if (message.encryptionType != EncryptionType.NONE) { + Icon( + Icons.Default.Lock, contentDescription = null, + tint = message.encryptionType.toColor(), + modifier = Modifier.size(10.dp) + ) + Spacer(Modifier.width(4.dp)) + } + Text( + text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(message.timestamp)), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 11.sp + ) + if (isOutgoing) { + Spacer(Modifier.width(4.dp)) + StatusIcon(message.status) + } + } + } +} + +// ── Message context action sheet ────────────────────────────────────────── + +/** + * Bottom sheet shown on long-press of a message. + * Provides: Copy text · Open URL (if present) · Delete (with confirmation) + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MessageActionsSheet( + message: Message, + clipboardManager: ClipboardManager, + onDelete: () -> Unit, + onDismiss: () -> Unit +) { + val uriHandler = LocalUriHandler.current + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val hasUrl = URL_PATTERN.matcher(message.body).find() + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + Column(modifier = Modifier.padding(bottom = 24.dp)) { + // Header preview + Text( + text = message.body.take(120) + if (message.body.length > 120) "…" else "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp) + ) + HorizontalDivider() + + // ── Copy ────────────────────────────────────────────────────── + ListItem( + headlineContent = { Text("Copy text") }, + leadingContent = { Icon(Icons.Default.ContentCopy, null) }, + modifier = Modifier.clickable { + clipboardManager.setText(AnnotatedString(message.body)) + onDismiss() + } + ) + + // ── Open URL ───────────────────────────────────────────────── + if (hasUrl) { + val matcher = URL_PATTERN.matcher(message.body) + if (matcher.find()) { + val url = matcher.group() + val fullUrl = if (url.startsWith("http")) url else "https://$url" + ListItem( + headlineContent = { Text("Open link") }, + supportingContent = { + Text( + fullUrl, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1 + ) + }, + leadingContent = { Icon(Icons.Default.OpenInBrowser, null) }, + modifier = Modifier.clickable { + uriHandler.openUri(fullUrl) + onDismiss() + } + ) + // Copy link separately + ListItem( + headlineContent = { Text("Copy link") }, + leadingContent = { Icon(Icons.Default.Link, null) }, + modifier = Modifier.clickable { + clipboardManager.setText(AnnotatedString(fullUrl)) + onDismiss() + } + ) + } + } + + HorizontalDivider() + + // ── Delete ──────────────────────────────────────────────────── + ListItem( + headlineContent = { + Text("Delete message", color = MaterialTheme.colorScheme.error) + }, + leadingContent = { + Icon( + Icons.Default.DeleteForever, null, + tint = MaterialTheme.colorScheme.error + ) + }, + modifier = Modifier.clickable { onDelete() } + ) + + Spacer(Modifier.height(8.dp)) + } + } +} + + +@Composable +fun StatusIcon(status: MessageStatus) { + val (icon, tint, cd) = when (status) { + MessageStatus.PENDING -> Triple(Icons.Default.AccessTime, MaterialTheme.colorScheme.outline, stringResource(R.string.chat_message_sending)) + MessageStatus.SENT -> Triple(Icons.Default.Check, MaterialTheme.colorScheme.outline, "Sent") + MessageStatus.DELIVERED -> Triple(Icons.Default.CheckCircleOutline, MaterialTheme.colorScheme.outline, stringResource(R.string.chat_message_delivered)) + MessageStatus.READ -> Triple(Icons.Default.CheckCircle, MaterialTheme.colorScheme.primary, stringResource(R.string.chat_message_read)) + MessageStatus.FAILED -> Triple(Icons.Default.ErrorOutline, MaterialTheme.colorScheme.error, stringResource(R.string.chat_message_failed)) + } + Icon(icon, contentDescription = cd, tint = tint, modifier = Modifier.size(14.dp).semantics { contentDescription = cd }) +} + +@Composable +fun DateDivider(date: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) + ) { + HorizontalDivider(modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.outlineVariant) + Text( + text = date, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp) + ) + HorizontalDivider(modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.outlineVariant) + } +} + +// ── Full emoji catalog organized by category ────────────────────────────── + +private data class EmojiCategory(val label: String, val icon: String, val emojis: List) + +private val EMOJI_CATEGORIES = listOf( + EmojiCategory("Recent", "🕐", listOf( + "😀","😂","🥰","😍","👍","❤️","🎉","🔥","😊","🤔","😅","🙏","💯","✅","🚀" + )), + EmojiCategory("Faces", "😀", listOf( + "😀","😁","😂","🤣","😃","😄","😅","😆","😉","😊","😋","😎","😍","🥰","😘", + "🥲","😗","😙","😚","🙂","🤗","🤩","🤔","🫡","🤨","😐","😑","😶","🫥","😏", + "😒","🙄","😬","🤥","😌","😔","😪","🤤","😴","😷","🤒","🤕","🤢","🤮","🤧", + "🥵","🥶","🥴","😵","🤯","🤠","🥸","🥳","😎","🤓","🧐","😟","😕","🫤","😣", + "😖","😫","😩","🥺","😢","😭","😤","😠","😡","🤬","😈","👿","💀","☠️","💩", + "🤡","👹","👺","👻","👽","👾","🤖","😺","😸","😹","😻","😼","😽","🙀","😿","😾" + )), + EmojiCategory("Gestures", "👋", listOf( + "👋","🤚","🖐","✋","🖖","🫱","🫲","🫳","🫴","👌","🤌","🤏","✌️","🤞","🫰", + "🤟","🤘","🤙","👈","👉","👆","🖕","👇","☝️","🫵","👍","👎","✊","👊","🤛", + "🤜","👏","🙌","🫶","👐","🤲","🤝","🙏","✍️","💅","🤳","💪","🦾","🦿","🦵", + "🦶","👂","🦻","👃","🫀","🫁","🧠","🦷","🦴","👀","👁","👅","👄","🫦","💋" + )), + EmojiCategory("People", "👩", listOf( + "👶","🧒","👦","👧","🧑","👱","👨","🧔","👩","🧓","👴","👵","🙍","🙎","🙅", + "🙆","💁","🙋","🧏","🙇","🤦","🤷","💆","💇","🚶","🧍","🧎","🏃","💃","🕺", + "🧖","🧗","🤸","⛹","🤺","🏇","🏊","🤽","🚣","🧘","🛀","🛌","👫","👬","👭", + "💑","👨‍👩‍👦","👨‍👩‍👧","👨‍👩‍👧‍👦","👨‍👩‍👦‍👦","👨‍👩‍👧‍👧","🪢","👣" + )), + EmojiCategory("Animals", "🐶", listOf( + "🐶","🐱","🐭","🐹","🐰","🦊","🐻","🐼","🐨","🐯","🦁","🐮","🐷","🐸","🐵", + "🙈","🙉","🙊","🐔","🐧","🐦","🐤","🦆","🦅","🦉","🦇","🐺","🐗","🐴","🦄", + "🐝","🪱","🐛","🦋","🐌","🐞","🐜","🪲","🦟","🦗","🕷","🦂","🐢","🐍","🦎", + "🦖","🦕","🐙","🦑","🦐","🦞","🦀","🐡","🐠","🐟","🐬","🐳","🐋","🦈","🐊", + "🐅","🐆","🦓","🦍","🦧","🦣","🐘","🦛","🦏","🐪","🐫","🦒","🦘","🦬","🐃", + "🐂","🐄","🐎","🐖","🐏","🐑","🦙","🐐","🦌","🐕","🐩","🦮","🐈","🐓","🦃", + "🦤","🦚","🦜","🦢","🦩","🕊","🐇","🦝","🦨","🦡","🦫","🦦","🦥","🐁","🐀","🦔" + )), + EmojiCategory("Food", "🍕", listOf( + "🍏","🍎","🍐","🍊","🍋","🍌","🍉","🍇","🍓","🫐","🍈","🍑","🥭","🍍","🥥", + "🥝","🍅","🍆","🥑","🥦","🥬","🥒","🌶","🫑","🧄","🧅","🥔","🍠","🫘","🥐", + "🥖","🍞","🥨","🧀","🥚","🍳","🧈","🥞","🧇","🥓","🥩","🍗","🍖","🌭","🍔", + "🍟","🍕","🫓","🥪","🥙","🧆","🌮","🌯","🫔","🥗","🥘","🫕","🥫","🍝","🍜", + "🍲","🍛","🍣","🍱","🥟","🦪","🍤","🍙","🍚","🍘","🍥","🥮","🍢","🧁","🍰", + "🎂","🍮","🍭","🍬","🍫","🍿","🍩","🍪","🌰","🥜","🍯","🧃","🥤","🧋","☕", + "🍵","🫖","🍺","🍻","🥂","🍷","🫗","🥃","🍸","🍹","🧉","🍾","🧊","🥄","🍴" + )), + EmojiCategory("Nature", "🌿", listOf( + "🌸","🌺","🌻","🌹","🌷","🌼","💐","🍄","🌾","🍀","🌿","☘️","🍃","🍂","🍁", + "🌵","🌴","🌳","🌲","🎋","🎍","⛄","🌊","🌬","🌀","🌈","⚡","🔥","💧","🌍", + "🌎","🌏","🌑","🌒","🌓","🌔","🌕","🌖","🌗","🌘","🌙","🌚","🌛","🌜","🌝", + "⭐","🌟","💫","✨","☀️","🌤","⛅","🌥","🌦","🌧","⛈","🌩","🌨","❄️","🌫" + )), + EmojiCategory("Travel", "✈️", listOf( + "🚗","🚕","🚙","🚌","🚎","🏎","🚓","🚑","🚒","🚐","🛻","🚚","🚛","🚜","🏍", + "🛵","🚲","🛴","🛺","🚁","🛸","✈️","🛩","🚀","🛶","⛵","🚤","🛥","🛳","⛴", + "🚂","🚆","🚇","🚈","🚉","🚊","🚞","🚋","🚌","🚍","🚎","🚐","🚑","🚒","🚓", + "🗺","🧭","🏔","⛰","🌋","🏕","🏖","🏜","🏝","🏞","🏟","🏛","🏗","🏘","🏚", + "🏠","🏡","🏢","🏣","🏤","🏥","🏦","🏨","🏩","🏪","🏫","🏬","🏭","🏯","🏰" + )), + EmojiCategory("Objects", "💡", listOf( + "⌚","📱","💻","⌨️","🖥","🖨","🖱","🖲","🕹","💾","💿","📀","📷","📸","📹", + "🎥","📽","🎞","📞","☎️","📟","📠","📺","📻","🧭","⏱","⏲","⏰","🕰","⌛", + "📡","🔋","🔌","💡","🔦","🕯","💰","💴","💵","💶","💷","💸","💳","🪙","💹", + "📧","📨","📩","📪","📫","📬","📭","📮","🗳","✏️","✒️","🖊","🖋","📝","📁", + "📂","🗂","📅","📆","🗒","🗓","📇","📈","📉","📊","📋","📌","📍","📎","🖇", + "📏","📐","✂️","🗃","🗄","🗑","🔒","🔓","🔏","🔐","🔑","🗝","🔨","🪓","⛏", + "🔧","🪛","🔩","🪤","🧲","🪜","🧰","🪝","🧲","💊","💉","🩹","🩺","🔭","🔬" + )), + EmojiCategory("Symbols", "❤️", listOf( + "❤️","🧡","💛","💚","💙","💜","🖤","🤍","🤎","💔","❤️‍🔥","❤️‍🩹","💕","💞", + "💓","💗","💖","💘","💝","💟","☮️","✝️","☯️","🕉","✡️","🔯","🕎","☦️","🛐", + "⛎","♈","♉","♊","♋","♌","♍","♎","♏","♐","♑","♒","♓","🆔","⚜️", + "🔀","🔁","🔂","▶️","⏩","⏪","⏫","⏬","⏭","⏮","🔼","🔽","⏸","⏹","⏺", + "🎦","🔅","🔆","📶","📳","📴","📵","📱","📲","☎️","📞","📟","📠","🔋","🔌", + "✅","❎","🔴","🟠","🟡","🟢","🔵","🟣","⚫","⚪","🟤","🔺","🔻","🔷","🔶", + "🔹","🔸","🔲","🔳","▪️","▫️","◾","◽","◼️","◻️","🟥","🟧","🟨","🟩","🟦", + "💯","🔞","🔅","🆗","🆙","🆒","🆕","🆓","🔟","🆖","🅰️","🅱️","🆎","🆑","🅾️","🆘" + )), + EmojiCategory("Flags", "🏳️", listOf( + "🏳️","🏴","🚩","🏁","🏳️‍🌈","🏳️‍⚧️","🏴‍☠️", + "🇺🇸","🇬🇧","🇪🇸","🇫🇷","🇩🇪","🇮🇹","🇯🇵","🇨🇳","🇷🇺","🇧🇷", + "🇦🇷","🇦🇺","🇨🇦","🇲🇽","🇰🇷","🇮🇳","🇿🇦","🇳🇬","🇪🇬","🇸🇦", + "🇹🇷","🇮🇩","🇵🇰","🇧🇩","🇵🇭","🇵🇱","🇳🇱","🇧🇪","🇸🇪","🇨🇭" + )), +) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun EmojiPicker(onEmojiClick: (String) -> Unit) { + var selectedTab by remember { mutableIntStateOf(0) } + val category = EMOJI_CATEGORIES[selectedTab] + + Column { + // ── Category tab row ────────────────────────────────────────────── + ScrollableTabRow( + selectedTabIndex = selectedTab, + edgePadding = 0.dp, + divider = {} + ) { + EMOJI_CATEGORIES.forEachIndexed { index, cat -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + modifier = Modifier.height(44.dp) + ) { + Text( + text = cat.icon, + fontSize = 20.sp + ) + } + } + } + HorizontalDivider() + // ── Emoji grid for the selected category ────────────────────────── + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 44.dp), + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentPadding = PaddingValues(4.dp) + ) { + items(category.emojis) { emoji -> + Text( + text = emoji, + fontSize = 26.sp, + modifier = Modifier + .clickable { onEmojiClick(emoji) } + .padding(6.dp) + ) + } + } + } +} + + +@Composable +fun ChatInput( + text: String, + onTextChange: (String) -> Unit, + onSend: () -> Unit, + onAttach: () -> Unit, + onStartRecording: () -> Unit, + onStopRecording: () -> Unit, + onCancelRecording: () -> Unit, + recordingState: RecordingState, + recordingDuration: Long, + isUploading: Boolean +) { + var showEmojiPicker by remember { mutableStateOf(false) } + + Column { + // ── Emoji panel ─────────────────────────────────────────────────── + AnimatedVisibility( + visible = showEmojiPicker && recordingState != RecordingState.RECORDING, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Surface(tonalElevation = 6.dp) { + EmojiPicker(onEmojiClick = { onTextChange(text + it) }) + } + } + + // ── Input bar ───────────────────────────────────────────────────── + Surface(tonalElevation = 3.dp, shadowElevation = 4.dp) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.Bottom + ) { + when (recordingState) { + RecordingState.RECORDING -> { + // ── Recording UI ────────────────────────────────── + Row( + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Pulsing dot + val infiniteTransition = rememberInfiniteTransition(label = "pulse") + val alpha by infiniteTransition.animateFloat( + initialValue = 0.3f, targetValue = 1f, + animationSpec = infiniteRepeatable(tween(600), RepeatMode.Reverse), + label = "pulse_alpha" + ) + Box( + modifier = Modifier + .size(12.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.error.copy(alpha = alpha)) + ) + Spacer(Modifier.width(8.dp)) + Text( + formatDuration(recordingDuration), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + Spacer(Modifier.weight(1f)) + Text( + stringResource(R.string.chat_record_audio), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + // Cancel + IconButton(onClick = onCancelRecording) { + Icon( + Icons.Default.Close, + stringResource(R.string.chat_cancel_audio), + tint = MaterialTheme.colorScheme.error + ) + } + // Send recording + IconButton(onClick = onStopRecording) { + Icon( + Icons.AutoMirrored.Filled.Send, + stringResource(R.string.chat_send_audio), + tint = MaterialTheme.colorScheme.primary + ) + } + } + + else -> { + // ── Normal input row ────────────────────────────── + // Attach file + IconButton( + onClick = onAttach, + modifier = Modifier.semantics { contentDescription = "Attach file" } + ) { + Icon( + Icons.Default.Attachment, + stringResource(R.string.chat_attach), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + // Emoji toggle + IconButton( + onClick = { showEmojiPicker = !showEmojiPicker }, + modifier = Modifier.semantics { contentDescription = "Emoji picker" } + ) { + Icon( + if (showEmojiPicker) Icons.Default.KeyboardAlt + else Icons.Default.EmojiEmotions, + contentDescription = null, + tint = if (showEmojiPicker) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.onSurfaceVariant + ) + } + // Text field + OutlinedTextField( + value = text, + onValueChange = onTextChange, + placeholder = { Text(stringResource(R.string.chat_hint)) }, + shape = RoundedCornerShape(24.dp), + modifier = Modifier.weight(1f), + maxLines = 5 + ) + // Send OR record + if (text.isNotBlank()) { + IconButton( + onClick = { onSend(); showEmojiPicker = false }, + modifier = Modifier + .padding(start = 4.dp) + .semantics { contentDescription = "Send message" } + ) { + Icon( + Icons.AutoMirrored.Filled.Send, + stringResource(R.string.chat_send), + tint = MaterialTheme.colorScheme.primary + ) + } + } else { + IconButton( + onClick = onStartRecording, + modifier = Modifier + .padding(start = 4.dp) + .semantics { contentDescription = "Record audio message" } + ) { + Icon( + Icons.Default.KeyboardVoice, + stringResource(R.string.chat_record_audio), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + } +} + +@Composable +fun EncryptionPickerDialog( + current: EncryptionType, + onSelect: (EncryptionType) -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.chat_encryption_select)) }, + text = { + Column { + EncryptionType.entries.forEach { type -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .pointerInput(type) { + detectTapGestures { onSelect(type) } + } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = current == type, onClick = { onSelect(type) }) + Spacer(Modifier.width(12.dp)) + Column { + Text(type.toDisplayName(), fontWeight = FontWeight.Medium) + Text(type.toDescription(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.close)) } + } + ) +} + +fun EncryptionType.toColor(): Color = when (this) { + EncryptionType.OTR -> EncryptionOtr + EncryptionType.OMEMO -> EncryptionOmemo + EncryptionType.OPENPGP -> EncryptionPgp + EncryptionType.NONE -> EncryptionNone +} + +fun EncryptionType.toDisplayName(): String = when (this) { + EncryptionType.NONE -> "None (Plain text)" + EncryptionType.OTR -> "OTR" + EncryptionType.OMEMO -> "OMEMO" + EncryptionType.OPENPGP -> "OpenPGP" +} + +fun EncryptionType.toDescription(): String = when (this) { + EncryptionType.NONE -> "Messages are sent unencrypted" + EncryptionType.OTR -> "Off-the-Record: perfect forward secrecy" + EncryptionType.OMEMO -> "Multi-device end-to-end encryption (recommended)" + EncryptionType.OPENPGP -> "OpenPGP asymmetric encryption" +} + +fun formatDuration(ms: Long): String { + val totalSec = ms / 1000 + val min = totalSec / 60 + val sec = totalSec % 60 + return "%d:%02d".format(min, sec) +} + + + + + + + + + diff --git a/app/src/main/java/com/manalejandro/alejabber/ui/chat/ChatViewModel.kt b/app/src/main/java/com/manalejandro/alejabber/ui/chat/ChatViewModel.kt new file mode 100644 index 0000000..33f51a0 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/ui/chat/ChatViewModel.kt @@ -0,0 +1,165 @@ +package com.manalejandro.alejabber.ui.chat + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.manalejandro.alejabber.data.repository.ContactRepository +import com.manalejandro.alejabber.data.repository.MessageRepository +import com.manalejandro.alejabber.domain.model.* +import com.manalejandro.alejabber.media.AudioRecorder +import com.manalejandro.alejabber.media.HttpUploadManager +import com.manalejandro.alejabber.media.RecordingState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class ChatUiState( + val messages: List = emptyList(), + val contactName: String = "", + val contactPresence: PresenceStatus = PresenceStatus.OFFLINE, + val inputText: String = "", + val encryptionType: EncryptionType = EncryptionType.NONE, + val isTyping: Boolean = false, + val isUploading: Boolean = false, + val recordingState: RecordingState = RecordingState.IDLE, + val recordingDurationMs: Long = 0, + val showEncryptionPicker: Boolean = false, + val error: String? = null +) + +@HiltViewModel +class ChatViewModel @Inject constructor( + private val messageRepository: MessageRepository, + private val contactRepository: ContactRepository, + private val httpUploadManager: HttpUploadManager, + private val audioRecorder: AudioRecorder +) : ViewModel() { + + private val _uiState = MutableStateFlow(ChatUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var currentAccountId: Long = 0 + private var currentJid: String = "" + + fun init(accountId: Long, jid: String) { + currentAccountId = accountId + currentJid = jid + + viewModelScope.launch { + // Load messages + messageRepository.getMessages(accountId, jid).collect { messages -> + _uiState.update { it.copy(messages = messages) } + } + } + viewModelScope.launch { + // Load contact info + contactRepository.getContacts(accountId) + .take(1) + .collect { contacts -> + val contact = contacts.find { it.jid == jid } + _uiState.update { + it.copy( + contactName = contact?.nickname?.ifBlank { jid } ?: jid, + contactPresence = contact?.presence ?: PresenceStatus.OFFLINE + ) + } + } + } + viewModelScope.launch { + // Mark as read + messageRepository.markAllAsRead(accountId, jid) + } + // Observe recording state + viewModelScope.launch { + audioRecorder.state.collect { state -> + _uiState.update { it.copy(recordingState = state) } + } + } + viewModelScope.launch { + audioRecorder.durationMs.collect { ms -> + _uiState.update { it.copy(recordingDurationMs = ms) } + } + } + } + + fun onInputChange(text: String) = _uiState.update { it.copy(inputText = text) } + + fun sendTextMessage() { + val text = _uiState.value.inputText.trim() + if (text.isBlank()) return + _uiState.update { it.copy(inputText = "") } + viewModelScope.launch { + messageRepository.sendMessage( + accountId = currentAccountId, + toJid = currentJid, + body = text, + encryptionType = _uiState.value.encryptionType + ) + } + } + + fun sendFile(uri: Uri) { + viewModelScope.launch { + _uiState.update { it.copy(isUploading = true) } + try { + val url = httpUploadManager.uploadFile(currentAccountId, uri) + if (url != null) { + messageRepository.sendMessage( + accountId = currentAccountId, + toJid = currentJid, + body = url, + encryptionType = _uiState.value.encryptionType + ) + } + } catch (e: Exception) { + _uiState.update { it.copy(error = "Upload failed: ${e.message}") } + } finally { + _uiState.update { it.copy(isUploading = false) } + } + } + } + + fun startRecording(): Boolean = audioRecorder.startRecording() + + fun stopAndSendRecording() { + viewModelScope.launch { + val file = audioRecorder.stopRecording() ?: return@launch + _uiState.update { it.copy(isUploading = true) } + try { + val url = httpUploadManager.uploadFile(currentAccountId, file, "audio/mp4") + if (url != null) { + messageRepository.sendMessage( + accountId = currentAccountId, + toJid = currentJid, + body = url, + encryptionType = _uiState.value.encryptionType + ) + } + audioRecorder.reset() + } catch (e: Exception) { + _uiState.update { it.copy(error = "Audio upload failed: ${e.message}") } + } finally { + _uiState.update { it.copy(isUploading = false) } + } + } + } + + fun cancelRecording() = audioRecorder.cancelRecording() + + fun setEncryption(type: EncryptionType) = _uiState.update { + it.copy(encryptionType = type, showEncryptionPicker = false) + } + + fun toggleEncryptionPicker() = _uiState.update { + it.copy(showEncryptionPicker = !it.showEncryptionPicker) + } + + fun deleteMessage(messageId: Long) { + viewModelScope.launch { messageRepository.deleteMessage(messageId) } + } + + fun clearError() = _uiState.update { it.copy(error = null) } +} + + diff --git a/app/src/main/java/com/manalejandro/alejabber/ui/components/AvatarComponents.kt b/app/src/main/java/com/manalejandro/alejabber/ui/components/AvatarComponents.kt new file mode 100644 index 0000000..9a1a08b --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/ui/components/AvatarComponents.kt @@ -0,0 +1,105 @@ +package com.manalejandro.alejabber.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.manalejandro.alejabber.domain.model.PresenceStatus +import com.manalejandro.alejabber.ui.theme.StatusAway +import com.manalejandro.alejabber.ui.theme.StatusDnd +import com.manalejandro.alejabber.ui.theme.StatusOffline +import com.manalejandro.alejabber.ui.theme.StatusOnline + +@Composable +fun AvatarWithStatus( + name: String, + avatarUrl: String?, + presence: PresenceStatus, + modifier: Modifier = Modifier, + size: Dp = 48.dp, + contentDescription: String = "" +) { + Box(modifier = modifier) { + if (!avatarUrl.isNullOrBlank()) { + AsyncImage( + model = avatarUrl, + contentDescription = contentDescription, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(size) + .clip(CircleShape) + ) + } else { + InitialsAvatar(name = name, size = size, contentDescription = contentDescription) + } + // Presence dot + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .size(size * 0.27f) + .clip(CircleShape) + .background(presence.toColor()) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0f)) + ) + } +} + +@Composable +fun InitialsAvatar( + name: String, + size: Dp = 48.dp, + contentDescription: String = "", + backgroundColor: Color = Color(0xFF3A5BCC) +) { + val initials = name.split(" ") + .take(2) + .mapNotNull { it.firstOrNull()?.uppercaseChar() } + .joinToString("") + .ifBlank { "?" } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(size) + .clip(CircleShape) + .background(backgroundColor) + .semantics { this.contentDescription = contentDescription } + ) { + Text( + text = initials, + color = Color.White, + fontSize = (size.value * 0.38f).sp, + fontWeight = FontWeight.Bold + ) + } +} + +fun PresenceStatus.toColor(): Color = when (this) { + PresenceStatus.ONLINE -> StatusOnline + PresenceStatus.AWAY, PresenceStatus.XA -> StatusAway + PresenceStatus.DND -> StatusDnd + PresenceStatus.OFFLINE -> StatusOffline +} + +fun PresenceStatus.toLabel(): String = when (this) { + PresenceStatus.ONLINE -> "Online" + PresenceStatus.AWAY -> "Away" + PresenceStatus.XA -> "Extended Away" + PresenceStatus.DND -> "Do Not Disturb" + PresenceStatus.OFFLINE -> "Offline" +} + diff --git a/app/src/main/java/com/manalejandro/alejabber/ui/components/EncryptionBadge.kt b/app/src/main/java/com/manalejandro/alejabber/ui/components/EncryptionBadge.kt new file mode 100644 index 0000000..615e4fd --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/ui/components/EncryptionBadge.kt @@ -0,0 +1,71 @@ +package com.manalejandro.alejabber.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.manalejandro.alejabber.R +import com.manalejandro.alejabber.domain.model.EncryptionType +import com.manalejandro.alejabber.ui.theme.EncryptionNone +import com.manalejandro.alejabber.ui.theme.EncryptionOmemo +import com.manalejandro.alejabber.ui.theme.EncryptionOtr +import com.manalejandro.alejabber.ui.theme.EncryptionPgp +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen + +@Composable +fun EncryptionBadge( + encryptionType: EncryptionType, + modifier: Modifier = Modifier +) { + val (color, label) = encryptionType.toBadgeInfo() + val cdLabel = stringResource(R.string.cd_encryption_badge, label) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(color.copy(alpha = 0.15f)) + .padding(horizontal = 8.dp, vertical = 3.dp) + .semantics { contentDescription = cdLabel } + ) { + Icon( + imageVector = if (encryptionType == EncryptionType.NONE) Icons.Default.LockOpen else Icons.Default.Lock, + contentDescription = null, + tint = color, + modifier = Modifier.size(12.dp) + ) + Spacer(Modifier.width(4.dp)) + Text( + text = label, + color = color, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold + ) + } +} + +fun EncryptionType.toBadgeInfo(): Pair = when (this) { + EncryptionType.OTR -> EncryptionOtr to "OTR" + EncryptionType.OMEMO -> EncryptionOmemo to "OMEMO" + EncryptionType.OPENPGP -> EncryptionPgp to "PGP" + EncryptionType.NONE -> EncryptionNone to "Plain" +} + diff --git a/app/src/main/java/com/manalejandro/alejabber/ui/contacts/ContactsScreen.kt b/app/src/main/java/com/manalejandro/alejabber/ui/contacts/ContactsScreen.kt new file mode 100644 index 0000000..91d71a2 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/ui/contacts/ContactsScreen.kt @@ -0,0 +1,463 @@ +package com.manalejandro.alejabber.ui.contacts + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.manalejandro.alejabber.R +import com.manalejandro.alejabber.domain.model.Contact +import com.manalejandro.alejabber.domain.model.PresenceStatus +import com.manalejandro.alejabber.ui.components.AvatarWithStatus +import com.manalejandro.alejabber.ui.components.toColor +import com.manalejandro.alejabber.ui.components.toLabel + +/** + * Shows the roster (contact list) for a single XMPP account identified by [accountId]. + * + * The user navigates here by tapping a connected account in [AccountsScreen]. + * From here, tapping a contact opens [ChatScreen] via [onNavigateToChat]. + * + * @param accountId The database id of the account whose contacts are shown. + * @param onNavigateToChat Called with (accountId, contactJid) when a contact is tapped. + * @param onNavigateBack Called when the user presses the back arrow. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContactsScreen( + accountId: Long, + onNavigateToChat: (Long, String) -> Unit, + onNavigateBack: () -> Unit, + viewModel: ContactsViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + // Contact whose info sheet is shown on long-press + var detailContact by remember { mutableStateOf(null) } + // Contact pending removal confirmation + var removeTarget by remember { mutableStateOf(null) } + + LaunchedEffect(accountId) { viewModel.loadForAccount(accountId) } + + Scaffold( + topBar = { + Column { + TopAppBar( + title = { + Column { + Text(stringResource(R.string.contacts_title)) + uiState.accountJid?.let { jid -> + Text( + text = jid, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + }, + actions = { + // Sync roster from server + IconButton(onClick = { viewModel.syncRoster(accountId) }) { + Icon(Icons.Default.Refresh, contentDescription = "Sync roster") + } + } + ) + // Inline search bar + SearchBar( + query = uiState.searchQuery, + onQueryChange = viewModel::onSearchQueryChange, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + }, + floatingActionButton = { + FloatingActionButton( + onClick = viewModel::showAddDialog, + modifier = Modifier.semantics { contentDescription = "Add contact" } + ) { + Icon(Icons.Default.PersonAdd, contentDescription = null) + } + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + when { + uiState.isLoading -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + uiState.filteredContacts.isEmpty() && uiState.searchQuery.isBlank() -> { + EmptyState( + icon = Icons.Default.People, + message = stringResource(R.string.contacts_empty), + actionLabel = "Sync now", + onAction = { viewModel.syncRoster(accountId) }, + modifier = Modifier.align(Alignment.Center) + ) + } + uiState.filteredContacts.isEmpty() -> { + EmptyState( + icon = Icons.Default.SearchOff, + message = "No contacts match \"${uiState.searchQuery}\"", + modifier = Modifier.align(Alignment.Center) + ) + } + else -> { + ContactList( + contacts = uiState.filteredContacts, + onContactClick = { onNavigateToChat(accountId, it.jid) }, + onContactLongPress = { detailContact = it }, + onRemoveContact = { removeTarget = it } + ) + } + } + } + } + + // Add contact dialog + if (uiState.showAddDialog) { + AddContactDialog( + onDismiss = viewModel::hideAddDialog, + onAdd = { jid, nickname -> viewModel.addContact(accountId, jid, nickname) } + ) + } + + // Contact detail sheet (long-press) + detailContact?.let { contact -> + ContactDetailSheet( + contact = contact, + onChat = { onNavigateToChat(accountId, contact.jid); detailContact = null }, + onRemove = { detailContact = null; removeTarget = contact }, + onDismiss = { detailContact = null } + ) + } + + // Confirm remove dialog + removeTarget?.let { contact -> + val displayName = contact.nickname.ifBlank { contact.jid } + AlertDialog( + onDismissRequest = { removeTarget = null }, + icon = { Icon(Icons.Default.PersonRemove, null, tint = MaterialTheme.colorScheme.error) }, + title = { Text("Remove contact?") }, + text = { + Text( + "Remove $displayName from your contact list?\n\n" + + "This will also remove them from your roster on the server.", + style = MaterialTheme.typography.bodyMedium + ) + }, + confirmButton = { + TextButton(onClick = { + viewModel.removeContact(accountId, contact.jid) + removeTarget = null + }) { Text("Remove", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { removeTarget = null }) { Text(stringResource(R.string.cancel)) } + } + ) + } +} + +// ── Contact Detail Bottom Sheet ─────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContactDetailSheet( + contact: Contact, + onChat: () -> Unit, + onRemove: () -> Unit, + onDismiss: () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val displayName = contact.nickname.ifBlank { contact.jid } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Avatar + AvatarWithStatus( + name = displayName, + avatarUrl = contact.avatarUrl, + presence = contact.presence, + size = 80.dp, + contentDescription = displayName + ) + Spacer(Modifier.height(12.dp)) + // Name + Text(displayName, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + // JID + Text( + contact.jid, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(4.dp)) + // Presence badge + Surface( + shape = RoundedCornerShape(50), + color = contact.presence.toColor().copy(alpha = 0.15f) + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(contact.presence.toColor()) + ) + Spacer(Modifier.width(6.dp)) + Text( + text = contact.presence.toLabel(), + style = MaterialTheme.typography.labelMedium, + color = contact.presence.toColor() + ) + } + } + // Status message + if (contact.statusMessage.isNotBlank()) { + Spacer(Modifier.height(8.dp)) + Text( + "\"${contact.statusMessage}\"", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + // Groups + if (contact.groups.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Text( + "Groups: ${contact.groups.joinToString(", ")}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(Modifier.height(24.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + // Action: Chat + ListItem( + headlineContent = { Text("Start chat") }, + leadingContent = { Icon(Icons.AutoMirrored.Filled.Chat, null, tint = MaterialTheme.colorScheme.primary) }, + modifier = Modifier.clickable(onClick = onChat) + ) + // Action: Remove + ListItem( + headlineContent = { Text("Remove contact", color = MaterialTheme.colorScheme.error) }, + leadingContent = { Icon(Icons.Default.PersonRemove, null, tint = MaterialTheme.colorScheme.error) }, + modifier = Modifier.clickable(onClick = onRemove) + ) + Spacer(Modifier.height(8.dp)) + } + } +} + +// ─── Reusable composables ───────────────────────────────────────────────── + +@Composable +fun SearchBar( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + placeholder = { Text(stringResource(R.string.contacts_search)) }, + leadingIcon = { Icon(Icons.Default.Search, null) }, + trailingIcon = { + if (query.isNotBlank()) { + IconButton(onClick = { onQueryChange("") }) { Icon(Icons.Default.Clear, null) } + } + }, + singleLine = true, + shape = RoundedCornerShape(24.dp), + modifier = modifier + ) +} + +@Composable +fun ContactList( + contacts: List, + onContactClick: (Contact) -> Unit, + onContactLongPress: (Contact) -> Unit, + onRemoveContact: (Contact) -> Unit +) { + val presenceOrder = listOf( + PresenceStatus.ONLINE, PresenceStatus.AWAY, PresenceStatus.DND, + PresenceStatus.XA, PresenceStatus.OFFLINE + ) + val deduplicated = contacts + .groupBy { it.jid } + .map { (_, dupes) -> + dupes.minByOrNull { presenceOrder.indexOf(it.presence).takeIf { i -> i >= 0 } ?: Int.MAX_VALUE }!! + } + val grouped = deduplicated.groupBy { it.presence } + + LazyColumn( + contentPadding = PaddingValues(vertical = 8.dp), + modifier = Modifier.fillMaxSize() + ) { + presenceOrder.forEach { presence -> + val group = grouped[presence] ?: return@forEach + if (group.isEmpty()) return@forEach + item(key = "header_${presence.name}") { + Text( + text = "${presence.toLabel()} (${group.size})", + style = MaterialTheme.typography.labelMedium, + color = presence.toColor(), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) + ) + } + items(group, key = { "${presence.name}_${it.jid}" }) { contact -> + ContactItem( + contact = contact, + onClick = { onContactClick(contact) }, + onLongPress = { onContactLongPress(contact) }, + onRemove = { onRemoveContact(contact) } + ) + } + } + item { Spacer(Modifier.height(88.dp)) } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ContactItem( + contact: Contact, + onClick: () -> Unit, + onLongPress: () -> Unit, + onRemove: () -> Unit +) { + val displayName = contact.nickname.ifBlank { contact.jid } + + ListItem( + headlineContent = { Text(displayName, fontWeight = FontWeight.Medium) }, + supportingContent = { + Text( + contact.statusMessage.ifBlank { contact.presence.toLabel() }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + leadingContent = { + AvatarWithStatus( + name = displayName, + avatarUrl = contact.avatarUrl, + presence = contact.presence, + contentDescription = stringResource(R.string.cd_avatar, displayName) + ) + }, + trailingContent = { + IconButton(onClick = onRemove) { + Icon( + Icons.Default.PersonRemove, null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable(onClick = onClick, onLongClick = onLongPress) + .animateContentSize() + .semantics { contentDescription = "Chat with $displayName" } + ) + HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant) +} + +@Composable +fun AddContactDialog( + onDismiss: () -> Unit, + onAdd: (String, String) -> Unit +) { + var jid by remember { mutableStateOf("") } + var nickname by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.add_contact)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = jid, onValueChange = { jid = it }, + label = { Text(stringResource(R.string.contact_jid)) }, + placeholder = { Text("user@example.com") }, + singleLine = true, modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = nickname, onValueChange = { nickname = it }, + label = { Text(stringResource(R.string.contact_nickname)) }, + singleLine = true, modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton(onClick = { if (jid.isNotBlank()) onAdd(jid.trim(), nickname.trim()) }, enabled = jid.contains("@")) { + Text(stringResource(R.string.add_contact)) + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } } + ) +} + +@Composable +fun EmptyState( + icon: androidx.compose.ui.graphics.vector.ImageVector, + message: String, + actionLabel: String? = null, + onAction: (() -> Unit)? = null, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon(icon, null, modifier = Modifier.size(72.dp), tint = MaterialTheme.colorScheme.outline) + Text(message, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (actionLabel != null && onAction != null) { + FilledTonalButton(onClick = onAction) { Text(actionLabel) } + } + } +} + diff --git a/app/src/main/java/com/manalejandro/alejabber/ui/contacts/ContactsViewModel.kt b/app/src/main/java/com/manalejandro/alejabber/ui/contacts/ContactsViewModel.kt new file mode 100644 index 0000000..26fba48 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/ui/contacts/ContactsViewModel.kt @@ -0,0 +1,145 @@ +package com.manalejandro.alejabber.ui.contacts + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.manalejandro.alejabber.data.repository.AccountRepository +import com.manalejandro.alejabber.data.repository.ContactRepository +import com.manalejandro.alejabber.domain.model.Contact +import com.manalejandro.alejabber.domain.model.PresenceStatus +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * UI state for [ContactsScreen]. + * + * @property accountJid JID of the account being displayed, shown in the toolbar subtitle. + * @property allContacts Full roster list from Room for this account. + * @property filteredContacts Roster filtered by [searchQuery]. + * @property isLoading True while contacts are being fetched. + * @property searchQuery Current text in the search bar. + * @property showAddDialog Whether the add-contact dialog is visible. + * @property error Non-null when an operation failed. + */ +data class ContactsUiState( + val accountJid: String? = null, + val allContacts: List = emptyList(), + val filteredContacts: List = emptyList(), + val isLoading: Boolean = true, + val searchQuery: String = "", + val showAddDialog: Boolean = false, + val error: String? = null +) + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@HiltViewModel +class ContactsViewModel @Inject constructor( + private val accountRepository: AccountRepository, + private val contactRepository: ContactRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(ContactsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val searchQuery = MutableStateFlow("") + + /** + * Load contacts for the given [accountId]. + * Safe to call multiple times; cancels the previous collection. + */ + fun loadForAccount(accountId: Long) { + viewModelScope.launch { + // Resolve the JID for the toolbar subtitle + val account = accountRepository.getAccountById(accountId) + _uiState.update { it.copy(accountJid = account?.jid, isLoading = true) } + + // Observe contacts + search query together + contactRepository.getContacts(accountId) + .combine(searchQuery) { contacts, query -> + contacts to query + } + .debounce(80) + .collect { (contacts, query) -> + // Deduplicate by JID — the roster sync can insert the same JID + // multiple times (e.g. once as OFFLINE seed + once from the server). + // Keep the entry whose presence is most available. + val presenceRank = mapOf( + PresenceStatus.ONLINE to 0, PresenceStatus.AWAY to 1, + PresenceStatus.DND to 2, PresenceStatus.XA to 3, + PresenceStatus.OFFLINE to 4 + ) + val deduped = contacts + .groupBy { it.jid } + .map { (_, dupes) -> + dupes.minByOrNull { presenceRank[it.presence] ?: 5 }!! + } + + val filtered = if (query.isBlank()) deduped + else deduped.filter { + it.jid.contains(query, ignoreCase = true) || + it.nickname.contains(query, ignoreCase = true) + } + _uiState.update { + it.copy( + allContacts = deduped, + filteredContacts = filtered, + isLoading = false + ) + } + } + } + } + + fun onSearchQueryChange(query: String) { + searchQuery.value = query + _uiState.update { it.copy(searchQuery = query) } + } + + fun showAddDialog() = _uiState.update { it.copy(showAddDialog = true) } + fun hideAddDialog() = _uiState.update { it.copy(showAddDialog = false) } + + fun addContact(accountId: Long, jid: String, nickname: String) { + viewModelScope.launch { + try { + contactRepository.addContact( + Contact( + accountId = accountId, + jid = jid, + nickname = nickname, + presence = PresenceStatus.OFFLINE, + statusMessage = "", + avatarUrl = null, + groups = emptyList() + ) + ) + hideAddDialog() + } catch (e: Exception) { + _uiState.update { it.copy(error = "Failed to add contact: ${e.message}") } + } + } + } + + fun removeContact(accountId: Long, jid: String) { + viewModelScope.launch { + try { + contactRepository.removeContact(accountId, jid) + } catch (e: Exception) { + _uiState.update { it.copy(error = "Failed to remove contact: ${e.message}") } + } + } + } + + fun syncRoster(accountId: Long) { + viewModelScope.launch { + try { + contactRepository.syncRoster(accountId) + } catch (e: Exception) { + _uiState.update { it.copy(error = "Sync failed: ${e.message}") } + } + } + } +} + diff --git a/app/src/main/java/com/manalejandro/alejabber/ui/navigation/NavGraph.kt b/app/src/main/java/com/manalejandro/alejabber/ui/navigation/NavGraph.kt new file mode 100644 index 0000000..eace68a --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/ui/navigation/NavGraph.kt @@ -0,0 +1,154 @@ +package com.manalejandro.alejabber.ui.navigation + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.manalejandro.alejabber.ui.accounts.AccountsScreen +import com.manalejandro.alejabber.ui.accounts.AddEditAccountScreen +import com.manalejandro.alejabber.ui.chat.ChatScreen +import com.manalejandro.alejabber.ui.contacts.ContactsScreen +import com.manalejandro.alejabber.ui.rooms.RoomsScreen +import com.manalejandro.alejabber.ui.settings.SettingsScreen + +/** All navigable destinations in the app. */ +sealed class Screen(val route: String) { + /** Account list — the app home screen. */ + object Accounts : Screen("accounts") + + /** Add a new XMPP account. */ + object AddAccount : Screen("add_account") + + /** Edit an existing account by its database id. */ + object EditAccount : Screen("edit_account/{accountId}") { + fun createRoute(accountId: Long) = "edit_account/$accountId" + } + + /** + * Contact list for a specific account. + * Navigated to after the user taps a connected account. + */ + object Contacts : Screen("contacts/{accountId}") { + fun createRoute(accountId: Long) = "contacts/$accountId" + } + + /** MUC room list — accessible via bottom nav. */ + object Rooms : Screen("rooms") + + /** + * Chat screen for a 1-to-1 conversation or a MUC room. + * + * @param accountId The local account used to send messages. + * @param jid The bare JID of the contact or room. + * @param isRoom True when [jid] represents a MUC room. + */ + object Chat : Screen("chat/{accountId}/{jid}/{isRoom}") { + fun createRoute(accountId: Long, jid: String, isRoom: Boolean = false) = + "chat/$accountId/${jid.replace("/", "%2F")}/$isRoom" + } + + /** Application settings. */ + object Settings : Screen("settings") +} + +@Composable +fun AleJabberNavGraph( + navController: NavHostController, + startDestination: String = Screen.Accounts.route +) { + NavHost( + navController = navController, + startDestination = startDestination, + enterTransition = { + slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(280)) + }, + exitTransition = { fadeOut(tween(180)) }, + popEnterTransition = { fadeIn(tween(180)) }, + popExitTransition = { + slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(280)) + } + ) { + // ── Accounts ───────────────────────────────────────────────────────── + composable(Screen.Accounts.route) { + AccountsScreen( + onAddAccount = { navController.navigate(Screen.AddAccount.route) }, + onEditAccount = { id -> navController.navigate(Screen.EditAccount.createRoute(id)) }, + onOpenContacts = { accountId -> + navController.navigate(Screen.Contacts.createRoute(accountId)) + } + ) + } + + // ── Add account ─────────────────────────────────────────────────────── + composable(Screen.AddAccount.route) { + AddEditAccountScreen( + accountId = null, + onNavigateBack = { navController.popBackStack() } + ) + } + + // ── Edit account ────────────────────────────────────────────────────── + composable( + route = Screen.EditAccount.route, + arguments = listOf(navArgument("accountId") { type = NavType.LongType }) + ) { back -> + AddEditAccountScreen( + accountId = back.arguments?.getLong("accountId"), + onNavigateBack = { navController.popBackStack() } + ) + } + + // ── Contacts for one account ────────────────────────────────────────── + composable( + route = Screen.Contacts.route, + arguments = listOf(navArgument("accountId") { type = NavType.LongType }) + ) { back -> + val accountId = back.arguments!!.getLong("accountId") + ContactsScreen( + accountId = accountId, + onNavigateToChat = { accId, jid -> + navController.navigate(Screen.Chat.createRoute(accId, jid)) + }, + onNavigateBack = { navController.popBackStack() } + ) + } + + // ── Rooms ───────────────────────────────────────────────────────────── + composable(Screen.Rooms.route) { + RoomsScreen( + onNavigateToRoom = { accountId, jid -> + navController.navigate(Screen.Chat.createRoute(accountId, jid, isRoom = true)) + } + ) + } + + // ── Chat ────────────────────────────────────────────────────────────── + composable( + route = Screen.Chat.route, + arguments = listOf( + navArgument("accountId") { type = NavType.LongType }, + navArgument("jid") { type = NavType.StringType }, + navArgument("isRoom") { type = NavType.BoolType } + ) + ) { back -> + ChatScreen( + accountId = back.arguments!!.getLong("accountId"), + conversationJid = back.arguments!!.getString("jid")!!, + isRoom = back.arguments!!.getBoolean("isRoom"), + onNavigateBack = { navController.popBackStack() } + ) + } + + // ── Settings ────────────────────────────────────────────────────────── + composable(Screen.Settings.route) { + SettingsScreen(onNavigateBack = { navController.popBackStack() }) + } + } +} + diff --git a/app/src/main/java/com/manalejandro/alejabber/ui/rooms/RoomsScreen.kt b/app/src/main/java/com/manalejandro/alejabber/ui/rooms/RoomsScreen.kt new file mode 100644 index 0000000..33ee2a8 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/ui/rooms/RoomsScreen.kt @@ -0,0 +1,303 @@ +package com.manalejandro.alejabber.ui.rooms + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ExitToApp +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.manalejandro.alejabber.R +import com.manalejandro.alejabber.domain.model.Account +import com.manalejandro.alejabber.domain.model.Room +import com.manalejandro.alejabber.ui.components.InitialsAvatar +import com.manalejandro.alejabber.ui.contacts.EmptyState + +/** + * Displays joined MUC rooms for all connected accounts. + * + * If no account is connected the screen shows an instructional empty-state instead + * of the room list, and the FAB is hidden (there's no server to join a room on). + * Once at least one account is online the FAB appears and lets the user join a room. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RoomsScreen( + onNavigateToRoom: (Long, String) -> Unit, + viewModel: RoomsViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var roomToLeave by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + TopAppBar(title = { Text(stringResource(R.string.rooms_title)) }) + }, + floatingActionButton = { + // Only show FAB when there is at least one connected account + if (uiState.hasConnectedAccount) { + FloatingActionButton( + onClick = viewModel::showJoinDialog, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) { + Icon(Icons.Default.Add, stringResource(R.string.join_room)) + } + } + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + when { + // ── Loading ──────────────────────────────────────────────── + uiState.isLoading -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + + // ── No connected account ─────────────────────────────────── + !uiState.hasConnectedAccount -> { + EmptyState( + icon = Icons.Default.CloudOff, + message = "Connect to an XMPP account first.\n" + + "Go to Accounts, then tap the cloud icon to connect.", + modifier = Modifier.align(Alignment.Center) + ) + } + + // ── Connected but no rooms yet ───────────────────────────── + uiState.rooms.isEmpty() -> { + EmptyState( + icon = Icons.Default.Forum, + message = stringResource(R.string.rooms_empty), + actionLabel = stringResource(R.string.join_room), + onAction = viewModel::showJoinDialog, + modifier = Modifier.align(Alignment.Center) + ) + } + + // ── Room list ────────────────────────────────────────────── + else -> { + LazyColumn( + contentPadding = PaddingValues(vertical = 8.dp), + modifier = Modifier.fillMaxSize() + ) { + items(uiState.rooms, key = { "${it.accountId}_${it.jid}" }) { room -> + RoomItem( + room = room, + onClick = { onNavigateToRoom(room.accountId, room.jid) }, + onLeave = { roomToLeave = room } + ) + } + item { Spacer(Modifier.height(88.dp)) } + } + } + } + } + } + + // ── Confirm leave room dialog ────────────────────────────────────────── + roomToLeave?.let { room -> + val displayName = room.name.ifBlank { room.jid } + AlertDialog( + onDismissRequest = { roomToLeave = null }, + icon = { Icon(Icons.AutoMirrored.Filled.ExitToApp, null, tint = MaterialTheme.colorScheme.error) }, + title = { Text("Leave room?") }, + text = { + Text( + "Leave \"$displayName\"?\n\nYou will no longer receive messages from this room.", + style = MaterialTheme.typography.bodyMedium + ) + }, + confirmButton = { + TextButton(onClick = { + viewModel.leaveRoom(room.accountId, room.jid) + roomToLeave = null + }) { Text("Leave", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { roomToLeave = null }) { Text("Cancel") } + } + ) + } + + // ── Join-room dialog ─────────────────────────────────────────────────── + if (uiState.showJoinDialog) { + JoinRoomDialog( + connectedAccounts = uiState.connectedAccounts, + onDismiss = viewModel::hideJoinDialog, + onJoin = { accountId, jid, nickname, password -> + viewModel.joinRoom(accountId, jid, nickname, password) + } + ) + } + + // ── Error snackbar ───────────────────────────────────────────────────── + uiState.error?.let { msg -> + LaunchedEffect(msg) { viewModel.clearError() } + } +} + +// ─── RoomItem ────────────────────────────────────────────────────────────── + +@Composable +fun RoomItem(room: Room, onClick: () -> Unit, onLeave: () -> Unit) { + var menuExpanded by remember { mutableStateOf(false) } + val displayName = room.name.ifBlank { room.jid } + + ListItem( + headlineContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(displayName, fontWeight = FontWeight.Medium) + if (room.unreadCount > 0) { + Spacer(Modifier.width(8.dp)) + Badge { Text(room.unreadCount.toString()) } + } + } + }, + supportingContent = { + Text( + room.topic.ifBlank { room.lastMessage.ifBlank { room.jid } }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + }, + leadingContent = { InitialsAvatar(name = displayName, size = 48.dp) }, + trailingContent = { + Box { + IconButton(onClick = { menuExpanded = true }) { + Icon(Icons.Default.MoreVert, stringResource(R.string.more_options)) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false } + ) { + DropdownMenuItem( + text = { + Text( + stringResource(R.string.leave_room), + color = MaterialTheme.colorScheme.error + ) + }, + leadingIcon = { + Icon( + Icons.AutoMirrored.Filled.ExitToApp, null, + tint = MaterialTheme.colorScheme.error + ) + }, + onClick = { menuExpanded = false; onLeave() } + ) + } + } + }, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + ) + HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant) +} + +// ─── JoinRoomDialog ──────────────────────────────────────────────────────── + +/** + * Dialog for joining a MUC room. + * [connectedAccounts] are the only accounts eligible — disconnected ones are excluded. + */ +@Composable +fun JoinRoomDialog( + connectedAccounts: List, + onDismiss: () -> Unit, + onJoin: (Long, String, String, String) -> Unit +) { + var selectedAccountId by remember { + mutableStateOf(connectedAccounts.firstOrNull()?.id ?: 0L) + } + var roomJid by remember { mutableStateOf("") } + var nickname by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var accountMenuExpanded by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.join_room)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + + // Account selector (always shown so user knows which account is used) + Box { + OutlinedTextField( + value = connectedAccounts.find { it.id == selectedAccountId }?.jid ?: "", + onValueChange = {}, + readOnly = true, + label = { Text("Account") }, + trailingIcon = { + IconButton(onClick = { accountMenuExpanded = true }) { + Icon(Icons.Default.ArrowDropDown, null) + } + }, + modifier = Modifier.fillMaxWidth() + ) + DropdownMenu( + expanded = accountMenuExpanded, + onDismissRequest = { accountMenuExpanded = false } + ) { + connectedAccounts.forEach { acc -> + DropdownMenuItem( + text = { Text(acc.jid) }, + onClick = { selectedAccountId = acc.id; accountMenuExpanded = false } + ) + } + } + } + + OutlinedTextField( + value = roomJid, + onValueChange = { roomJid = it }, + label = { Text(stringResource(R.string.room_jid)) }, + placeholder = { Text("room@conference.example.com") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = nickname, + onValueChange = { nickname = it }, + label = { Text(stringResource(R.string.room_nickname)) }, + placeholder = { Text("Your display name in the room") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(R.string.room_password) + " (optional)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton( + onClick = { + if (roomJid.isNotBlank() && nickname.isNotBlank()) + onJoin(selectedAccountId, roomJid.trim(), nickname.trim(), password) + }, + enabled = roomJid.contains("@") && nickname.isNotBlank() + ) { Text(stringResource(R.string.join_room)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } + } + ) +} diff --git a/app/src/main/java/com/manalejandro/alejabber/ui/rooms/RoomsViewModel.kt b/app/src/main/java/com/manalejandro/alejabber/ui/rooms/RoomsViewModel.kt new file mode 100644 index 0000000..5121fc8 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/ui/rooms/RoomsViewModel.kt @@ -0,0 +1,96 @@ +package com.manalejandro.alejabber.ui.rooms + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.manalejandro.alejabber.data.repository.AccountRepository +import com.manalejandro.alejabber.data.repository.RoomRepository +import com.manalejandro.alejabber.domain.model.Account +import com.manalejandro.alejabber.domain.model.ConnectionStatus +import com.manalejandro.alejabber.domain.model.Room +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class RoomsUiState( + /** All accounts, used to detect connected ones. */ + val accounts: List = emptyList(), + val rooms: List = emptyList(), + val isLoading: Boolean = true, + val showJoinDialog: Boolean = false, + val error: String? = null +) { + /** True when at least one account has an active XMPP connection. */ + val hasConnectedAccount: Boolean + get() = accounts.any { + it.status == ConnectionStatus.ONLINE || + it.status == ConnectionStatus.AWAY || + it.status == ConnectionStatus.DND + } + + /** Only accounts that are currently connected — used to populate the join-room dialog. */ + val connectedAccounts: List + get() = accounts.filter { + it.status == ConnectionStatus.ONLINE || + it.status == ConnectionStatus.AWAY || + it.status == ConnectionStatus.DND + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class RoomsViewModel @Inject constructor( + private val accountRepository: AccountRepository, + private val roomRepository: RoomRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(RoomsUiState(isLoading = true)) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { + accountRepository.getAllAccounts().collect { accounts -> + _uiState.update { it.copy(accounts = accounts) } + if (accounts.isNotEmpty()) loadRooms(accounts.map { a -> a.id }) + else _uiState.update { it.copy(isLoading = false) } + } + } + } + + private fun loadRooms(accountIds: List) { + viewModelScope.launch { + val flows = accountIds.map { id -> roomRepository.getRooms(id) } + combine(flows) { arrays -> arrays.flatMap { it } } + .collect { rooms -> + _uiState.update { it.copy(rooms = rooms, isLoading = false) } + } + } + } + + fun showJoinDialog() = _uiState.update { it.copy(showJoinDialog = true) } + fun hideJoinDialog() = _uiState.update { it.copy(showJoinDialog = false) } + + fun joinRoom(accountId: Long, roomJid: String, nickname: String, password: String = "") { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + try { + val success = roomRepository.joinRoom(accountId, roomJid, nickname, password) + if (success) { + roomRepository.saveRoom( + Room(accountId = accountId, jid = roomJid, nickname = nickname, isJoined = true) + ) + } + _uiState.update { it.copy(isLoading = false, showJoinDialog = false) } + } catch (e: Exception) { + _uiState.update { it.copy(isLoading = false, error = e.message) } + } + } + } + + fun leaveRoom(accountId: Long, roomJid: String) { + viewModelScope.launch { roomRepository.leaveRoom(accountId, roomJid) } + } + + fun clearError() = _uiState.update { it.copy(error = null) } +} diff --git a/app/src/main/java/com/manalejandro/alejabber/ui/settings/SettingsScreen.kt b/app/src/main/java/com/manalejandro/alejabber/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..3a12045 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/ui/settings/SettingsScreen.kt @@ -0,0 +1,240 @@ +package com.manalejandro.alejabber.ui.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.manalejandro.alejabber.R +import com.manalejandro.alejabber.domain.model.EncryptionType +import com.manalejandro.alejabber.ui.theme.AppTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onNavigateBack: () -> Unit, + viewModel: SettingsViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var showThemeDialog by remember { mutableStateOf(false) } + var showEncryptionDialog by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.settings_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + ) { + // Appearance section + SettingsSectionHeader(stringResource(R.string.settings_appearance)) + + SettingsItem( + icon = Icons.Default.Palette, + title = stringResource(R.string.settings_theme), + subtitle = uiState.appTheme.toDisplayName(), + onClick = { showThemeDialog = true } + ) + + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + + // Notifications section + SettingsSectionHeader(stringResource(R.string.settings_notifications)) + + SettingsSwitchItem( + icon = Icons.Default.Notifications, + title = stringResource(R.string.settings_notifications_messages), + checked = uiState.notificationsEnabled, + onCheckedChange = viewModel::setNotifications + ) + + SettingsSwitchItem( + icon = Icons.Default.Vibration, + title = stringResource(R.string.settings_notifications_vibrate), + checked = uiState.vibrateEnabled, + onCheckedChange = viewModel::setVibrate + ) + + SettingsSwitchItem( + icon = Icons.AutoMirrored.Filled.VolumeUp, + title = stringResource(R.string.settings_notifications_sound), + checked = uiState.soundEnabled, + onCheckedChange = viewModel::setSound + ) + + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + + // Encryption section + SettingsSectionHeader(stringResource(R.string.settings_encryption)) + + SettingsItem( + icon = Icons.Default.Lock, + title = stringResource(R.string.settings_default_encryption), + subtitle = uiState.defaultEncryption.name, + onClick = { showEncryptionDialog = true } + ) + + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + + // About section + SettingsSectionHeader(stringResource(R.string.settings_about)) + + SettingsItem( + icon = Icons.Default.Info, + title = stringResource(R.string.settings_version), + subtitle = "1.0.0", + onClick = {} + ) + + Spacer(Modifier.height(32.dp)) + } + } + + // Theme dialog + if (showThemeDialog) { + AlertDialog( + onDismissRequest = { showThemeDialog = false }, + title = { Text(stringResource(R.string.settings_theme)) }, + text = { + Column { + AppTheme.entries.forEach { theme -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + viewModel.setTheme(theme) + showThemeDialog = false + } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = uiState.appTheme == theme, onClick = { + viewModel.setTheme(theme) + showThemeDialog = false + }) + Spacer(Modifier.width(12.dp)) + Text(theme.toDisplayName()) + } + } + } + }, + confirmButton = { + TextButton(onClick = { showThemeDialog = false }) { Text(stringResource(R.string.cancel)) } + } + ) + } + + // Encryption default dialog + if (showEncryptionDialog) { + AlertDialog( + onDismissRequest = { showEncryptionDialog = false }, + title = { Text(stringResource(R.string.settings_default_encryption)) }, + text = { + Column { + EncryptionType.entries.forEach { type -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + viewModel.setDefaultEncryption(type) + showEncryptionDialog = false + } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = uiState.defaultEncryption == type, onClick = { + viewModel.setDefaultEncryption(type) + showEncryptionDialog = false + }) + Spacer(Modifier.width(12.dp)) + Text(type.name) + } + } + } + }, + confirmButton = { + TextButton(onClick = { showEncryptionDialog = false }) { Text(stringResource(R.string.cancel)) } + } + ) + } +} + +@Composable +fun SettingsSectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 16.dp, top = 20.dp, bottom = 4.dp) + ) +} + +@Composable +fun SettingsItem( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + subtitle: String = "", + onClick: () -> Unit +) { + ListItem( + headlineContent = { Text(title) }, + supportingContent = if (subtitle.isNotBlank()) { + { Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } else null, + leadingContent = { + Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + }, + modifier = Modifier.clickable(onClick = onClick) + ) +} + +@Composable +fun SettingsSwitchItem( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + ListItem( + headlineContent = { Text(title) }, + leadingContent = { + Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + }, + trailingContent = { + Switch(checked = checked, onCheckedChange = onCheckedChange) + } + ) +} + +fun AppTheme.toDisplayName(): String = when (this) { + AppTheme.SYSTEM -> "System Default" + AppTheme.LIGHT -> "Light" + AppTheme.DARK -> "Dark" +} + + + diff --git a/app/src/main/java/com/manalejandro/alejabber/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/manalejandro/alejabber/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..bbbca0c --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/ui/settings/SettingsViewModel.kt @@ -0,0 +1,87 @@ +package com.manalejandro.alejabber.ui.settings + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.manalejandro.alejabber.domain.model.EncryptionType +import com.manalejandro.alejabber.ui.theme.AppTheme +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class SettingsUiState( + val appTheme: AppTheme = AppTheme.SYSTEM, + val notificationsEnabled: Boolean = true, + val vibrateEnabled: Boolean = true, + val soundEnabled: Boolean = true, + val defaultEncryption: EncryptionType = EncryptionType.OMEMO +) + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val dataStore: DataStore +) : ViewModel() { + + companion object { + val KEY_THEME = stringPreferencesKey("app_theme") + val KEY_NOTIFICATIONS = booleanPreferencesKey("notifications") + val KEY_VIBRATE = booleanPreferencesKey("vibrate") + val KEY_SOUND = booleanPreferencesKey("sound") + val KEY_DEFAULT_ENCRYPTION = stringPreferencesKey("default_encryption") + } + + private val _uiState = MutableStateFlow(SettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { + dataStore.data.collect { prefs -> + _uiState.update { state -> + state.copy( + appTheme = try { AppTheme.valueOf(prefs[KEY_THEME] ?: "SYSTEM") } catch (e: Exception) { AppTheme.SYSTEM }, + notificationsEnabled = prefs[KEY_NOTIFICATIONS] ?: true, + vibrateEnabled = prefs[KEY_VIBRATE] ?: true, + soundEnabled = prefs[KEY_SOUND] ?: true, + defaultEncryption = try { EncryptionType.valueOf(prefs[KEY_DEFAULT_ENCRYPTION] ?: "OMEMO") } catch (e: Exception) { EncryptionType.OMEMO } + ) + } + } + } + } + + fun setTheme(theme: AppTheme) { + viewModelScope.launch { + dataStore.edit { it[KEY_THEME] = theme.name } + } + } + + fun setNotifications(enabled: Boolean) { + viewModelScope.launch { + dataStore.edit { it[KEY_NOTIFICATIONS] = enabled } + } + } + + fun setVibrate(enabled: Boolean) { + viewModelScope.launch { + dataStore.edit { it[KEY_VIBRATE] = enabled } + } + } + + fun setSound(enabled: Boolean) { + viewModelScope.launch { + dataStore.edit { it[KEY_SOUND] = enabled } + } + } + + fun setDefaultEncryption(type: EncryptionType) { + viewModelScope.launch { + dataStore.edit { it[KEY_DEFAULT_ENCRYPTION] = type.name } + } + } +} + diff --git a/app/src/main/java/com/manalejandro/alejabber/ui/theme/Color.kt b/app/src/main/java/com/manalejandro/alejabber/ui/theme/Color.kt new file mode 100644 index 0000000..0f9740c --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/ui/theme/Color.kt @@ -0,0 +1,55 @@ +package com.manalejandro.alejabber.ui.theme + +import androidx.compose.ui.graphics.Color + +// Primary - Indigo/Blue +val Primary80 = Color(0xFFB0C4FF) +val Primary40 = Color(0xFF3A5BCC) +val PrimaryContainer80 = Color(0xFFDCE4FF) +val PrimaryContainer40 = Color(0xFF1B3AA0) + +// Secondary - Teal +val Secondary80 = Color(0xFFB3EFEF) +val Secondary40 = Color(0xFF1A7A7A) +val SecondaryContainer80 = Color(0xFFCCF5F5) +val SecondaryContainer40 = Color(0xFF0A5858) + +// Tertiary - Violet +val Tertiary80 = Color(0xFFD4B8FF) +val Tertiary40 = Color(0xFF6636B8) + +// Error +val Error80 = Color(0xFFFFB4AB) +val Error40 = Color(0xFFBA1A1A) + +// Neutral +val NeutralVariant80 = Color(0xFFC6C6D0) +val NeutralVariant40 = Color(0xFF46464F) + +// Background / Surface +val BackgroundLight = Color(0xFFFBFBFF) +val BackgroundDark = Color(0xFF1B1B1F) +val SurfaceLight = Color(0xFFFBFBFF) +val SurfaceDark = Color(0xFF1B1B1F) +val SurfaceVariantLight = Color(0xFFE4E1EC) +val SurfaceVariantDark = Color(0xFF47464F) +val OnSurfaceVariantLight = Color(0xFF47464F) +val OnSurfaceVariantDark = Color(0xFFC8C5D0) + +// Encryption badge colors +val EncryptionOtr = Color(0xFF4CAF50) +val EncryptionOmemo = Color(0xFF2196F3) +val EncryptionPgp = Color(0xFF9C27B0) +val EncryptionNone = Color(0xFF9E9E9E) + +// Status colors +val StatusOnline = Color(0xFF4CAF50) +val StatusAway = Color(0xFFFF9800) +val StatusDnd = Color(0xFFF44336) +val StatusOffline = Color(0xFF9E9E9E) + +// Chat bubble colors +val BubbleSent = Color(0xFF3A5BCC) +val BubbleReceived = Color(0xFFE4E1EC) +val BubbleSentDark = Color(0xFF5E7CE2) +val BubbleReceivedDark = Color(0xFF47464F) diff --git a/app/src/main/java/com/manalejandro/alejabber/ui/theme/Theme.kt b/app/src/main/java/com/manalejandro/alejabber/ui/theme/Theme.kt new file mode 100644 index 0000000..0c751b7 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/ui/theme/Theme.kt @@ -0,0 +1,87 @@ +package com.manalejandro.alejabber.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Primary80, + onPrimary = Color(0xFF002082), + primaryContainer = PrimaryContainer40, + onPrimaryContainer = PrimaryContainer80, + secondary = Secondary80, + onSecondary = Color(0xFF003737), + secondaryContainer = SecondaryContainer40, + onSecondaryContainer = SecondaryContainer80, + tertiary = Tertiary80, + onTertiary = Color(0xFF3B0083), + error = Error80, + onError = Color(0xFF690005), + background = BackgroundDark, + onBackground = Color(0xFFE4E1E6), + surface = SurfaceDark, + onSurface = Color(0xFFE4E1E6), + surfaceVariant = SurfaceVariantDark, + onSurfaceVariant = OnSurfaceVariantDark, + outline = Color(0xFF918F9A) +) + +private val LightColorScheme = lightColorScheme( + primary = Primary40, + onPrimary = Color.White, + primaryContainer = PrimaryContainer80, + onPrimaryContainer = PrimaryContainer40, + secondary = Secondary40, + onSecondary = Color.White, + secondaryContainer = SecondaryContainer80, + onSecondaryContainer = SecondaryContainer40, + tertiary = Tertiary40, + onTertiary = Color.White, + error = Error40, + onError = Color.White, + background = BackgroundLight, + onBackground = Color(0xFF1B1B1F), + surface = SurfaceLight, + onSurface = Color(0xFF1B1B1F), + surfaceVariant = SurfaceVariantLight, + onSurfaceVariant = OnSurfaceVariantLight, + outline = Color(0xFF77767F) +) + +enum class AppTheme { SYSTEM, LIGHT, DARK } + +@Composable +fun AleJabberTheme( + appTheme: AppTheme = AppTheme.SYSTEM, + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val darkTheme = when (appTheme) { + AppTheme.DARK -> true + AppTheme.LIGHT -> false + AppTheme.SYSTEM -> isSystemInDarkTheme() + } + + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/manalejandro/alejabber/ui/theme/Type.kt b/app/src/main/java/com/manalejandro/alejabber/ui/theme/Type.kt new file mode 100644 index 0000000..c1518b0 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.manalejandro.alejabber.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..c6c3ea1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,18 @@ + + + + + + + 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..d01c9ae --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..652a456 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..e44e929 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..eac2928 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..fc44ff7 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..344d18c Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..2e92f95 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..3aca857 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..e9a988e Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..b65438f Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..75e21dd Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..c93af9c --- /dev/null +++ b/app/src/main/res/values-es/strings.xml @@ -0,0 +1,156 @@ + + AleJabber + + + Contactos + Salas + Cuentas + Ajustes + + + Cuentas + Añadir cuenta + Editar cuenta + Eliminar cuenta + Usuario (JID) + Contraseña + Servidor + Puerto + Usar TLS + Conectado + Desconectado + Conectando… + Error de conexión + Conectar + Desconectar + No hay cuentas configuradas.\nPulsa + para añadir una. + ¿Eliminar la cuenta %1$s? + usuario@ejemplo.com + Recurso + AleJabber + + + Contactos + Buscar contactos… + Sin contactos.\nAñade personas con su ID de Jabber. + Añadir contacto + ID de Jabber (JID) + Apodo + Eliminar contacto + Bloquear contacto + En línea + Ausente + No molestar + Desconectado + + + Salas + Buscar salas… + No te has unido a ninguna sala. + Unirse a sala + JID de la sala + Tu apodo + Contraseña (opcional) + Salir de la sala + Participantes + Tema + Explorar salas + + + Escribe un mensaje… + Enviar + Adjuntar archivo + Grabar audio + Detener grabación + Enviar audio + Cancelar audio + Sin cifrado + OTR + OMEMO + OpenPGP + Seleccionar cifrado + Entregado + Leído + Enviando… + Error al enviar + %1$s está escribiendo… + Imagen + Vídeo + Audio + Archivo + Subiendo… + Descargar + Sin mensajes aún.\n¡Di hola! + Sesión OTR iniciada. La conversación está cifrada. + Sesión OTR finalizada. + Advertencia: Huella OTR no verificada. + OMEMO: Todos los dispositivos son de confianza. + OMEMO: Dispositivos no verificados detectados. + + + Ajustes + Apariencia + Tema + Por defecto del sistema + Claro + Oscuro + Idioma + English + Español + 中文 + Notificaciones + Notificaciones de mensajes + Vibrar + Sonido + Cifrado + Dispositivos OMEMO + Claves OpenPGP + Huellas OTR + Cifrado por defecto + Acerca de + Versión + + + Aceptar + Cancelar + Guardar + Eliminar + Confirmar + Error + Cargando… + Reintentar + Cerrar + Buscar + Limpiar + Atrás + Más opciones + + + Permiso de micrófono + AleJabber necesita acceso al micrófono para grabar mensajes de audio. + Permiso de almacenamiento + AleJabber necesita acceso al almacenamiento para enviar y recibir archivos. + Permiso de cámara + AleJabber necesita acceso a la cámara para tomar fotos. + Permiso denegado. Por favor, concédelo en Ajustes. + Abrir Ajustes + + + Mensajes + Mensajes de chat entrantes + Servicio XMPP + Conexión XMPP en segundo plano + AleJabber está conectado + Nuevo mensaje de %1$s + + + Avatar de %1$s + Estado: %1$s + Cifrado: %1$s + Enviar mensaje + Adjuntar archivo + Grabar mensaje de audio + Mensaje entregado + Mensaje leído + + diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml new file mode 100644 index 0000000..e21b518 --- /dev/null +++ b/app/src/main/res/values-zh/strings.xml @@ -0,0 +1,156 @@ + + AleJabber + + + 联系人 + 聊天室 + 账号 + 设置 + + + 账号管理 + 添加账号 + 编辑账号 + 删除账号 + 用户名 (JID) + 密码 + 服务器 + 端口 + 使用 TLS + 已连接 + 已断开 + 连接中… + 连接错误 + 连接 + 断开连接 + 尚未配置账号。\n点击 + 添加账号。 + 删除账号 %1$s? + user@example.com + 资源 + AleJabber + + + 联系人 + 搜索联系人… + 暂无联系人。\n通过 Jabber ID 添加好友。 + 添加联系人 + Jabber ID (JID) + 昵称 + 删除联系人 + 屏蔽联系人 + 在线 + 离开 + 请勿打扰 + 离线 + + + 聊天室 + 搜索聊天室… + 尚未加入任何聊天室。 + 加入聊天室 + 聊天室 JID + 您的昵称 + 聊天室密码(可选) + 退出聊天室 + 参与者 + 主题 + 浏览聊天室 + + + 输入消息… + 发送 + 附件 + 录音 + 停止录音 + 发送音频 + 取消录音 + 无加密 + OTR + OMEMO + OpenPGP + 选择加密方式 + 已送达 + 已读 + 发送中… + 发送失败 + %1$s 正在输入… + 图片 + 视频 + 音频 + 文件 + 上传中… + 下载 + 暂无消息。\n说声你好! + OTR 会话已开始,您的对话已加密。 + OTR 会话已结束。 + 警告:OTR 指纹未验证。 + OMEMO:所有设备已信任。 + OMEMO:检测到不受信任的设备。 + + + 设置 + 外观 + 主题 + 跟随系统 + 浅色 + 深色 + 语言 + English + Español + 中文 + 通知 + 消息通知 + 震动 + 声音 + 加密 + OMEMO 设备 + OpenPGP 密钥 + OTR 指纹 + 默认加密方式 + 关于 + 版本 + + + 确定 + 取消 + 保存 + 删除 + 确认 + 错误 + 加载中… + 重试 + 关闭 + 搜索 + 清除 + 返回 + 更多选项 + + + 麦克风权限 + AleJabber 需要麦克风权限来录制语音消息。 + 存储权限 + AleJabber 需要存储权限来发送和接收文件。 + 相机权限 + AleJabber 需要相机权限来拍照。 + 权限被拒绝,请在设置中授予权限。 + 打开设置 + + + 消息 + 收到的聊天消息 + XMPP 服务 + 后台 XMPP 连接 + AleJabber 已连接 + 来自 %1$s 的新消息 + + + %1$s 的头像 + 状态:%1$s + 加密:%1$s + 发送消息 + 附件 + 录制语音消息 + 消息已送达 + 消息已读 + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..98eea79 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #24308B + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..7cbcfba --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,156 @@ + + AleJabber + + + Contacts + Rooms + Accounts + Settings + + + Accounts + Add Account + Edit Account + Delete Account + Username (JID) + Password + Server + Port + Use TLS + Online + Offline + Connecting… + Connection Error + Connect + Disconnect + No accounts configured.\nTap + to add one. + Delete account %1$s? + user@example.com + Resource + AleJabber + + + Contacts + Search contacts… + No contacts yet.\nAdd people using their Jabber ID. + Add Contact + Jabber ID (JID) + Nickname + Remove Contact + Block Contact + Online + Away + Do Not Disturb + Offline + + + Rooms + Search rooms… + No rooms joined yet. + Join Room + Room JID + Your Nickname + Room Password (optional) + Leave Room + Participants + Topic + Browse Rooms + + + Type a message… + Send + Attach file + Record audio + Stop recording + Send audio + Cancel audio + No encryption + OTR + OMEMO + OpenPGP + Select encryption + Delivered + Read + Sending… + Failed to send + %1$s is typing… + Image + Video + Audio + File + Uploading… + Download + No messages yet.\nSay hello! + OTR session started. Your conversation is now encrypted. + OTR session ended. + Warning: OTR fingerprint not verified. + OMEMO: All devices trusted. + OMEMO: Untrusted devices detected. + + + Settings + Appearance + Theme + System Default + Light + Dark + Language + English + Español + 中文 + Notifications + Message notifications + Vibrate + Sound + Encryption + OMEMO Devices + OpenPGP Keys + OTR Fingerprints + Default encryption + About + Version + + + OK + Cancel + Save + Delete + Confirm + Error + Loading… + Retry + Close + Search + Clear + Back + More options + + + Microphone Permission + AleJabber needs microphone access to record audio messages. + Storage Permission + AleJabber needs storage access to send and receive files. + Camera Permission + AleJabber needs camera access to take photos. + Permission denied. Please grant it in Settings. + Open Settings + + + Messages + Incoming chat messages + XMPP Service + Background XMPP connection + AleJabber is connected + New message from %1$s + + + %1$s\'s avatar + Status: %1$s + Encryption: %1$s + Send message + Attach file + Record audio message + Message delivered + Message read + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..79bd122 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +