15
.gitignore
vendido
Archivo normal
@@ -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
|
||||||
354
README.md
Archivo normal
@@ -0,0 +1,354 @@
|
|||||||
|
# AleJabber — XMPP/Jabber Client for Android
|
||||||
|
|
||||||
|
[](.)
|
||||||
|
[](.)
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://kotlinlang.org)
|
||||||
|
[](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-<locale>/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.
|
||||||
|
```
|
||||||
|
|
||||||
1
app/.gitignore
vendido
Archivo normal
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
119
app/build.gradle.kts
Archivo normal
@@ -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)
|
||||||
|
}
|
||||||
21
app/proguard-rules.pro
vendido
Archivo normal
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/src/main/AndroidManifest.xml
Archivo normal
@@ -0,0 +1,73 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="28" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".AleJabberApp"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.AleJabber"
|
||||||
|
android:usesCleartextTraffic="false"
|
||||||
|
tools:targetApi="31">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.AleJabber"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".service.XmppForegroundService"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".service.BootReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
|
|
||||||
72
app/src/main/assets/ic_launcher_logo.svg
Archivo normal
@@ -0,0 +1,72 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
AleJabber Application Icon
|
||||||
|
===========================
|
||||||
|
Format : SVG 1.1
|
||||||
|
Canvas : 512 × 512 px
|
||||||
|
Design : Chat bubble with XMPP/Jabber lightning bolt and presence indicator dot.
|
||||||
|
Colors :
|
||||||
|
- Background : Deep Indigo #1A237E
|
||||||
|
- Bubble : White #FFFFFF
|
||||||
|
- Bolt : Vivid Indigo #3D5AFE
|
||||||
|
- Presence : Green #00E676
|
||||||
|
Usage : app/src/main/res/drawable/ (Android), docs/, store listing.
|
||||||
|
-->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
width="512"
|
||||||
|
height="512"
|
||||||
|
role="img"
|
||||||
|
aria-label="AleJabber application icon">
|
||||||
|
|
||||||
|
<title>AleJabber</title>
|
||||||
|
<desc>XMPP/Jabber client icon: a speech bubble with a lightning bolt symbolising fast, secure messaging.</desc>
|
||||||
|
|
||||||
|
<!-- ── Background circle ── -->
|
||||||
|
<circle cx="256" cy="256" r="256" fill="#1A237E"/>
|
||||||
|
|
||||||
|
<!-- ── Subtle inner highlight ── -->
|
||||||
|
<circle cx="256" cy="230" r="180" fill="#283593" opacity="0.6"/>
|
||||||
|
|
||||||
|
<!-- ── Speech bubble (white) ── -->
|
||||||
|
<path d="
|
||||||
|
M 110 110
|
||||||
|
Q 80 110 80 140
|
||||||
|
L 80 310
|
||||||
|
Q 80 340 110 340
|
||||||
|
L 155 340
|
||||||
|
L 140 420
|
||||||
|
L 240 340
|
||||||
|
L 400 340
|
||||||
|
Q 430 340 430 310
|
||||||
|
L 430 140
|
||||||
|
Q 430 110 400 110
|
||||||
|
Z"
|
||||||
|
fill="#FFFFFF"/>
|
||||||
|
|
||||||
|
<!-- ── Speech bubble inner tint ── -->
|
||||||
|
<path d="
|
||||||
|
M 116 118
|
||||||
|
Q 90 118 90 144
|
||||||
|
L 90 306
|
||||||
|
Q 90 332 116 332
|
||||||
|
L 157 332
|
||||||
|
L 144 410
|
||||||
|
L 238 332
|
||||||
|
L 396 332
|
||||||
|
Q 422 332 422 306
|
||||||
|
L 422 144
|
||||||
|
Q 422 118 396 118
|
||||||
|
Z"
|
||||||
|
fill="#C5CAE9" opacity="0.55"/>
|
||||||
|
|
||||||
|
<!-- ── XMPP Lightning bolt (indigo) ── -->
|
||||||
|
<path d="M 300 122 L 210 258 L 258 258 L 192 388 L 322 258 L 270 258 Z"
|
||||||
|
fill="#3D5AFE"/>
|
||||||
|
|
||||||
|
<!-- ── Presence indicator dot (online green) ── -->
|
||||||
|
<circle cx="400" cy="128" r="32" fill="#00E676"/>
|
||||||
|
<circle cx="400" cy="128" r="22" fill="#69F0AE"/>
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
Después Anchura: | Altura: | Tamaño: 1.9 KiB |
BIN
app/src/main/ic_launcher-playstore.png
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 24 KiB |
49
app/src/main/java/com/manalejandro/alejabber/AleJabberApp.kt
Archivo normal
@@ -0,0 +1,49 @@
|
|||||||
|
package com.manalejandro.alejabber
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.os.Build
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class AleJabberApp : Application() {
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createNotificationChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannels() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val nm = getSystemService(NotificationManager::class.java)
|
||||||
|
|
||||||
|
val messagesChannel = NotificationChannel(
|
||||||
|
CHANNEL_MESSAGES,
|
||||||
|
getString(R.string.notification_channel_messages),
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
).apply {
|
||||||
|
description = getString(R.string.notification_channel_messages_desc)
|
||||||
|
enableVibration(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
val serviceChannel = NotificationChannel(
|
||||||
|
CHANNEL_SERVICE,
|
||||||
|
getString(R.string.notification_channel_service),
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = getString(R.string.notification_channel_service_desc)
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
nm.createNotificationChannels(listOf(messagesChannel, serviceChannel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val CHANNEL_MESSAGES = "channel_messages"
|
||||||
|
const val CHANNEL_SERVICE = "channel_service"
|
||||||
|
const val NOTIFICATION_ID_SERVICE = 1001
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
144
app/src/main/java/com/manalejandro/alejabber/MainActivity.kt
Archivo normal
@@ -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
|
||||||
|
)
|
||||||
67
app/src/main/java/com/manalejandro/alejabber/data/local/AppDatabase.kt
Archivo normal
@@ -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 "<jid>" 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)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<AccountEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM accounts WHERE id = :id")
|
||||||
|
suspend fun getAccountById(id: Long): AccountEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM accounts WHERE isEnabled = 1")
|
||||||
|
fun getEnabledAccounts(): Flow<List<AccountEntity>>
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<List<ContactEntity>>
|
||||||
|
|
||||||
|
@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<List<ContactEntity>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertContact(contact: ContactEntity): Long
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertContacts(contacts: List<ContactEntity>)
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<List<MessageEntity>>
|
||||||
|
|
||||||
|
@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<Int>
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
|
||||||
36
app/src/main/java/com/manalejandro/alejabber/data/local/dao/RoomDao.kt
Archivo normal
@@ -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<List<RoomEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM rooms WHERE accountId = :accountId AND isJoined = 1")
|
||||||
|
fun getJoinedRooms(accountId: Long): Flow<List<RoomEntity>>
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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<Long, AbstractXMPPConnection>()
|
||||||
|
|
||||||
|
// ── Account connection status ─────────────────────────────────────────
|
||||||
|
private val _connectionStatus = MutableStateFlow<Map<Long, ConnectionStatus>>(emptyMap())
|
||||||
|
val connectionStatus: StateFlow<Map<Long, ConnectionStatus>> = _connectionStatus.asStateFlow()
|
||||||
|
|
||||||
|
// ── Incoming chat messages ────────────────────────────────────────────
|
||||||
|
private val _incomingMessages = MutableSharedFlow<IncomingMessage>()
|
||||||
|
val incomingMessages: SharedFlow<IncomingMessage> = _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<Map<Long, Map<String, PresenceStatus>>>(emptyMap())
|
||||||
|
val rosterPresence: StateFlow<Map<Long, Map<String, PresenceStatus>>> =
|
||||||
|
_rosterPresence.asStateFlow()
|
||||||
|
|
||||||
|
// ── Presence updates (kept for backward compatibility) ────────────────
|
||||||
|
private val _presenceUpdates = MutableSharedFlow<PresenceUpdate>(extraBufferCapacity = 64)
|
||||||
|
val presenceUpdates: SharedFlow<PresenceUpdate> = _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<RosterEntry> {
|
||||||
|
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<String, PresenceStatus>()
|
||||||
|
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<Jid>?) {
|
||||||
|
// 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<Jid>?) {}
|
||||||
|
override fun entriesDeleted(addresses: MutableCollection<Jid>?) {}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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<List<Account>> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<List<Contact>> =
|
||||||
|
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<List<Contact>> =
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<Message>> =
|
||||||
|
messageDao.getMessages(accountId, conversationJid).map { list -> list.map { it.toDomain() } }
|
||||||
|
|
||||||
|
fun getUnreadCount(accountId: Long, conversationJid: String): Flow<Int> =
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<String, MultiUserChat>()
|
||||||
|
|
||||||
|
fun getRooms(accountId: Long): Flow<List<Room>> =
|
||||||
|
roomDao.getRoomsByAccount(accountId).map { list -> list.map { it.toDomain() } }
|
||||||
|
|
||||||
|
fun getJoinedRooms(accountId: Long): Flow<List<Room>> =
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
36
app/src/main/java/com/manalejandro/alejabber/di/AppModule.kt
Archivo normal
@@ -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<Preferences> 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<Preferences> =
|
||||||
|
context.dataStore
|
||||||
|
}
|
||||||
|
|
||||||
41
app/src/main/java/com/manalejandro/alejabber/di/DatabaseModule.kt
Archivo normal
@@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
20
app/src/main/java/com/manalejandro/alejabber/domain/model/Account.kt
Archivo normal
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
23
app/src/main/java/com/manalejandro/alejabber/domain/model/Contact.kt
Archivo normal
@@ -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<String> = 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
|
||||||
|
}
|
||||||
|
|
||||||
41
app/src/main/java/com/manalejandro/alejabber/domain/model/Message.kt
Archivo normal
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
36
app/src/main/java/com/manalejandro/alejabber/domain/model/Room.kt
Archivo normal
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
129
app/src/main/java/com/manalejandro/alejabber/media/AudioRecorder.kt
Archivo normal
@@ -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<RecordingState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _durationMs = MutableStateFlow(0L)
|
||||||
|
val durationMs: StateFlow<Long> = _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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
84
app/src/main/java/com/manalejandro/alejabber/media/HttpUploadManager.kt
Archivo normal
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
16
app/src/main/java/com/manalejandro/alejabber/service/BootReceiver.kt
Archivo normal
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
337
app/src/main/java/com/manalejandro/alejabber/ui/accounts/AccountsScreen.kt
Archivo normal
@@ -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<Account?>(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"
|
||||||
|
}
|
||||||
@@ -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<Account> = 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<AccountsUiState> = _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<AddEditAccountUiState> = _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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
945
app/src/main/java/com/manalejandro/alejabber/ui/chat/ChatScreen.kt
Archivo normal
@@ -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<Message?>(null) }
|
||||||
|
// Confirm-delete dialog
|
||||||
|
var messageToDelete by remember { mutableStateOf<Message?>(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<String>)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
165
app/src/main/java/com/manalejandro/alejabber/ui/chat/ChatViewModel.kt
Archivo normal
@@ -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<Message> = 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<ChatUiState> = _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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<Color, String> = when (this) {
|
||||||
|
EncryptionType.OTR -> EncryptionOtr to "OTR"
|
||||||
|
EncryptionType.OMEMO -> EncryptionOmemo to "OMEMO"
|
||||||
|
EncryptionType.OPENPGP -> EncryptionPgp to "PGP"
|
||||||
|
EncryptionType.NONE -> EncryptionNone to "Plain"
|
||||||
|
}
|
||||||
|
|
||||||
463
app/src/main/java/com/manalejandro/alejabber/ui/contacts/ContactsScreen.kt
Archivo normal
@@ -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<Contact?>(null) }
|
||||||
|
// Contact pending removal confirmation
|
||||||
|
var removeTarget by remember { mutableStateOf<Contact?>(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<Contact>,
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<Contact> = emptyList(),
|
||||||
|
val filteredContacts: List<Contact> = 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<ContactsUiState> = _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}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
154
app/src/main/java/com/manalejandro/alejabber/ui/navigation/NavGraph.kt
Archivo normal
@@ -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() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
303
app/src/main/java/com/manalejandro/alejabber/ui/rooms/RoomsScreen.kt
Archivo normal
@@ -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<Room?>(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<Account>,
|
||||||
|
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)) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
96
app/src/main/java/com/manalejandro/alejabber/ui/rooms/RoomsViewModel.kt
Archivo normal
@@ -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<Account> = emptyList(),
|
||||||
|
val rooms: List<Room> = 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<Account>
|
||||||
|
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<RoomsUiState> = _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<Long>) {
|
||||||
|
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) }
|
||||||
|
}
|
||||||
240
app/src/main/java/com/manalejandro/alejabber/ui/settings/SettingsScreen.kt
Archivo normal
@@ -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"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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<Preferences>
|
||||||
|
) : 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<SettingsUiState> = _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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
55
app/src/main/java/com/manalejandro/alejabber/ui/theme/Color.kt
Archivo normal
@@ -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)
|
||||||
87
app/src/main/java/com/manalejandro/alejabber/ui/theme/Theme.kt
Archivo normal
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
34
app/src/main/java/com/manalejandro/alejabber/ui/theme/Type.kt
Archivo normal
@@ -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
|
||||||
|
)
|
||||||
|
*/
|
||||||
|
)
|
||||||
18
app/src/main/res/drawable/ic_launcher_background.xml
Archivo normal
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
AleJabber App Icon - Background Layer
|
||||||
|
Deep Indigo gradient background for the adaptive icon.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#1A237E"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<!-- Subtle radial highlight -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#283593"
|
||||||
|
android:pathData="M54,54 m-40,0 a40,40 0 1,0 80,0 a40,40 0 1,0 -80,0" />
|
||||||
|
</vector>
|
||||||
36
app/src/main/res/drawable/ic_launcher_foreground.xml
Archivo normal
@@ -0,0 +1,36 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="512"
|
||||||
|
android:viewportHeight="512">
|
||||||
|
<group android:scaleX="0.58"
|
||||||
|
android:scaleY="0.58"
|
||||||
|
android:translateX="107.52"
|
||||||
|
android:translateY="107.52">
|
||||||
|
<path
|
||||||
|
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"
|
||||||
|
android:fillColor="#1A237E"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M256,230m-180,0a180,180 0,1 1,360 0a180,180 0,1 1,-360 0"
|
||||||
|
android:strokeAlpha="0.6"
|
||||||
|
android:fillColor="#283593"
|
||||||
|
android:fillAlpha="0.6"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M110,110Q80,110 80,140L80,310Q80,340 110,340L155,340L140,420L240,340L400,340Q430,340 430,310L430,140Q430,110 400,110Z"
|
||||||
|
android:fillColor="#FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M116,118Q90,118 90,144L90,306Q90,332 116,332L157,332L144,410L238,332L396,332Q422,332 422,306L422,144Q422,118 396,118Z"
|
||||||
|
android:strokeAlpha="0.55"
|
||||||
|
android:fillColor="#C5CAE9"
|
||||||
|
android:fillAlpha="0.55"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M300,122L210,258L258,258L192,388L322,258L270,258Z"
|
||||||
|
android:fillColor="#3D5AFE"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M400,128m-32,0a32,32 0,1 1,64 0a32,32 0,1 1,-64 0"
|
||||||
|
android:fillColor="#00E676"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M400,128m-22,0a22,22 0,1 1,44 0a22,22 0,1 1,-44 0"
|
||||||
|
android:fillColor="#69F0AE"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Archivo normal
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Archivo normal
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.7 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 3.4 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.2 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 2.2 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 2.4 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 4.7 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 3.6 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 7.3 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 5.0 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 10 KiB |
156
app/src/main/res/values-es/strings.xml
Archivo normal
@@ -0,0 +1,156 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">AleJabber</string>
|
||||||
|
|
||||||
|
<!-- Navegación -->
|
||||||
|
<string name="nav_contacts">Contactos</string>
|
||||||
|
<string name="nav_rooms">Salas</string>
|
||||||
|
<string name="nav_accounts">Cuentas</string>
|
||||||
|
<string name="nav_settings">Ajustes</string>
|
||||||
|
|
||||||
|
<!-- Cuentas -->
|
||||||
|
<string name="accounts_title">Cuentas</string>
|
||||||
|
<string name="add_account">Añadir cuenta</string>
|
||||||
|
<string name="edit_account">Editar cuenta</string>
|
||||||
|
<string name="delete_account">Eliminar cuenta</string>
|
||||||
|
<string name="account_username">Usuario (JID)</string>
|
||||||
|
<string name="account_password">Contraseña</string>
|
||||||
|
<string name="account_server">Servidor</string>
|
||||||
|
<string name="account_port">Puerto</string>
|
||||||
|
<string name="account_use_tls">Usar TLS</string>
|
||||||
|
<string name="account_status_online">Conectado</string>
|
||||||
|
<string name="account_status_offline">Desconectado</string>
|
||||||
|
<string name="account_status_connecting">Conectando…</string>
|
||||||
|
<string name="account_status_error">Error de conexión</string>
|
||||||
|
<string name="account_connect">Conectar</string>
|
||||||
|
<string name="account_disconnect">Desconectar</string>
|
||||||
|
<string name="account_no_accounts">No hay cuentas configuradas.\nPulsa + para añadir una.</string>
|
||||||
|
<string name="account_delete_confirm">¿Eliminar la cuenta %1$s?</string>
|
||||||
|
<string name="account_jid_hint">usuario@ejemplo.com</string>
|
||||||
|
<string name="account_resource">Recurso</string>
|
||||||
|
<string name="account_resource_hint">AleJabber</string>
|
||||||
|
|
||||||
|
<!-- Contactos -->
|
||||||
|
<string name="contacts_title">Contactos</string>
|
||||||
|
<string name="contacts_search">Buscar contactos…</string>
|
||||||
|
<string name="contacts_empty">Sin contactos.\nAñade personas con su ID de Jabber.</string>
|
||||||
|
<string name="add_contact">Añadir contacto</string>
|
||||||
|
<string name="contact_jid">ID de Jabber (JID)</string>
|
||||||
|
<string name="contact_nickname">Apodo</string>
|
||||||
|
<string name="contact_remove">Eliminar contacto</string>
|
||||||
|
<string name="contact_block">Bloquear contacto</string>
|
||||||
|
<string name="contact_status_online">En línea</string>
|
||||||
|
<string name="contact_status_away">Ausente</string>
|
||||||
|
<string name="contact_status_dnd">No molestar</string>
|
||||||
|
<string name="contact_status_offline">Desconectado</string>
|
||||||
|
|
||||||
|
<!-- Salas / MUC -->
|
||||||
|
<string name="rooms_title">Salas</string>
|
||||||
|
<string name="rooms_search">Buscar salas…</string>
|
||||||
|
<string name="rooms_empty">No te has unido a ninguna sala.</string>
|
||||||
|
<string name="join_room">Unirse a sala</string>
|
||||||
|
<string name="room_jid">JID de la sala</string>
|
||||||
|
<string name="room_nickname">Tu apodo</string>
|
||||||
|
<string name="room_password">Contraseña (opcional)</string>
|
||||||
|
<string name="leave_room">Salir de la sala</string>
|
||||||
|
<string name="room_participants">Participantes</string>
|
||||||
|
<string name="room_topic">Tema</string>
|
||||||
|
<string name="browse_rooms">Explorar salas</string>
|
||||||
|
|
||||||
|
<!-- Chat -->
|
||||||
|
<string name="chat_hint">Escribe un mensaje…</string>
|
||||||
|
<string name="chat_send">Enviar</string>
|
||||||
|
<string name="chat_attach">Adjuntar archivo</string>
|
||||||
|
<string name="chat_record_audio">Grabar audio</string>
|
||||||
|
<string name="chat_stop_recording">Detener grabación</string>
|
||||||
|
<string name="chat_send_audio">Enviar audio</string>
|
||||||
|
<string name="chat_cancel_audio">Cancelar audio</string>
|
||||||
|
<string name="chat_encryption_none">Sin cifrado</string>
|
||||||
|
<string name="chat_encryption_otr">OTR</string>
|
||||||
|
<string name="chat_encryption_omemo">OMEMO</string>
|
||||||
|
<string name="chat_encryption_pgp">OpenPGP</string>
|
||||||
|
<string name="chat_encryption_select">Seleccionar cifrado</string>
|
||||||
|
<string name="chat_message_delivered">Entregado</string>
|
||||||
|
<string name="chat_message_read">Leído</string>
|
||||||
|
<string name="chat_message_sending">Enviando…</string>
|
||||||
|
<string name="chat_message_failed">Error al enviar</string>
|
||||||
|
<string name="chat_typing">%1$s está escribiendo…</string>
|
||||||
|
<string name="chat_media_image">Imagen</string>
|
||||||
|
<string name="chat_media_video">Vídeo</string>
|
||||||
|
<string name="chat_media_audio">Audio</string>
|
||||||
|
<string name="chat_media_file">Archivo</string>
|
||||||
|
<string name="chat_media_uploading">Subiendo…</string>
|
||||||
|
<string name="chat_media_download">Descargar</string>
|
||||||
|
<string name="chat_empty">Sin mensajes aún.\n¡Di hola!</string>
|
||||||
|
<string name="chat_otr_started">Sesión OTR iniciada. La conversación está cifrada.</string>
|
||||||
|
<string name="chat_otr_ended">Sesión OTR finalizada.</string>
|
||||||
|
<string name="chat_otr_untrusted">Advertencia: Huella OTR no verificada.</string>
|
||||||
|
<string name="chat_omemo_trusted">OMEMO: Todos los dispositivos son de confianza.</string>
|
||||||
|
<string name="chat_omemo_untrusted">OMEMO: Dispositivos no verificados detectados.</string>
|
||||||
|
|
||||||
|
<!-- Ajustes -->
|
||||||
|
<string name="settings_title">Ajustes</string>
|
||||||
|
<string name="settings_appearance">Apariencia</string>
|
||||||
|
<string name="settings_theme">Tema</string>
|
||||||
|
<string name="settings_theme_system">Por defecto del sistema</string>
|
||||||
|
<string name="settings_theme_light">Claro</string>
|
||||||
|
<string name="settings_theme_dark">Oscuro</string>
|
||||||
|
<string name="settings_language">Idioma</string>
|
||||||
|
<string name="settings_language_en">English</string>
|
||||||
|
<string name="settings_language_es">Español</string>
|
||||||
|
<string name="settings_language_zh">中文</string>
|
||||||
|
<string name="settings_notifications">Notificaciones</string>
|
||||||
|
<string name="settings_notifications_messages">Notificaciones de mensajes</string>
|
||||||
|
<string name="settings_notifications_vibrate">Vibrar</string>
|
||||||
|
<string name="settings_notifications_sound">Sonido</string>
|
||||||
|
<string name="settings_encryption">Cifrado</string>
|
||||||
|
<string name="settings_omemo_devices">Dispositivos OMEMO</string>
|
||||||
|
<string name="settings_pgp_keys">Claves OpenPGP</string>
|
||||||
|
<string name="settings_otr_fingerprints">Huellas OTR</string>
|
||||||
|
<string name="settings_default_encryption">Cifrado por defecto</string>
|
||||||
|
<string name="settings_about">Acerca de</string>
|
||||||
|
<string name="settings_version">Versión</string>
|
||||||
|
|
||||||
|
<!-- Común -->
|
||||||
|
<string name="ok">Aceptar</string>
|
||||||
|
<string name="cancel">Cancelar</string>
|
||||||
|
<string name="save">Guardar</string>
|
||||||
|
<string name="delete">Eliminar</string>
|
||||||
|
<string name="confirm">Confirmar</string>
|
||||||
|
<string name="error">Error</string>
|
||||||
|
<string name="loading">Cargando…</string>
|
||||||
|
<string name="retry">Reintentar</string>
|
||||||
|
<string name="close">Cerrar</string>
|
||||||
|
<string name="search">Buscar</string>
|
||||||
|
<string name="clear">Limpiar</string>
|
||||||
|
<string name="back">Atrás</string>
|
||||||
|
<string name="more_options">Más opciones</string>
|
||||||
|
|
||||||
|
<!-- Permisos -->
|
||||||
|
<string name="permission_microphone_title">Permiso de micrófono</string>
|
||||||
|
<string name="permission_microphone_message">AleJabber necesita acceso al micrófono para grabar mensajes de audio.</string>
|
||||||
|
<string name="permission_storage_title">Permiso de almacenamiento</string>
|
||||||
|
<string name="permission_storage_message">AleJabber necesita acceso al almacenamiento para enviar y recibir archivos.</string>
|
||||||
|
<string name="permission_camera_title">Permiso de cámara</string>
|
||||||
|
<string name="permission_camera_message">AleJabber necesita acceso a la cámara para tomar fotos.</string>
|
||||||
|
<string name="permission_denied">Permiso denegado. Por favor, concédelo en Ajustes.</string>
|
||||||
|
<string name="open_settings">Abrir Ajustes</string>
|
||||||
|
|
||||||
|
<!-- Notificaciones -->
|
||||||
|
<string name="notification_channel_messages">Mensajes</string>
|
||||||
|
<string name="notification_channel_messages_desc">Mensajes de chat entrantes</string>
|
||||||
|
<string name="notification_channel_service">Servicio XMPP</string>
|
||||||
|
<string name="notification_channel_service_desc">Conexión XMPP en segundo plano</string>
|
||||||
|
<string name="notification_service_running">AleJabber está conectado</string>
|
||||||
|
<string name="notification_new_message">Nuevo mensaje de %1$s</string>
|
||||||
|
|
||||||
|
<!-- Accesibilidad -->
|
||||||
|
<string name="cd_avatar">Avatar de %1$s</string>
|
||||||
|
<string name="cd_status_indicator">Estado: %1$s</string>
|
||||||
|
<string name="cd_encryption_badge">Cifrado: %1$s</string>
|
||||||
|
<string name="cd_send_button">Enviar mensaje</string>
|
||||||
|
<string name="cd_attach_button">Adjuntar archivo</string>
|
||||||
|
<string name="cd_record_button">Grabar mensaje de audio</string>
|
||||||
|
<string name="cd_message_delivered">Mensaje entregado</string>
|
||||||
|
<string name="cd_message_read">Mensaje leído</string>
|
||||||
|
</resources>
|
||||||
|
|
||||||
156
app/src/main/res/values-zh/strings.xml
Archivo normal
@@ -0,0 +1,156 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">AleJabber</string>
|
||||||
|
|
||||||
|
<!-- 导航 -->
|
||||||
|
<string name="nav_contacts">联系人</string>
|
||||||
|
<string name="nav_rooms">聊天室</string>
|
||||||
|
<string name="nav_accounts">账号</string>
|
||||||
|
<string name="nav_settings">设置</string>
|
||||||
|
|
||||||
|
<!-- 账号 -->
|
||||||
|
<string name="accounts_title">账号管理</string>
|
||||||
|
<string name="add_account">添加账号</string>
|
||||||
|
<string name="edit_account">编辑账号</string>
|
||||||
|
<string name="delete_account">删除账号</string>
|
||||||
|
<string name="account_username">用户名 (JID)</string>
|
||||||
|
<string name="account_password">密码</string>
|
||||||
|
<string name="account_server">服务器</string>
|
||||||
|
<string name="account_port">端口</string>
|
||||||
|
<string name="account_use_tls">使用 TLS</string>
|
||||||
|
<string name="account_status_online">已连接</string>
|
||||||
|
<string name="account_status_offline">已断开</string>
|
||||||
|
<string name="account_status_connecting">连接中…</string>
|
||||||
|
<string name="account_status_error">连接错误</string>
|
||||||
|
<string name="account_connect">连接</string>
|
||||||
|
<string name="account_disconnect">断开连接</string>
|
||||||
|
<string name="account_no_accounts">尚未配置账号。\n点击 + 添加账号。</string>
|
||||||
|
<string name="account_delete_confirm">删除账号 %1$s?</string>
|
||||||
|
<string name="account_jid_hint">user@example.com</string>
|
||||||
|
<string name="account_resource">资源</string>
|
||||||
|
<string name="account_resource_hint">AleJabber</string>
|
||||||
|
|
||||||
|
<!-- 联系人 -->
|
||||||
|
<string name="contacts_title">联系人</string>
|
||||||
|
<string name="contacts_search">搜索联系人…</string>
|
||||||
|
<string name="contacts_empty">暂无联系人。\n通过 Jabber ID 添加好友。</string>
|
||||||
|
<string name="add_contact">添加联系人</string>
|
||||||
|
<string name="contact_jid">Jabber ID (JID)</string>
|
||||||
|
<string name="contact_nickname">昵称</string>
|
||||||
|
<string name="contact_remove">删除联系人</string>
|
||||||
|
<string name="contact_block">屏蔽联系人</string>
|
||||||
|
<string name="contact_status_online">在线</string>
|
||||||
|
<string name="contact_status_away">离开</string>
|
||||||
|
<string name="contact_status_dnd">请勿打扰</string>
|
||||||
|
<string name="contact_status_offline">离线</string>
|
||||||
|
|
||||||
|
<!-- 聊天室 / MUC -->
|
||||||
|
<string name="rooms_title">聊天室</string>
|
||||||
|
<string name="rooms_search">搜索聊天室…</string>
|
||||||
|
<string name="rooms_empty">尚未加入任何聊天室。</string>
|
||||||
|
<string name="join_room">加入聊天室</string>
|
||||||
|
<string name="room_jid">聊天室 JID</string>
|
||||||
|
<string name="room_nickname">您的昵称</string>
|
||||||
|
<string name="room_password">聊天室密码(可选)</string>
|
||||||
|
<string name="leave_room">退出聊天室</string>
|
||||||
|
<string name="room_participants">参与者</string>
|
||||||
|
<string name="room_topic">主题</string>
|
||||||
|
<string name="browse_rooms">浏览聊天室</string>
|
||||||
|
|
||||||
|
<!-- 聊天 -->
|
||||||
|
<string name="chat_hint">输入消息…</string>
|
||||||
|
<string name="chat_send">发送</string>
|
||||||
|
<string name="chat_attach">附件</string>
|
||||||
|
<string name="chat_record_audio">录音</string>
|
||||||
|
<string name="chat_stop_recording">停止录音</string>
|
||||||
|
<string name="chat_send_audio">发送音频</string>
|
||||||
|
<string name="chat_cancel_audio">取消录音</string>
|
||||||
|
<string name="chat_encryption_none">无加密</string>
|
||||||
|
<string name="chat_encryption_otr">OTR</string>
|
||||||
|
<string name="chat_encryption_omemo">OMEMO</string>
|
||||||
|
<string name="chat_encryption_pgp">OpenPGP</string>
|
||||||
|
<string name="chat_encryption_select">选择加密方式</string>
|
||||||
|
<string name="chat_message_delivered">已送达</string>
|
||||||
|
<string name="chat_message_read">已读</string>
|
||||||
|
<string name="chat_message_sending">发送中…</string>
|
||||||
|
<string name="chat_message_failed">发送失败</string>
|
||||||
|
<string name="chat_typing">%1$s 正在输入…</string>
|
||||||
|
<string name="chat_media_image">图片</string>
|
||||||
|
<string name="chat_media_video">视频</string>
|
||||||
|
<string name="chat_media_audio">音频</string>
|
||||||
|
<string name="chat_media_file">文件</string>
|
||||||
|
<string name="chat_media_uploading">上传中…</string>
|
||||||
|
<string name="chat_media_download">下载</string>
|
||||||
|
<string name="chat_empty">暂无消息。\n说声你好!</string>
|
||||||
|
<string name="chat_otr_started">OTR 会话已开始,您的对话已加密。</string>
|
||||||
|
<string name="chat_otr_ended">OTR 会话已结束。</string>
|
||||||
|
<string name="chat_otr_untrusted">警告:OTR 指纹未验证。</string>
|
||||||
|
<string name="chat_omemo_trusted">OMEMO:所有设备已信任。</string>
|
||||||
|
<string name="chat_omemo_untrusted">OMEMO:检测到不受信任的设备。</string>
|
||||||
|
|
||||||
|
<!-- 设置 -->
|
||||||
|
<string name="settings_title">设置</string>
|
||||||
|
<string name="settings_appearance">外观</string>
|
||||||
|
<string name="settings_theme">主题</string>
|
||||||
|
<string name="settings_theme_system">跟随系统</string>
|
||||||
|
<string name="settings_theme_light">浅色</string>
|
||||||
|
<string name="settings_theme_dark">深色</string>
|
||||||
|
<string name="settings_language">语言</string>
|
||||||
|
<string name="settings_language_en">English</string>
|
||||||
|
<string name="settings_language_es">Español</string>
|
||||||
|
<string name="settings_language_zh">中文</string>
|
||||||
|
<string name="settings_notifications">通知</string>
|
||||||
|
<string name="settings_notifications_messages">消息通知</string>
|
||||||
|
<string name="settings_notifications_vibrate">震动</string>
|
||||||
|
<string name="settings_notifications_sound">声音</string>
|
||||||
|
<string name="settings_encryption">加密</string>
|
||||||
|
<string name="settings_omemo_devices">OMEMO 设备</string>
|
||||||
|
<string name="settings_pgp_keys">OpenPGP 密钥</string>
|
||||||
|
<string name="settings_otr_fingerprints">OTR 指纹</string>
|
||||||
|
<string name="settings_default_encryption">默认加密方式</string>
|
||||||
|
<string name="settings_about">关于</string>
|
||||||
|
<string name="settings_version">版本</string>
|
||||||
|
|
||||||
|
<!-- 通用 -->
|
||||||
|
<string name="ok">确定</string>
|
||||||
|
<string name="cancel">取消</string>
|
||||||
|
<string name="save">保存</string>
|
||||||
|
<string name="delete">删除</string>
|
||||||
|
<string name="confirm">确认</string>
|
||||||
|
<string name="error">错误</string>
|
||||||
|
<string name="loading">加载中…</string>
|
||||||
|
<string name="retry">重试</string>
|
||||||
|
<string name="close">关闭</string>
|
||||||
|
<string name="search">搜索</string>
|
||||||
|
<string name="clear">清除</string>
|
||||||
|
<string name="back">返回</string>
|
||||||
|
<string name="more_options">更多选项</string>
|
||||||
|
|
||||||
|
<!-- 权限 -->
|
||||||
|
<string name="permission_microphone_title">麦克风权限</string>
|
||||||
|
<string name="permission_microphone_message">AleJabber 需要麦克风权限来录制语音消息。</string>
|
||||||
|
<string name="permission_storage_title">存储权限</string>
|
||||||
|
<string name="permission_storage_message">AleJabber 需要存储权限来发送和接收文件。</string>
|
||||||
|
<string name="permission_camera_title">相机权限</string>
|
||||||
|
<string name="permission_camera_message">AleJabber 需要相机权限来拍照。</string>
|
||||||
|
<string name="permission_denied">权限被拒绝,请在设置中授予权限。</string>
|
||||||
|
<string name="open_settings">打开设置</string>
|
||||||
|
|
||||||
|
<!-- 通知 -->
|
||||||
|
<string name="notification_channel_messages">消息</string>
|
||||||
|
<string name="notification_channel_messages_desc">收到的聊天消息</string>
|
||||||
|
<string name="notification_channel_service">XMPP 服务</string>
|
||||||
|
<string name="notification_channel_service_desc">后台 XMPP 连接</string>
|
||||||
|
<string name="notification_service_running">AleJabber 已连接</string>
|
||||||
|
<string name="notification_new_message">来自 %1$s 的新消息</string>
|
||||||
|
|
||||||
|
<!-- 无障碍 -->
|
||||||
|
<string name="cd_avatar">%1$s 的头像</string>
|
||||||
|
<string name="cd_status_indicator">状态:%1$s</string>
|
||||||
|
<string name="cd_encryption_badge">加密:%1$s</string>
|
||||||
|
<string name="cd_send_button">发送消息</string>
|
||||||
|
<string name="cd_attach_button">附件</string>
|
||||||
|
<string name="cd_record_button">录制语音消息</string>
|
||||||
|
<string name="cd_message_delivered">消息已送达</string>
|
||||||
|
<string name="cd_message_read">消息已读</string>
|
||||||
|
</resources>
|
||||||
|
|
||||||
10
app/src/main/res/values/colors.xml
Archivo normal
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="purple_200">#FFBB86FC</color>
|
||||||
|
<color name="purple_500">#FF6200EE</color>
|
||||||
|
<color name="purple_700">#FF3700B3</color>
|
||||||
|
<color name="teal_200">#FF03DAC5</color>
|
||||||
|
<color name="teal_700">#FF018786</color>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
</resources>
|
||||||
4
app/src/main/res/values/ic_launcher_background.xml
Archivo normal
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#24308B</color>
|
||||||
|
</resources>
|
||||||
156
app/src/main/res/values/strings.xml
Archivo normal
@@ -0,0 +1,156 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">AleJabber</string>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<string name="nav_contacts">Contacts</string>
|
||||||
|
<string name="nav_rooms">Rooms</string>
|
||||||
|
<string name="nav_accounts">Accounts</string>
|
||||||
|
<string name="nav_settings">Settings</string>
|
||||||
|
|
||||||
|
<!-- Accounts -->
|
||||||
|
<string name="accounts_title">Accounts</string>
|
||||||
|
<string name="add_account">Add Account</string>
|
||||||
|
<string name="edit_account">Edit Account</string>
|
||||||
|
<string name="delete_account">Delete Account</string>
|
||||||
|
<string name="account_username">Username (JID)</string>
|
||||||
|
<string name="account_password">Password</string>
|
||||||
|
<string name="account_server">Server</string>
|
||||||
|
<string name="account_port">Port</string>
|
||||||
|
<string name="account_use_tls">Use TLS</string>
|
||||||
|
<string name="account_status_online">Online</string>
|
||||||
|
<string name="account_status_offline">Offline</string>
|
||||||
|
<string name="account_status_connecting">Connecting…</string>
|
||||||
|
<string name="account_status_error">Connection Error</string>
|
||||||
|
<string name="account_connect">Connect</string>
|
||||||
|
<string name="account_disconnect">Disconnect</string>
|
||||||
|
<string name="account_no_accounts">No accounts configured.\nTap + to add one.</string>
|
||||||
|
<string name="account_delete_confirm">Delete account %1$s?</string>
|
||||||
|
<string name="account_jid_hint">user@example.com</string>
|
||||||
|
<string name="account_resource">Resource</string>
|
||||||
|
<string name="account_resource_hint">AleJabber</string>
|
||||||
|
|
||||||
|
<!-- Contacts -->
|
||||||
|
<string name="contacts_title">Contacts</string>
|
||||||
|
<string name="contacts_search">Search contacts…</string>
|
||||||
|
<string name="contacts_empty">No contacts yet.\nAdd people using their Jabber ID.</string>
|
||||||
|
<string name="add_contact">Add Contact</string>
|
||||||
|
<string name="contact_jid">Jabber ID (JID)</string>
|
||||||
|
<string name="contact_nickname">Nickname</string>
|
||||||
|
<string name="contact_remove">Remove Contact</string>
|
||||||
|
<string name="contact_block">Block Contact</string>
|
||||||
|
<string name="contact_status_online">Online</string>
|
||||||
|
<string name="contact_status_away">Away</string>
|
||||||
|
<string name="contact_status_dnd">Do Not Disturb</string>
|
||||||
|
<string name="contact_status_offline">Offline</string>
|
||||||
|
|
||||||
|
<!-- Rooms / MUC -->
|
||||||
|
<string name="rooms_title">Rooms</string>
|
||||||
|
<string name="rooms_search">Search rooms…</string>
|
||||||
|
<string name="rooms_empty">No rooms joined yet.</string>
|
||||||
|
<string name="join_room">Join Room</string>
|
||||||
|
<string name="room_jid">Room JID</string>
|
||||||
|
<string name="room_nickname">Your Nickname</string>
|
||||||
|
<string name="room_password">Room Password (optional)</string>
|
||||||
|
<string name="leave_room">Leave Room</string>
|
||||||
|
<string name="room_participants">Participants</string>
|
||||||
|
<string name="room_topic">Topic</string>
|
||||||
|
<string name="browse_rooms">Browse Rooms</string>
|
||||||
|
|
||||||
|
<!-- Chat -->
|
||||||
|
<string name="chat_hint">Type a message…</string>
|
||||||
|
<string name="chat_send">Send</string>
|
||||||
|
<string name="chat_attach">Attach file</string>
|
||||||
|
<string name="chat_record_audio">Record audio</string>
|
||||||
|
<string name="chat_stop_recording">Stop recording</string>
|
||||||
|
<string name="chat_send_audio">Send audio</string>
|
||||||
|
<string name="chat_cancel_audio">Cancel audio</string>
|
||||||
|
<string name="chat_encryption_none">No encryption</string>
|
||||||
|
<string name="chat_encryption_otr">OTR</string>
|
||||||
|
<string name="chat_encryption_omemo">OMEMO</string>
|
||||||
|
<string name="chat_encryption_pgp">OpenPGP</string>
|
||||||
|
<string name="chat_encryption_select">Select encryption</string>
|
||||||
|
<string name="chat_message_delivered">Delivered</string>
|
||||||
|
<string name="chat_message_read">Read</string>
|
||||||
|
<string name="chat_message_sending">Sending…</string>
|
||||||
|
<string name="chat_message_failed">Failed to send</string>
|
||||||
|
<string name="chat_typing">%1$s is typing…</string>
|
||||||
|
<string name="chat_media_image">Image</string>
|
||||||
|
<string name="chat_media_video">Video</string>
|
||||||
|
<string name="chat_media_audio">Audio</string>
|
||||||
|
<string name="chat_media_file">File</string>
|
||||||
|
<string name="chat_media_uploading">Uploading…</string>
|
||||||
|
<string name="chat_media_download">Download</string>
|
||||||
|
<string name="chat_empty">No messages yet.\nSay hello!</string>
|
||||||
|
<string name="chat_otr_started">OTR session started. Your conversation is now encrypted.</string>
|
||||||
|
<string name="chat_otr_ended">OTR session ended.</string>
|
||||||
|
<string name="chat_otr_untrusted">Warning: OTR fingerprint not verified.</string>
|
||||||
|
<string name="chat_omemo_trusted">OMEMO: All devices trusted.</string>
|
||||||
|
<string name="chat_omemo_untrusted">OMEMO: Untrusted devices detected.</string>
|
||||||
|
|
||||||
|
<!-- Settings -->
|
||||||
|
<string name="settings_title">Settings</string>
|
||||||
|
<string name="settings_appearance">Appearance</string>
|
||||||
|
<string name="settings_theme">Theme</string>
|
||||||
|
<string name="settings_theme_system">System Default</string>
|
||||||
|
<string name="settings_theme_light">Light</string>
|
||||||
|
<string name="settings_theme_dark">Dark</string>
|
||||||
|
<string name="settings_language">Language</string>
|
||||||
|
<string name="settings_language_en">English</string>
|
||||||
|
<string name="settings_language_es">Español</string>
|
||||||
|
<string name="settings_language_zh">中文</string>
|
||||||
|
<string name="settings_notifications">Notifications</string>
|
||||||
|
<string name="settings_notifications_messages">Message notifications</string>
|
||||||
|
<string name="settings_notifications_vibrate">Vibrate</string>
|
||||||
|
<string name="settings_notifications_sound">Sound</string>
|
||||||
|
<string name="settings_encryption">Encryption</string>
|
||||||
|
<string name="settings_omemo_devices">OMEMO Devices</string>
|
||||||
|
<string name="settings_pgp_keys">OpenPGP Keys</string>
|
||||||
|
<string name="settings_otr_fingerprints">OTR Fingerprints</string>
|
||||||
|
<string name="settings_default_encryption">Default encryption</string>
|
||||||
|
<string name="settings_about">About</string>
|
||||||
|
<string name="settings_version">Version</string>
|
||||||
|
|
||||||
|
<!-- Common -->
|
||||||
|
<string name="ok">OK</string>
|
||||||
|
<string name="cancel">Cancel</string>
|
||||||
|
<string name="save">Save</string>
|
||||||
|
<string name="delete">Delete</string>
|
||||||
|
<string name="confirm">Confirm</string>
|
||||||
|
<string name="error">Error</string>
|
||||||
|
<string name="loading">Loading…</string>
|
||||||
|
<string name="retry">Retry</string>
|
||||||
|
<string name="close">Close</string>
|
||||||
|
<string name="search">Search</string>
|
||||||
|
<string name="clear">Clear</string>
|
||||||
|
<string name="back">Back</string>
|
||||||
|
<string name="more_options">More options</string>
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
<string name="permission_microphone_title">Microphone Permission</string>
|
||||||
|
<string name="permission_microphone_message">AleJabber needs microphone access to record audio messages.</string>
|
||||||
|
<string name="permission_storage_title">Storage Permission</string>
|
||||||
|
<string name="permission_storage_message">AleJabber needs storage access to send and receive files.</string>
|
||||||
|
<string name="permission_camera_title">Camera Permission</string>
|
||||||
|
<string name="permission_camera_message">AleJabber needs camera access to take photos.</string>
|
||||||
|
<string name="permission_denied">Permission denied. Please grant it in Settings.</string>
|
||||||
|
<string name="open_settings">Open Settings</string>
|
||||||
|
|
||||||
|
<!-- Notifications -->
|
||||||
|
<string name="notification_channel_messages">Messages</string>
|
||||||
|
<string name="notification_channel_messages_desc">Incoming chat messages</string>
|
||||||
|
<string name="notification_channel_service">XMPP Service</string>
|
||||||
|
<string name="notification_channel_service_desc">Background XMPP connection</string>
|
||||||
|
<string name="notification_service_running">AleJabber is connected</string>
|
||||||
|
<string name="notification_new_message">New message from %1$s</string>
|
||||||
|
|
||||||
|
<!-- Accessibility -->
|
||||||
|
<string name="cd_avatar">%1$s\'s avatar</string>
|
||||||
|
<string name="cd_status_indicator">Status: %1$s</string>
|
||||||
|
<string name="cd_encryption_badge">Encryption: %1$s</string>
|
||||||
|
<string name="cd_send_button">Send message</string>
|
||||||
|
<string name="cd_attach_button">Attach file</string>
|
||||||
|
<string name="cd_record_button">Record audio message</string>
|
||||||
|
<string name="cd_message_delivered">Message delivered</string>
|
||||||
|
<string name="cd_message_read">Message read</string>
|
||||||
|
</resources>
|
||||||
|
|
||||||
5
app/src/main/res/values/themes.xml
Archivo normal
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<style name="Theme.AleJabber" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
|
</resources>
|
||||||
13
app/src/main/res/xml/backup_rules.xml
Archivo normal
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample backup rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/guide/topics/data/autobackup
|
||||||
|
for details.
|
||||||
|
Note: This file is ignored for devices older than API 31
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore
|
||||||
|
-->
|
||||||
|
<full-backup-content>
|
||||||
|
<!--
|
||||||
|
<include domain="sharedpref" path="."/>
|
||||||
|
<exclude domain="sharedpref" path="device.xml"/>
|
||||||
|
-->
|
||||||
|
</full-backup-content>
|
||||||
19
app/src/main/res/xml/data_extraction_rules.xml
Archivo normal
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample data extraction rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||||
|
for details.
|
||||||
|
-->
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
-->
|
||||||
|
</cloud-backup>
|
||||||
|
<!--
|
||||||
|
<device-transfer>
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
</device-transfer>
|
||||||
|
-->
|
||||||
|
</data-extraction-rules>
|
||||||
7
app/src/main/res/xml/file_paths.xml
Archivo normal
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<cache-path name="shared_files" path="." />
|
||||||
|
<external-cache-path name="external_cache" path="." />
|
||||||
|
<files-path name="files" path="." />
|
||||||
|
</paths>
|
||||||
|
|
||||||
17
app/src/test/java/com/manalejandro/alejabber/ExampleUnitTest.kt
Archivo normal
@@ -0,0 +1,17 @@
|
|||||||
|
package com.manalejandro.alejabber
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
class ExampleUnitTest {
|
||||||
|
@Test
|
||||||
|
fun addition_isCorrect() {
|
||||||
|
assertEquals(4, 2 + 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
8
build.gradle.kts
Archivo normal
@@ -0,0 +1,8 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.hilt) apply false
|
||||||
|
alias(libs.plugins.ksp) apply false
|
||||||
|
}
|
||||||
|
|
||||||
24
gradle.properties
Archivo normal
@@ -0,0 +1,24 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.disallowKotlinSourceSets=false
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. For more details, visit
|
||||||
|
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
|
kotlin.code.style=official
|
||||||
|
# Enables namespacing of each library's R class so that its R class includes only the
|
||||||
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
|
# thereby reducing the size of the R class for that library
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
12
gradle/gradle-daemon-jvm.properties
Archivo normal
@@ -0,0 +1,12 @@
|
|||||||
|
#This file is generated by updateDaemonJvm
|
||||||
|
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||||
|
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||||
|
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||||
|
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||||
|
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
|
||||||
|
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
|
||||||
|
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||||
|
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||||
|
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
|
||||||
|
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
|
||||||
|
toolchainVersion=21
|
||||||
82
gradle/libs.versions.toml
Archivo normal
@@ -0,0 +1,82 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "9.0.1"
|
||||||
|
coreKtx = "1.17.0"
|
||||||
|
junit = "4.13.2"
|
||||||
|
junitVersion = "1.3.0"
|
||||||
|
espressoCore = "3.7.0"
|
||||||
|
lifecycleRuntimeKtx = "2.10.0"
|
||||||
|
activityCompose = "1.12.4"
|
||||||
|
kotlin = "2.0.21"
|
||||||
|
composeBom = "2024.09.00"
|
||||||
|
hilt = "2.59.2"
|
||||||
|
room = "2.7.1"
|
||||||
|
navigation = "2.9.0"
|
||||||
|
datastore = "1.1.7"
|
||||||
|
coil = "2.7.0"
|
||||||
|
smack = "4.4.8"
|
||||||
|
okhttp = "4.12.0"
|
||||||
|
coroutines = "1.9.0"
|
||||||
|
viewmodelCompose = "2.10.0"
|
||||||
|
materialIconsExtended = "1.7.8"
|
||||||
|
bouncycastle = "1.78.1"
|
||||||
|
accompanist = "0.36.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
|
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
|
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "materialIconsExtended" }
|
||||||
|
# Hilt DI
|
||||||
|
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
|
||||||
|
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
|
||||||
|
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version = "1.2.0" }
|
||||||
|
# Room
|
||||||
|
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||||
|
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||||
|
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||||
|
# Navigation
|
||||||
|
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
|
||||||
|
# DataStore
|
||||||
|
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
|
# Coil
|
||||||
|
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||||
|
# Smack XMPP
|
||||||
|
smack-android = { group = "org.igniterealtime.smack", name = "smack-android", version.ref = "smack" }
|
||||||
|
smack-android-extensions = { group = "org.igniterealtime.smack", name = "smack-android-extensions", version.ref = "smack" }
|
||||||
|
smack-tcp = { group = "org.igniterealtime.smack", name = "smack-tcp", version.ref = "smack" }
|
||||||
|
smack-im = { group = "org.igniterealtime.smack", name = "smack-im", version.ref = "smack" }
|
||||||
|
smack-extensions = { group = "org.igniterealtime.smack", name = "smack-extensions", version.ref = "smack" }
|
||||||
|
smack-omemo = { group = "org.igniterealtime.smack", name = "smack-omemo", version.ref = "smack" }
|
||||||
|
smack-omemo-signal = { group = "org.igniterealtime.smack", name = "smack-omemo-signal", version.ref = "smack" }
|
||||||
|
smack-openpgp = { group = "org.igniterealtime.smack", name = "smack-openpgp", version.ref = "smack" }
|
||||||
|
# OkHttp
|
||||||
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
|
# Coroutines
|
||||||
|
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
|
# ViewModel Compose
|
||||||
|
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "viewmodelCompose" }
|
||||||
|
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "viewmodelCompose" }
|
||||||
|
# Bouncy Castle (OpenPGP)
|
||||||
|
bouncycastle-bcpg = { group = "org.bouncycastle", name = "bcpg-jdk18on", version.ref = "bouncycastle" }
|
||||||
|
bouncycastle-bcprov = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bouncycastle" }
|
||||||
|
# Accompanist permissions
|
||||||
|
accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||||
|
ksp = { id = "com.google.devtools.ksp", version = "2.0.21-1.0.28" }
|
||||||
|
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendido
Archivo normal
9
gradle/wrapper/gradle-wrapper.properties
vendido
Archivo normal
@@ -0,0 +1,9 @@
|
|||||||
|
#Fri Feb 27 23:49:34 CET 2026
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionSha256Sum=72f44c9f8ebcb1af43838f45ee5c4aa9c5444898b3468ab3f4af7b6076c5bc3f
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
251
gradlew
vendido
Archivo ejecutable
@@ -0,0 +1,251 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="\\\"\\\""
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
94
gradlew.bat
vendido
Archivo normal
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
26
settings.gradle.kts
Archivo normal
@@ -0,0 +1,26 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google {
|
||||||
|
content {
|
||||||
|
includeGroupByRegex("com\\.android.*")
|
||||||
|
includeGroupByRegex("com\\.google.*")
|
||||||
|
includeGroupByRegex("androidx.*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plugins {
|
||||||
|
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "AleJabber"
|
||||||
|
include(":app")
|
||||||