From 60865ce6292de1507e4515bcfbdf43c74509f8ee Mon Sep 17 00:00:00 2001 From: ale Date: Sat, 28 Feb 2026 01:40:45 +0100 Subject: [PATCH] initial commit Signed-off-by: ale --- .gitignore | 15 + README.md | 354 +++++++ app/.gitignore | 1 + app/build.gradle.kts | 119 +++ app/proguard-rules.pro | 21 + .../alejabber/ExampleInstrumentedTest.kt | 24 + app/src/main/AndroidManifest.xml | 73 ++ app/src/main/assets/ic_launcher_logo.svg | 72 ++ app/src/main/ic_launcher-playstore.png | Bin 0 -> 25048 bytes .../manalejandro/alejabber/AleJabberApp.kt | 49 + .../manalejandro/alejabber/MainActivity.kt | 144 +++ .../alejabber/data/local/AppDatabase.kt | 67 ++ .../alejabber/data/local/dao/AccountDao.kt | 30 + .../alejabber/data/local/dao/ContactDao.kt | 36 + .../alejabber/data/local/dao/MessageDao.kt | 36 + .../alejabber/data/local/dao/RoomDao.kt | 36 + .../data/local/entity/AccountEntity.kt | 19 + .../data/local/entity/ContactEntity.kt | 41 + .../alejabber/data/local/entity/Mappers.kt | 70 ++ .../data/local/entity/MessageEntity.kt | 42 + .../alejabber/data/local/entity/RoomEntity.kt | 35 + .../data/remote/XmppConnectionManager.kt | 290 ++++++ .../data/repository/AccountRepository.kt | 58 ++ .../data/repository/ContactRepository.kt | 97 ++ .../data/repository/MessageRepository.kt | 74 ++ .../data/repository/RoomRepository.kt | 78 ++ .../manalejandro/alejabber/di/AppModule.kt | 36 + .../alejabber/di/DatabaseModule.kt | 41 + .../alejabber/domain/model/Account.kt | 20 + .../alejabber/domain/model/Contact.kt | 23 + .../alejabber/domain/model/Message.kt | 41 + .../alejabber/domain/model/Room.kt | 36 + .../alejabber/media/AudioRecorder.kt | 129 +++ .../alejabber/media/HttpUploadManager.kt | 84 ++ .../alejabber/service/BootReceiver.kt | 16 + .../service/XmppForegroundService.kt | 126 +++ .../alejabber/ui/accounts/AccountsScreen.kt | 337 +++++++ .../ui/accounts/AccountsViewModel.kt | 130 +++ .../ui/accounts/AddEditAccountScreen.kt | 199 ++++ .../alejabber/ui/chat/ChatScreen.kt | 945 ++++++++++++++++++ .../alejabber/ui/chat/ChatViewModel.kt | 165 +++ .../ui/components/AvatarComponents.kt | 105 ++ .../ui/components/EncryptionBadge.kt | 71 ++ .../alejabber/ui/contacts/ContactsScreen.kt | 463 +++++++++ .../ui/contacts/ContactsViewModel.kt | 145 +++ .../alejabber/ui/navigation/NavGraph.kt | 154 +++ .../alejabber/ui/rooms/RoomsScreen.kt | 303 ++++++ .../alejabber/ui/rooms/RoomsViewModel.kt | 96 ++ .../alejabber/ui/settings/SettingsScreen.kt | 240 +++++ .../ui/settings/SettingsViewModel.kt | 87 ++ .../manalejandro/alejabber/ui/theme/Color.kt | 55 + .../manalejandro/alejabber/ui/theme/Theme.kt | 87 ++ .../manalejandro/alejabber/ui/theme/Type.kt | 34 + .../res/drawable/ic_launcher_background.xml | 18 + .../res/drawable/ic_launcher_foreground.xml | 36 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1762 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 3480 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 1278 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 2228 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 2430 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 4768 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 3704 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 7494 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 5168 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 10298 bytes app/src/main/res/values-es/strings.xml | 156 +++ app/src/main/res/values-zh/strings.xml | 156 +++ app/src/main/res/values/colors.xml | 10 + .../res/values/ic_launcher_background.xml | 4 + app/src/main/res/values/strings.xml | 156 +++ app/src/main/res/values/themes.xml | 5 + app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 + app/src/main/res/xml/file_paths.xml | 7 + .../manalejandro/alejabber/ExampleUnitTest.kt | 17 + build.gradle.kts | 8 + gradle.properties | 24 + gradle/gradle-daemon-jvm.properties | 12 + gradle/libs.versions.toml | 82 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 9 + gradlew | 251 +++++ gradlew.bat | 94 ++ settings.gradle.kts | 26 + 86 files changed, 7162 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/.gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/java/com/manalejandro/alejabber/ExampleInstrumentedTest.kt create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/assets/ic_launcher_logo.svg create mode 100644 app/src/main/ic_launcher-playstore.png create mode 100644 app/src/main/java/com/manalejandro/alejabber/AleJabberApp.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/MainActivity.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/data/local/AppDatabase.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/data/local/dao/AccountDao.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/data/local/dao/ContactDao.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/data/local/dao/MessageDao.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/data/local/dao/RoomDao.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/data/local/entity/AccountEntity.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/data/local/entity/ContactEntity.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/data/local/entity/Mappers.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/data/local/entity/MessageEntity.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/data/local/entity/RoomEntity.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/data/remote/XmppConnectionManager.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/data/repository/AccountRepository.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/data/repository/ContactRepository.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/data/repository/MessageRepository.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/data/repository/RoomRepository.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/di/AppModule.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/di/DatabaseModule.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/domain/model/Account.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/domain/model/Contact.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/domain/model/Message.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/domain/model/Room.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/media/AudioRecorder.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/media/HttpUploadManager.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/service/BootReceiver.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/service/XmppForegroundService.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/ui/accounts/AccountsScreen.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/ui/accounts/AccountsViewModel.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/ui/accounts/AddEditAccountScreen.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/ui/chat/ChatScreen.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/ui/chat/ChatViewModel.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/ui/components/AvatarComponents.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/ui/components/EncryptionBadge.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/ui/contacts/ContactsScreen.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/ui/contacts/ContactsViewModel.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/ui/navigation/NavGraph.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/ui/rooms/RoomsScreen.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/ui/rooms/RoomsViewModel.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/ui/settings/SettingsScreen.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/ui/settings/SettingsViewModel.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/ui/theme/Color.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/manalejandro/alejabber/ui/theme/Type.kt create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/values-es/strings.xml create mode 100644 app/src/main/res/values-zh/strings.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/ic_launcher_background.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 app/src/test/java/com/manalejandro/alejabber/ExampleUnitTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/gradle-daemon-jvm.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts 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 0000000000000000000000000000000000000000..c2319c1f1310dadb7eb21a38e01254be817c5051 GIT binary patch literal 25048 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 p&+i3T= 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 0000000000000000000000000000000000000000..652a45606e50bbf7dd69673cbd527a1aad4f287b GIT binary patch literal 1762 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 GNC5z?sg;5N literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..eac29286fec0d2604cb43203c89427936d37f045 GIT binary patch literal 1278 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3aca85770c1da42cffed488ac28041d3729ee68d GIT binary patch literal 3704 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e9a988e18de9aecef056c86017d744c4c7335e66 GIT binary patch literal 7494 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 QDrZ)fNglHT0kLDP0N+T4ApigX literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b65438fc920bf476854a898685a0750154bb9a69 GIT binary patch literal 5168 zcmV-06wm8YNk&E}6aWBMMM6+kP&iB+6aWA(zrZgLRfpoXZ5(s|v9H&6L`;AaGdwS; zl_OQ;yCuO8nGJJpiw+&iii~T{ND`adtpHG!<;K_nGkwO9%*@OrW@ct)W@ct)W@cvk z%)2XQDf%;S?z{KSkAF0|seoswfJ8U8()HG`nRA9Mz?3U86@b40vxN=#OlXK4zbZsG zb4s%{Ob-s(F5>Grbpf;mOdT3iQK!$9q zKjhlBZQHhOBeFx=hD&Xw%ypO zZ5#WZuC=r6I;w4JS#S?P0*4Wd-7UvY3_1{q5kV;7Z~c1$=*Iu=$Zh^KOYglW{2kmb zIBbBec+-++BOF#h*1%ru;lDhD-irYC#w8B+8iZz4t8kY+9wYLF{o6`};WD`FL? zBU{3Ca1AI`>ZP0oTnBPjfO6k~eexa^+$dBH%8^2SVCGstjuzy-ml{w9E?2Z6i;&Bd zz7!>B=BiMnBJ7jP=ve{dt3a7DTfq%XD(~$Z#fUa#ADbz0+%}RUDgUT0fy-g-J$+Yq zjaeK7*|tqbJGSklZCkPJhd-E}f;Czwx2-Kx#@!uvcXxM4EHZ5>B!}ex0J?jE<}`Pg z=+?*QOaPg=%>#-5*>k-d%AZu!7LFk%79IY#JFHR7zWZOQuz+nGiz0U4ssI6+NY}Fabv_>TAHw0{E~1J}rPR3*gTJ_;v7JMTYCm z-5&XrkZ>5;4y3R?z=;L$ZUJP~0>IIWNnd=1kR4F%K!^p%wQk+I7QnDHFf0v-0-zE) zP+OZPgiy2|A!G+cVL~3MTlYy8z*H0%73pABD39P7DW(8~B#dTFnEfX5NCy$7O!x06A-!l;Sv$xMihc*0to=n;jdijsP>%x8j3a4 zR3wi?o=UhYSVZJkUDrcJmcWs{D*zP@V|YzZeb=HC={HfU2CqvY&>Ai;3VG#@8hrMM z^Hja}{Zfh{odcQa$X>o8!>s}yhd>Dl9(fc|TiI1UwmX0#vHGQ6j?3$(1W~Gn zAd7+S|6!eG_tPT?Z50XtWr929xV%nE5T?NpT!-QE!}Fln|M+-!Dll;;C5Ti1^#mP2#t z`HF`Y`E>}`7A?-rDjbP`RDxAy4M+|oIE}~b3PTh}d6+~5!lD|A8cC>FRg3@xgl_0y zVh9C@yfi|h;=9KoiU;KJ#N%)Yfbx4hp55+Vtk#f+kZ3}Q2pkA^5;>_-5(mhm1NH3Y zRHz(8&=_0=6U`k&bFLFhu3}G6~RtOGo(b6bPw>t#S&a#@%(KcQb$# z`7p&50@Bz{M|`)^Fd|O;Z?&_3o2mI%3T-@~H1aXQ>3=H}Tka=?&=8(f0@A3eBY)aI zEA?~uxjFhDlGc2$^k#c#T2gDXu@^kh&`iTm(X@(l1dW*fZ0W zn||bJ;b0(v)WQr8NJ~^h`m(u}5)oDnT&oKXJZ4WgKud46PlXNVmD#Lx=!VRJ9$Hcx zvnJm}lR~_BsYN8mD7|r9oK*yu+r6>~u}p|iDF{^XLV2Eoe4Z6ZZ)2im4RWh=@CI9D zc`HuSv<|L^lB8l~jexYyZK;(+2={7%W68jm#v;iq0sj&#v!IP;+MS$_klxxz%Ni9> zCb7cC3)0&dX+56$ND{d*PW(i2xFifFS*Eb6)k(DpOjZzMeE`8 zCw8p2DK5O!j*d(@w>!%-P#6zuH5>&DTp7s(_%ki5pHrp%*GR})a+0RCbDPHw62gU( z;9!0mtJP!|Hl-{9B5l?Q+HdpLo@1-x!5cFBIBAQuw`usZB=+osi39|Oed{BzxRdLx zG#zUs@>|%cfk?Uk*l)Fr&Q=w6TxKJC!U38#+r+aXNzWeY#evBo3u`6W z?VTW!jY!(`<1{U!lSS^*Q*K^XR#svAWmzNq(wpz0WpprG=&Ls(rGY&pngAXY9L!~s zw4Uz3*dUb=OrR;he~~u8OViR@?v>HOENhro_PC(T9(K3awEHi4^0p*Lu`gT#j)PDD zfE&8SoWe60+hxRh4>0C!(q^Be*-Ylhe>!$T}OWSx&${$~3SjMV?3q%kHr5KpoBx*C=hB5Y&B$fhY zn#mP`lwUqc`Qo*d_b;S;|6cO{eqOKB#SznR$P^-!b) zS}YL&0yjpj9zkvr(rGO6veI(0mkCE45deq}wO9ZkIx!k>E$Wn7iuYkwdoN7|Ng@&; zf~YAVI#(J{)$Y4I5wkvWy%Qw$0RbX_8U@k0)`Ds(U99mY4{J?CZj+)GECB$NqqPPI z+J(}9>GSQRn=$LHBzr7sl_04fhzLRe$c@peM*yzZh1P)RodZ=yVKzI+)-j=2_ve={ z1`$I5t#5!RhG}vt*JR{m>-^9JLBt?H%Z1jq?PQxVn+?YS<3kN-{2)}c_Cc!7fR)jh z?dD^%8C4EwN=%K#DVLTf=Yl`7U$>>#1-Y>R)ObaaW(q9FXxEMQ}_m2Jf&cAj6( zM2Vn9Ko&#ui(RCQ3C#)?kA!3jRjQfDhDmI!$!iKKgBHh4aA!CcbWz&LHepgXo5Q4l zBZFD%rUV8cFK+yh3{oAgk=RdC$NAN2qZN+`H^E0jF`drWc#|8G+*f%L6RjJgWB~$5 zF%;izFSh}cen-SYC<#>R3|H^Nq&FV9O|SF7vWf_C;miAhcyXh`Ftg6|MLXKdHu@1X zsh)QEVjyearWArY<3XK)t0PIh2xE5{x;8}esEu}Yt0Ja6pSa}DD7_H_!`FK;I|n_!80RZYd3s5*(xo>>Nr350Ye2P#RcR&N&5m=P zuw|-o4;>o~nCc8u8^oml{DhrdiUh6+VdpI{&1d3VUs}PTcD7}tz%{xf+nY%)*l1_l zH|Z&G4R>H4Y(3dQQs48RTs!+F{=GO$;2MDxMQqGxMe)@BWIC14bVvqTFKjx1e|`2RwerHPHzt0HKA>B$W#<;v=LI#`UX;OJTRr7H`;27L4a00 zl*adM_AQ>`;KwIV*f=vh>i|ICKq~#uWbl#m1WJDeff#V=EApBc^~jk?N5h_}nXnP}L#$1+(7& z_$AlIn!;ksi*W9lyb=K-5c7Ztlf)ce#_e9p^Hh;-+y7f@DstG>Mw{m;>#yInEdj)j zS~&>PmeKi)+r4z)N%0bL4O$(JS^dd0s*sJgq8C^#aJW-E<%Fsi5Fcusf}lgVG;X(h z{pVK<0$WSXW}02|ns|*xURIioZRXbLwnlo%r2@|ZZugQRLX)N*0ZHQ#pe7U$`7v&0V-p#{4FI$^XqhOh*uLFGqBRxRg0~F7y`L(03qugOZIC(H5ryu zjp-7MYZFMFgkvqv688M*C;1vkoO;G~=Z1GoPSB^#b86iwUJgURI?)SJAXc~2B{m{k zEy!|q)b6i54r7d+D{(r0bvhm1sswyFdO`t5cazYrs%F~x#A`Cf&Y3tJuR4aSJ#&R48MF!%x_JM_2N?P>+%HuHBK=XtqeJW>az+ynSXjf5gOucJOVwC{C8Xqd%6@}X#$vbr zkq&XSqe~jzXR42Jw$$Qy z)me0SJt~p+sheUWBom30M7n2L%Vfrjmla|9GkK1M2B$2pvyBdK93X?pTrRL7{4$+Z zb*PfBN{*-Pm+b}GR@FrPjd6}$olJ-J&(VY^ky#ZOStB4%Pqt9LD%IEB`K;JUhq-D5 zAtL*YaUz_ugh^sRm!*nnrn1=cUp$_RcS_k$hu9N^C_rRa1y(ktazsdCVvc2+>0abc zD~M42+HCP;;DN_mEleT;p%*VA29X@mQZK_Z%ul>HH}JJ)DiK1X2zlU$$Kk3`0;5x4 zMkq2Qsv#AEP_Cy`oJ(ln&1ZR=qf&)6TB2}HR2KR>7L*U&Ww<71A;5~D*dx? z2!gA)Qq~X-LU1RZR4yEb5WWD0T$>6#>awbVcez;1fcu;VOm4aCrrS{_+&}emYjMINI&VDG^ZMP^JM`Wv$QEA%x(r zVhVxkx}HGNP^1HLtVwp`0U%5Rw&WB6fTYbctP5pmHp~4g$?tXf6>vuIb0LAO5lh#M z_i6IM&zuXC73m;%W{)oI zyerg-paBE3fP@;r0uY@ZqqAeYo1$cs^8^I8xj^6WW@tfTmG1%KB0x-IF$4f&0A({g zHh_q3rM=U@RKxR)rE;+NymF2Nm%>P=4@At=IJVP^5vPlnLuLESgwM|4bDS?pb zl>mAGN5f`M1W1)2t4lxVNpR)y>&2N`QN%5P96m3SV@0|qII2T@dw%LP4*M!*9s5## zrOq%AYH9#xfN;Q`4A?U&Bq-?Ij|b!0{Z^tYuSstHJF4f2r1pry_P>LVq_%Ll>rJlK zf1=B;J^RCU$9Xs`L1D$8`#(EdGbusIbO1SkHWUCHi4yj#)e_`LkXs*0+VprwyRqdw zgiTKl!Rd*EKZH%cyngm~TMJj8_gNrR=Fu4r!mLVPC;$)-*wZBJ*-eH$M}q9atp&is z>&}v}<(yj@2>B{4MzBz;(iZ^;z5OtN!Aq5AO?bAKUh+4}_jxiEGtX8kp3%&_(iV4ggvCO)wgug9w(b2|+qO)$ z`hOiUNZozTma_MKYWBq4-QC^Y-QC^Y-QC^Y-JRv`_SReXBkxlyLSC#RG<0Y@Vvy;Z zHKENhw_#?5?0{=XrZYt%cS4uh+t7`!EkGN0cM6NOaEN<93fThEWjladM`*JRGh=Cr zM$i$XcVYFCn85}xiL^2@GOZ3tE$a>Fh{!|6sHAf;YqB1s?vT2>OG@tKYZQ`qh`X~3 zJqD3Gg%nXmq@Cdokw*}bF;<90+Tr4py&re?PsfBk#z!mrBy?+Aa&0RW4qBL4*yKbQ zWPwEj3~Xg8PabvkJzaZOwJvJ5tsRr3FKio?mA37OETqHnec$`s`HFJ=&b&w6v%q8S_ z1)ac1cW|63RiB~Z2rZr}RXJxS)M%iQs+JoXH8<6spt(!bImr=cCgwy%H&BP(QuP5G zFLCcK$3~z~UbVpxkC|a6+`}Uy zbA_U|ZEM?B+qP}nwrvJ) zJAx!NZkDKomIdV}-}=MJ_Ro>xCTD0dfeC6HL;*SH*hUxBQ01A~na#7b)EEByUw>Pl zbAnvVVBq}Q__;n6JV-lTxZuGZEk+Q45M-SII066=P(E_@X4|%H+vbYQ$!@JzSZllO zn%oEgXexh0#@;15L35kySdZDZt=YCFPi@-z&&X{gMU`PV%|dt257ebmP?TJaj*=&0 z2JpQZ>+H2u<3Kr``QaANd}Z-+UA!}2B~re#c3F#Cx^TV|%2(TRBCTM|QRUPHEWA`w zbX2l}wM3wYjFzAcb2I#334vErCiQuB^n^r-K@&tR^>uO z?nK}z1l~g63j}@^sedEz9|A)JMi59(&fd@UH@571efD2Be~@F|?0_AsGL<egKh-2v)GjL8xt0hVX;rSVs$4_h+icxV2mnc!EBkS)In4G&=oaxRMT%5Ohwho| zG4k_mYq+h|MJ=o zr)_-vUQ31))emA-97N!I-KHc2{7*WNNJff+#40K}&|iHcDX2@@_U9iuc0A>OJN7e0 zSy)wTTNPEUNyZ@Pvk3g7OG04qjUN{UnW*SM@~-dS<3{UWPWkhWn_G%IAOZ&=@aQXT z52z%lk5!k6(Ghol`~hv@`Hq@9Ebv@CqW?L5ex?3Of?a9?kBnf|CKF#uLycz{L4)3NNK`SM8}s!;7utW zf&Y^UVl4zSAO$wh`lDi53vc|EjV27|8b~=XljtJZ}j?3f!W}>E$qMZyz znYc8%{`$Ld?R2WD_08M}T&wHPz&ug!yu_s`sDfAnGf5=DPvc3te$lO{s*~R^foPAE zudt&^)0ArgPwAM{lJgTU;xsDkRL>!Tv$3OgUZQcKN91A$Srg`*Lv@CE4BIs#uj0JL zX$2ZDN-V@V_li_ilcM+wLyEo#{EqSZ6ACn*lqHbh#f-i_5z_}`iitR{(WtVM z#+h7q#W;fqYEqP#v1%DtW;)Gu2-u?Q&w!sUG!FHM(jcI}(t`rk))>?>sAU{kmD4P6 zEd3eTK>!nkOXE_m1Tg9uK@oCM(0XBBO1Mn57|J8?5u9VYX&Se>5#%iTPhEt1Z1h$o zKBmyHQx(Aa2z;mSCkUwUMl7erjEqnyBZK9=B-BO_(0+6g>Ya(5#ylK1oUaPTrU?82 z+c%j_=y?XxTaBb#Gc^my0!;v=r&oAVH=Yn{#Oi7Ctw#WhNcmy5Zc8=)f^PSt#E3r2 z^}$2>vsgTQVvI_1Eu?ZUfy&Es<484G+y!Es)&?HYV)$)lr){9S-LimSXad{X(&#;D z9y7>Gr5c`+4EhCeCRi*czfIn3mD$tH?Ud^SEi?p$vW_yEjS9-^Ey zioHygzHz$Lt_mobOH|vy&MA0gSK<3wkt2)%U&A~QT-n#0$4N^UlEtZ z?52ard^I3KZ-jdbXzph%7kd@a&P!Zu%GKH`p>n((fa-yh-7%pWK+17?b~2DHgKDz# zDyG~JO#zY>WeZe{)|whn!%Acj4Lx){8(1h(Sivv6ifZEp6#oxU4ivP|VzAo4K$!xD zl3fvjU!aw)zkP~lJC9dknLsz9XaW;x(hz}G|438BhMvO?TfXtsrY$kx0BUx1R z=J!n_{qCwceEp~SeEF^UfA*yXeEOxLxqkJFaee!rzWzK%_L0NTW61yPG|n6AQ`9iK zj3QJ!T~D?ri`YCoS?{ZPWQI9qW*s2Z08q-?bmP(bs%Ned$w*$U6f)ZdnLS;`nOV<_ zly{-Jka$|)%6i_d%jK&CV-Qph?ET&wZOR(RrBwI`Z`Gu$sQu=Mlu6Kxv$pn0$>YiT zpN*6Kw*nzRLy(Je2Bvfzt5!p2-X(ht3>O;v!Nm`KKBd6y^8~rwd7=At5ho;7`GZhK z0|oI@s+T7Q57ieKH=toSv}A3ilQen-l3F+Nmkf#mw=g=q%rl1`yj7+6Hy$G(vmc^* zSL6h85M(Rlt0=${?V^w6*zoI`QP)KiYGbC+drhnNnr`pKlTLnB=jIw<5Tsfw!)6;U z-UXatgH0c1?hsKq6ms_iP|IA<1@RmK(h3|C!nS%>>t;TS784;&i9<7D;7#`zFPH3r z2Eovrt`S7xiUuPH2pQT_rhK6Mzz~%aNM+`Qj9>lp1KpIhqzE{r|0ns3IfPoZ_y}@K zYFZqc2&cxvt&#BHNq5^@z{R<2u1P7WbUbzdm})-L?zn)8PziiE!rWIPVb?k+f?aw-{{beFlLf+o3&i}{Mz39-Id-XB5@k( zPr%m0tQ#5^CwJ3gSp1BMD9MCh7mWFShX@gBY6bY>2(8eTt79j!ZhO(^-0EsVCFJc~ z>7t}k(pAsO5n#1~YQGE1#QO-h``C)sb12gWwtQ(?IExH5u>^cxkk-pTcEhXRJdJXo zQYk{To$j9VEUG!@A4JFw{XZ#QZA`q6a=YuBeoET>TaS@ko-okmE3?gW$WWUXz@vIU zC?)k?Ad3QXm20~Fn*}W&WBu@8lo$ztFipQHx2s=9|1q#);SA5D-mV}Z z!9ybm3=BuXG9{a98Z<>^V(`h}gg@A2w8p>gj-AFdN%or}syzeB(uPJvy)!^TW|QX) zetq9SmamQBez%47EHcD1??@7WNB2Dt@)dF53h5ZQ*5v5`?`nP9g02|uElt@kMZfvE zn-eLX>Pf(q&lwACIp^~E#`CyNgivyB1HfbWZLw9Vq}uT%Q4NHE%ye6REgUbwPcJM& zF|hWs`)R3APW*2R@x%;jT$ZwIk^OqTfG-x=QzrRVd_La{SV4+-U>#GTkX(X0wC8N9 zZi{VOZre=b`Rl(9?9I_7u3>>>8k4N!*qrehAcwfUm{G|c$AR^6$5X)s91)v?8G_;pH6R)o3JIer> z`C`GnXxRHpBpc%Pt_Zp0)nEA zi*|Mlb-KqDiySGFJ39vc{}&|2a=%@KLgI;2Gi!$#)CdX`yeCcYs7`;;e;ewWLA3yw z=KGjh38ZxxtK84PG4H20ZLO-K?COt3PDdIk^+td~%&ANo#8mZ2gi7K^BKPlV zG*|uMpBm2O>RG%A8j-qYNUhckZX-L3mZyt}Dj;q9T;o62q|l^cW7UV}f*NOrxMG2= zY!uf=*ldvNr)J1)5n73V>E~;sQ7XDOQy%P6%KypFGH~!)1}&NujfzHgAVGtfLgR`l zrMiJHU$8N(<7M-0y&{aXF&R{Y2UJdgdp}rtp+Vz-R@DyHq$iu3d4F>Pajh z3c9P`zE~RTutCY+9hAng)T()bf{Uk%1+|jV-0tU-F^=a=BJ3_23^$W28rfA~>Ws+S zf5TgmYsm106=lcCEc;-3q>>X)7n41^d~#-CDc+X?us956>-+Vt8?T(G)@wfTU`rz;l`#I4+D|G6K*dR3Bpf zMn(!I*+xhxuwWVK<#BfP@Q>%*lqFUQ7a6feNWt#o+$N%ONZLLFKyR=Pwx@!RVNFJi zyj1L(bRDG#)6jiBJI49A$s$G?OX49ygtuF!eaBY0oj2T_^DEenr>7o0#wO?y()R}^ z(?%)5ob!a0*fWvWc@dUWg%Z-pWPb`X!73Ff1ieg8nSLKnzz}$PK&`wPd6$qF1ws}! zvPOTuhrJDA`P?jh2|Jb=ArdW92D--HiF-Q|czOUc>(nV}R4ztE+90H51@KH=4-E4E z&)75Q^!_f$#|avWTA5#mvRl@4zGfX|*kiLhg- zGem;)hD!t>R6^Rur4=9Nw2B@am#XATIYsapG;1dE!a{q_Sns#-0gUf?4aVdW0|d(K zzG**QyA&X*>rn>c{IJ^kbP)_`$z{*v&$fZCUxAoDb}*)b^nsrV%$#!jzj&iW1kl2a z09^}!ror3obZBIV6Xsrq4S9PDWdA>_yod`53W0$?7mQkF+mu10Wb)2n=wyANix0jx z#@k(d5F@xeYXS8H#+X5xMz{2J4ZKqrZLp*V;;-78Nej)1`j4sKYO7$)w6MzuGV1FC zBAhX%RtmB8Majd^*a1LSfV~=uY=aNxQpOEqe-+GxU*~ax(ZHT>Ym`Ic<1Wh%B<`0g z3Y7i2U<@I+xH883U3@Te&7(LY&#aT$I1PZ5x*xJu0JtKHQ0H`k41v#t^FSxl(p?{h z`N4c^FHT4WAXGup=1o9g1$H1|YhrGQpl)%-+{%cdZLh=BBf30g!L5iB66_g?NMk=* zLCe~PLT16n8`%b_dJ6GP4%}}a>*H2F3=?`@w5XoM30VLxx$M`?+ZNak%b-vaX~7kN zw|p^`bZijIXD|$O{mNz{oR9{Pm&^Uc?Vd;J1t^GVYbJ58#YpiPMENV<8}jxxABMp# zADB~_z#U)BE98FXW#pgK14(b)5bz_&5#Jm0;|@L?8(u_DV@?GrEkh{m{b_&mU^HF; zKnL%gr%3(|(ua81F#}fo0LzGur$m^OOYQzDEAQZ6+ntB|c)V$&u*oNk>9Ze>c+#+s zm-w)(ksA^ql;z7fSBmnrBkCX!+T;^v?}LE949H`~Ou6o3utt1&lEegFmu&%~%`w2k z0EIm%=ZaCj--Hl&fkhPE8-c$V5N)(GMb10zYq-bre0ZIHKBW%>@)%rEIQYwXzmb-! zP`Jv%znTY&kdQLWn41V}c*pbRR!6Y7lE@;Y1u@jp&XW!r9aoo#;3pPLrjAmA-;MJ6 zFg(LKUNoQDhlx8xN-P0F*8aEFI}Job@ZG1Dg}V}81YYa(xF*#`D8Wo^-G{LZcYnb= z%OEBp@lY}t%$qX1ADeI2+wwRx+RwyJggj<|HiFCSsUgEa>0MyZX5%Xo6I z0H0bY?63e}3b4io&FD5_j79(&We{24`SMf$Ljd9n0O)2mCxGV-xM3U5!PrWfty^r+ zpEo!|EcCFBM@6{dqHmeWHD(>T7mUQYm3te}B*gK* ztBruK;CV|v_}%&7-s?sfQcZ&XyrtaskJdYgdw-X1*%Tll;8hy{xr?Q@u&DA#e*$Mr z8)pn;{TjsZj^}(S!Uc)Yv?I0os$pLQ>xq(LvYdblO`HHoQDS7Zz&fm>ga9~i&jn8! z;qf%E_qL2*chKW9U5tBw5rFBbq-hhP-Rc0?YQ>9?_f5E9%BcpldDI~hrqnY7+^v-p zFWUjsN+D`>Q>}WtFpufnE0G!iOBSyM_0&Qs1r3MCVn!S@7Tz<4w!aQK&?_Qrd9J&) zSXYQ&_}ihah8R#f1aFt)oSl0mQa=VH@DKOQ3a!RIKaaR=PRc0ik|xE${lAMFG0d2X z9(#^G$Gt9NTkMEyG~C>EiJyxU3&}o`mA511WRjQd{4<&A9|AC+1VC%qFCL-lF+%7k z4YauFDsK1rS@EUS0@>~zVa$?enA<(;S)h@UX+P^nByGHG+-FbsJu#&Gb*`*dz7Hi# zAf&R3xy%f=yGe0c4I|zP9Q75fZ3#1~F>!OZH|)BB_*KUdfVT}r(N6;~%UwJLCTE*R z2NfNRLj{ZkjZnjEo7!``|97boR-Ngbv;4W0QLJrAGotZual7X`6MB+ch*#}EkGFGH z3xY>)Q_2MoT*(EsCTcZvci!0NMzm-b<80I1l73WQ_|-LoFk0e_AYoE(o1Km1@{~pN zf{n3Nz0s%2&v}AtF8zkdA)H|83SFuGW_EOOX?ZTgu%^RjfmU4BBg(MSBxvfs}?_S z(q0nd<#L|AqLfTH@HN009e|0t;w`8{cZ%&9dRyzp*yhc06tstBaWjU1DW(RWj9V|U z=df?pBVLo~Lx$Zph}ooR)UzgYu_)@Hx%a>39|9OsO3OK805Fua*+L$Xp%2|jlNsD5 zx(T_dC$#hw7EpunvliA<@ciECW%PG{M~~AbJ%#DQjP*mSn*=w-xX>un2C!&UH~??t zarR{h%u))DoIt~WmlOaT&Yk&dtN6fvwTr&c&@%jW*HPv*iyTRKCUcr(*5CboRnxV+ z%w>1BjQDynu;`N6_|kN{vFVlnUr+;5B(CF#C;)Sn;{)hJFFGZFOrXvn_|>o zz@i-V;;;Ia!n3V-Yc&z}Km;FNAU+FjrY2|LzVG-L*06|HHoW6SzJV-Xd0~9IOtEj@ zvlxA#yxcChJ?pV9h2!|BV{B63oMvCbvrtp%J=E3X5d9`ix%`MLP7?2*F6PF~{(eeTMi+qo5;#Mz)XvNZ@@;$iBI}&hD8qba?ng@* zX+zrH<@*$mZOvc=oUu{LM`xBRy>FP(jal7g#k2fvGYHqH_{KgM`NlDe0an25Mj6Cn zMqE7+@V_UX>}hhpP>B%)`%Y4PZ-|LVZT@IJ?PUK_@od_du^G6e0GuHJYgzoQk=0en zT!^nVFeo*={j{2S^gPJw55H!_(6G2oATJrf_OX*IaG;%sG>FU|>u!}I2xoIJ_T=r9 zY)&^-Tq=$e@62xw@ma_bou9=fQ?Aru`?f`n1bg;(+FA>pG2idg{VDxE-lsr2S7xj- zGcK7e1!snp@g?RAiYao;>n8sMz!-q7qJv`*^wYl#?ocBxtM+Hd5@*y6-aN07);Fu` z;d#BjU0zr9yaszqaA_b}?@L@JwPJ`yy#T;|`u)jD#z2tb#c)OdEIaP1{9@G8<4pdg zG1qx^x&ElBaBxqyjpzltygh&3qw0Ab=B?s1HXG{g9H`~C!MT%`gBHUYlWHB9i-9u+ z0F!Zry)&rJNWp>yjHK$a$+F%Es8(0_jk>I=3dQS1dLHwqYmZA~LJ3g+Tom;x07n2O zfWV}wnTr9C7sHwqI##aKR7T_z`rn(TS;aRHKC=7`g;%vDM9?sDl~)0osl^ykY&A^! zy=j-MErPS-w!&fV1>hjJbA(WZ)gO)A)pcu6&vR2f5hStr5z~lxG$V%D zje_1=n-Uw|B(U2f8K_l?A)L+GUh7^9N=-EP=N30>9OGu5t8)53|6n@RJ~hLvo7N!Z zXgplLGsQljs&|zXd%c`&97gk%MJ6JR`M+fb<*~)hn&e9Ko;2}ZfH5DJw6*Et5qej# zBU&cahuQLfXAEtg!4*vlm87%8LXB+WHU@9DDHWn~*Zam#+e=n92AK-k6`H3Nu5|o9 zf;jrhqNJ6n_I@qocP}iW`d@b0Z=2^~1eLZluYifbR&q&k$vE8(T_T09a!HWJ=t)E@c5g|JW?|O!Ji;Nh1eh;~Gdkqu%fu zRa+FB6{6d!AdcjDb&Yh~&F%5z6)3UOa3bJirG1h%-+tBX$NDU&IL+LX2574xytm8L zO7T^C=TD?z0OhP9)(V=Zt5Bj%HLoYj6mn_oM7VEKf|1*UAku~*fVNJf)*PxNJ^(a; zH3eXU!HMLuZxNNlPydy=XhJmJRR~NuDks2OeSpEb4g-vt+ecY*sH?yf09HM;^*A@< ze5HcFsDf>14y#naN2r9C)7%DIGs8|mSd8*Ho&J;0AZFF0Py=yL*~#A z0YIwN4{bfZ@;(R4?7Vow;i&HxIwF-FAiEfk{(8l|jU2y5&4b9HA3{?0dWc;wcjn~^ zxqk?iKxj&%O*kF}r9jX+N$>x1&akQX&vufdtvMc0)e^#de2y`o^-FlIs%Qs=KA7ajLW;+HM#X;ya zS@%ozRpWb2e(yi5QIv>njK>MUtPb9JV^?cmn`qyE1(vnNn&1Ma`<8c9$t!QVIC?s5 ztb^yyIXxwEF7|{fNpmfJ!I`#VJY-m6DLo&GHTdB@tI-qyRHD{s5d`nt4qRy=J^ zRuCNH1j$?)fJJA-TcV{dje&N1?C3;AL3_QL`=z2E`Y9%(wG=+0rHgsfxg@#*^W;JA zSqrP)Tf8`j2S5L40=aJj&xC+Ox&}rR4j_#BxbeLvFVw;F09afCpPl{j4my5e<$Vao zdw2P4ZB@jBQ7WE)x1t;{CLy;6VXv;Qc-P?z7(D+Vazbo&*2lBB?FZFCaTgZDdWfPO znI}}jzq)FmR4z_u8-r00FjGWs3;bE0Tnyv2PzS|3$nM?<)|ekp_ahT9AQJRy&52L& zuTA^5n25?DZ3CT=H&r*@%ByQBk||w+-nv7aH)2508F7L%<{z~ZAj=2a2yy(oIj)51 z3~}~ z+o=ygWFV}QTn-G>QBJxQvNWbr$A`rRnlOH=__Ti|DTW(BvA zGZ;hw5c|1w&X>|PE{eHk9sbz@2810W&5^|Ttx|-$fzII0)A-_wfAaLFMdCe)J?c#&{T@Aj_BKN zvZi7Wx3{h>7=&z$d4k*)gjro{+E%$^W~8h z?%f;Lne+(!rONznKti%!@G}n%p5!GEoVJRJyUX& zqBxPZ#zrwHVYu3ST1A3cAC>hrYDN%8k|M>%p}=gcPJh}3BuL3>DLRhJ}0vwyDp&;q~jv^-PpUf-#+>8 z##QbZU=tA=nHzHPFR z8z;M535LnPbCas$=Gyow2!KpNGcsTWrtZmRz(PA0E{?tImBwXa1ir zihl2VN(CS)N8UsduDlUO?lG;d282rdTxL@)YV|&E#Dxv-DS`Ahj1I@k(VWFb)|4C1 zPF)fJRvCcB@czr_IgaY${m+ZD{Z>Jgy?FL-PQ2f`)m3ARF*IaE-7%I>36)~V+QA8e zNP9WkYkPXtanAk=<*>Kx)Me3F-W!zw0PU%X+H`z)XdZ(Sg&oy>?o~85lVL%Yhfg^1 zx*BEBRt1)!Oye?AE4#fJ)^)K;5$<>bf0l>28ILx+tJta|yk&l9I6m#1m`VmJ6wd() z&2g6JVzU6@owLDFtv0;d*!DB(=SSX+M|&vq>9^b-yieGw3ynND;i4GwkVGaCj2!4R zvc7dHCs5dftc~Az^oO@^+tFadyN%mkb!<3i+iYyEO!sunp&HSk^`QQp0L90a66%OW z2>xH;o!KmYR)=mO>En+R6%L?Qf{P=0c3^CbA#H?aKbDl8oviErwV2s;-Ca@}KW@RF z)gkWAX0N*KibV(kRg&549_>HK8jPYn$7&SKed+0VY*Ok&9q`Zqs~QRc&WF#GUyZ{z#jIy*MQ5&3WAFty_qD|6J0?SCX9GNo{X&PKNxxPD=#{7d$p?5o^#CR)3>c3A1=hFgHAU^KS=i+(h(x% zHXRX7m#t)Ic4!nJT!0dsj=@Qmz)6nTa*jifowMbbFMjUyzRO34X33bfoQ~=k8>tT= z8AlD*5f)Dl06m(v`{w{y6k`3e<3qjcF9#^T8(YGk-%Q&4yxV>G`re`O+5T9;JLk8j zqkTD#g=A3ro`49Hd=`%x04?iG2g`ozT-zX(^fX zS&GFekH<_gk7ue_wD^+A5=E>N=dDxQmH^7V53+d5J;s`6z>% literal 0 HcmV?d00001 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 @@ + + + +