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