initial commit

Signed-off-by: ale <ale@manalejandro.com>
Este commit está contenido en:
ale
2026-02-28 01:40:45 +01:00
commit 60865ce629
Se han modificado 86 ficheros con 7162 adiciones y 0 borrados

15
.gitignore vendido Archivo normal
Ver fichero

@@ -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
Ver fichero

@@ -0,0 +1,354 @@
# AleJabber — XMPP/Jabber Client for Android
[![Build Status](https://img.shields.io/badge/build-passing-brightgreen)](.)
[![API](https://img.shields.io/badge/API-24%2B-blue)](.)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![Kotlin](https://img.shields.io/badge/Kotlin-2.0-purple)](https://kotlinlang.org)
[![Compose](https://img.shields.io/badge/Jetpack%20Compose-2024.09-blue)](https://developer.android.com/jetpack/compose)
A modern, feature-rich XMPP/Jabber messaging client for Android built with **Jetpack Compose** and **Material Design 3**. AleJabber supports multiple accounts, end-to-end encryption (OTR, OMEMO, OpenPGP), multimedia file transfers via `http_upload`, in-app audio recording, group chat rooms, and full accessibility support.
---
## Table of Contents
1. [Features](#features)
2. [Screenshots](#screenshots)
3. [Architecture](#architecture)
4. [Project Structure](#project-structure)
5. [Getting Started](#getting-started)
6. [Configuration](#configuration)
7. [Encryption](#encryption)
8. [Multimedia & Audio](#multimedia--audio)
9. [Internationalization](#internationalization)
10. [Accessibility](#accessibility)
11. [Dependencies](#dependencies)
12. [Contributing](#contributing)
13. [License](#license)
---
## Features
| Feature | Description |
|---------|-------------|
| 🔐 **OTR Encryption** | Off-the-Record messaging with perfect forward secrecy |
| 🔒 **OMEMO Encryption** | XEP-0384 multi-device end-to-end encryption (recommended) |
| 🗝️ **OpenPGP** | Asymmetric PGP encryption via XEP-0373/0374 |
| 👥 **Multi-Account** | Manage multiple XMPP accounts from different servers |
| 💬 **Group Rooms (MUC)** | Join and manage Multi-User Chat rooms (XEP-0045) |
| 📎 **File Transfer** | Upload images, audio, and files via XEP-0363 `http_upload` |
| 🎙️ **Audio Messages** | Record and send voice messages directly from the app |
| 🔔 **Smart Notifications** | Per-account notification channels with vibration/sound control |
| 🌐 **Multilingual** | English 🇬🇧, Spanish 🇪🇸, Chinese 🇨🇳 |
| ♿ **Accessible** | Full TalkBack support with content descriptions and semantic roles |
| 🎨 **Material You** | Dynamic theming with Light/Dark/System modes |
| 🔄 **Auto-Reconnect** | Automatic reconnection with random increasing delay policy |
| 💾 **Offline Storage** | Room database caches messages for offline reading |
---
## Screenshots
> _Screenshots to be added after first device deployment._
---
## Architecture
AleJabber follows **Clean Architecture** with an **MVVM** presentation layer:
```
┌─────────────────────────────────────────────────────┐
│ Presentation Layer │
│ Compose Screens ←→ ViewModels ←→ UI State │
├─────────────────────────────────────────────────────┤
│ Domain Layer │
│ Models · Use Cases · Repository Interfaces │
├─────────────────────────────────────────────────────┤
│ Data Layer │
│ Room DB · Smack XMPP · DataStore · OkHttp │
└─────────────────────────────────────────────────────┘
```
### Key Patterns
- **Dependency Injection**: Hilt (Dagger-based)
- **Reactive Streams**: Kotlin `Flow` + `StateFlow` + `SharedFlow`
- **Navigation**: Jetpack Navigation Compose with type-safe routes
- **Background Work**: `XmppForegroundService` keeps connections alive
- **Persistence**: Room with KSP-generated DAOs
---
## Project Structure
```
app/src/main/java/com/manalejandro/alejabber/
├── AleJabberApp.kt # Application class with Hilt initialization
├── MainActivity.kt # Single-Activity entry point
├── data/
│ ├── local/
│ │ ├── AppDatabase.kt # Room database definition
│ │ ├── dao/ # Data Access Objects (AccountDao, MessageDao, ContactDao, RoomDao)
│ │ └── entity/ # Room entities (AccountEntity, MessageEntity, etc.)
│ ├── remote/
│ │ └── XmppConnectionManager.kt # Smack connection lifecycle manager
│ └── repository/
│ ├── AccountRepository.kt
│ ├── ContactRepository.kt
│ ├── MessageRepository.kt
│ └── RoomRepository.kt
├── domain/
│ └── model/ # Pure Kotlin domain models
│ ├── Account.kt # XMPP account model
│ ├── Contact.kt # Roster contact
│ ├── Message.kt # Chat message with encryption + media metadata
│ ├── Room.kt # MUC room
│ └── Enums.kt # EncryptionType, MessageStatus, PresenceStatus, etc.
├── di/
│ └── AppModule.kt # Hilt module: DB, OkHttp, DataStore, XmppManager
├── media/
│ ├── AudioRecorder.kt # MediaRecorder wrapper with StateFlow
│ └── HttpUploadManager.kt # XEP-0363 file upload via OkHttp
├── service/
│ ├── XmppForegroundService.kt # Foreground service keeping XMPP alive
│ └── BootReceiver.kt # BroadcastReceiver to restart on boot
└── ui/
├── theme/
│ ├── Color.kt # Brand colors + bubble colors
│ ├── Theme.kt # Material3 dynamic theme with AppTheme enum
│ └── Type.kt # Typography scale
├── navigation/
│ ├── Screen.kt # Sealed class route definitions
│ └── AleJabberNavGraph.kt # NavHost with all destinations
├── components/
│ ├── AvatarWithStatus.kt # Avatar + presence dot component
│ └── EncryptionBadge.kt # Encryption type indicator badge
├── accounts/
│ ├── AccountsScreen.kt # Account list with connect/disconnect
│ ├── AccountsViewModel.kt
│ └── AddEditAccountScreen.kt # Add/edit XMPP account form
├── contacts/
│ ├── ContactsScreen.kt # Roster list with search + presence
│ └── ContactsViewModel.kt
├── rooms/
│ ├── RoomsScreen.kt # MUC rooms list
│ └── RoomsViewModel.kt
├── chat/
│ ├── ChatScreen.kt # Full chat UI with bubbles, media, recording
│ └── ChatViewModel.kt
└── settings/
├── SettingsScreen.kt # App preferences
└── SettingsViewModel.kt
```
---
## Getting Started
### Prerequisites
- Android Studio Hedgehog (2023.1.1) or later
- JDK 11+
- Android SDK 36
- A running XMPP server (e.g., [ejabberd](https://www.ejabberd.im/), [Prosody](https://prosody.im/), [Openfire](https://www.igniterealtime.org/projects/openfire/))
### Build
```bash
# Clone the repository
git clone https://github.com/manalejandro/AleJabber.git
cd AleJabber
# Build debug APK
./gradlew assembleDebug
# Install on connected device
./gradlew installDebug
# Run unit tests
./gradlew test
# Run instrumented tests
./gradlew connectedAndroidTest
```
The debug APK will be at:
```
app/build/outputs/apk/debug/app-debug.apk
```
---
## Configuration
### Adding an XMPP Account
1. Open the app → tap **Accounts** tab
2. Press the **+** FAB
3. Fill in:
- **JID** — your full Jabber ID, e.g. `user@jabber.org`
- **Password** — your account password
- **Server** _(optional)_ — override DNS-resolved hostname
- **Port** _(default: 5222)_ — custom port if needed
- **TLS** — toggle to require TLS (recommended)
- **Resource** _(default: AleJabber)_ — client resource identifier
### Gradle Properties
`gradle.properties` contains build-time flags:
| Property | Default | Description |
|----------|---------|-------------|
| `android.disallowKotlinSourceSets` | `false` | Required for KSP + AGP 9.x compatibility |
| `org.gradle.jvmargs` | `-Xmx2048m` | Gradle daemon heap size |
| `android.useAndroidX` | `true` | AndroidX migration flag |
---
## Encryption
AleJabber supports three levels of end-to-end encryption, selectable per conversation:
### OMEMO (Recommended — XEP-0384)
- Multi-device, forward-secrecy encryption based on the Signal Protocol
- Works even when the recipient is offline
- Select **OMEMO** in the encryption picker (🔒 icon in chat toolbar)
### OTR (Off-the-Record — XEP-0364)
- Classic two-party encryption with perfect forward secrecy
- Requires both parties to be online simultaneously
- Best for high-privacy one-on-one conversations
### OpenPGP (XEP-0373/0374)
- Asymmetric RSA/ECC encryption using PGP key pairs
- Works offline; keys must be exchanged in advance
- Uses Bouncy Castle (`bcpg-jdk18on`, `bcprov-jdk18on`)
### None (Plain Text)
- Messages are sent unencrypted over the TLS-secured XMPP stream
- Only use on trusted, private servers
---
## Multimedia & Audio
### File Upload (XEP-0363 `http_upload`)
1. Tap the 📎 attach button in the chat input
2. Select any file from the device storage
3. AleJabber requests an upload slot from the XMPP server
4. The file is PUT to the provided URL via OkHttp
5. The download URL is sent as a message body
6. Images are auto-rendered inline in the chat bubble
### Audio Messages
1. In the chat input, press and hold the 🎙️ microphone button
2. Speak your message — a recording timer appears
3. Release to **send**, or tap **✕** to cancel
4. Audio is recorded with `MediaRecorder` (AAC/MP4 format)
5. The recording is uploaded via `http_upload` automatically
> **Note:** Microphone permission (`RECORD_AUDIO`) is requested on first use.
---
## Internationalization
AleJabber ships with three locale bundles:
| Locale | File |
|--------|------|
| English (default) | `app/src/main/res/values/strings.xml` |
| Spanish | `app/src/main/res/values-es/strings.xml` |
| Chinese (Simplified) | `app/src/main/res/values-zh/strings.xml` |
To add a new language:
1. Create `app/src/main/res/values-<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
Ver fichero

@@ -0,0 +1 @@
/build

119
app/build.gradle.kts Archivo normal
Ver fichero

@@ -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
Ver fichero

@@ -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

Ver fichero

@@ -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)
}
}

Ver fichero

@@ -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>

Ver fichero

@@ -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

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 24 KiB

Ver fichero

@@ -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
}
}

Ver fichero

@@ -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
)

Ver fichero

@@ -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)"
)
}
}
}
}

Ver fichero

@@ -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)
}

Ver fichero

@@ -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)
}

Ver fichero

@@ -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)
}

Ver fichero

@@ -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)
}

Ver fichero

@@ -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
)

Ver fichero

@@ -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"
)

Ver fichero

@@ -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
)

Ver fichero

@@ -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
)

Ver fichero

@@ -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
)

Ver fichero

@@ -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
}

Ver fichero

@@ -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)
}

Ver fichero

@@ -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)
}
}

Ver fichero

@@ -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)
}

Ver fichero

@@ -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
}
}
}

Ver fichero

@@ -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
}

Ver fichero

@@ -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()
}

Ver fichero

@@ -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
}

Ver fichero

@@ -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
}

Ver fichero

@@ -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
}

Ver fichero

@@ -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
}

Ver fichero

@@ -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
}
}

Ver fichero

@@ -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
}
}
}

Ver fichero

@@ -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)
}
}
}

Ver fichero

@@ -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()
}
}

Ver fichero

@@ -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"
}

Ver fichero

@@ -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) }
}
}
}
}

Ver fichero

@@ -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)
}
}
}
}
}

Ver fichero

@@ -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)
}

Ver fichero

@@ -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) }
}

Ver fichero

@@ -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"
}

Ver fichero

@@ -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"
}

Ver fichero

@@ -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) }
}
}
}

Ver fichero

@@ -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}") }
}
}
}
}

Ver fichero

@@ -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() })
}
}
}

Ver fichero

@@ -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)) }
}
)
}

Ver fichero

@@ -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) }
}

Ver fichero

@@ -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"
}

Ver fichero

@@ -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 }
}
}
}

Ver fichero

@@ -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)

Ver fichero

@@ -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
)
}

Ver fichero

@@ -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
)
*/
)

Ver fichero

@@ -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>

Ver fichero

@@ -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>

Ver fichero

@@ -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>

Ver fichero

@@ -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>

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.7 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 3.4 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.2 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 2.2 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 2.4 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 4.7 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 3.6 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 7.3 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 5.0 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 10 KiB

Ver fichero

@@ -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>

Ver fichero

@@ -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>

Ver fichero

@@ -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>

Ver fichero

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#24308B</color>
</resources>

Ver fichero

@@ -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>

Ver fichero

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.AleJabber" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

Ver fichero

@@ -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>

Ver fichero

@@ -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>

Ver fichero

@@ -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>

Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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

Ver fichero

@@ -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
Ver fichero

@@ -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

Archivo binario no mostrado.

9
gradle/wrapper/gradle-wrapper.properties vendido Archivo normal
Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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")