initial commit

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

1
app/.gitignore vendido Archivo normal
Ver fichero

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

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

@@ -0,0 +1,119 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}
android {
namespace = "com.manalejandro.alejabber"
compileSdk = 36
defaultConfig {
applicationId = "com.manalejandro.alejabber"
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += setOf(
"META-INF/DEPENDENCIES",
"META-INF/LICENSE",
"META-INF/LICENSE.txt",
"META-INF/license.txt",
"META-INF/NOTICE",
"META-INF/NOTICE.txt",
"META-INF/notice.txt",
"META-INF/*.kotlin_module",
"META-INF/INDEX.LIST",
"META-INF/io.netty.versions.properties",
"META-INF/AL2.0",
"META-INF/LGPL2.1",
"mozilla/public-suffix-list.txt"
)
}
}
}
configurations.all {
resolutionStrategy {
force("org.bouncycastle:bcprov-jdk18on:1.78.1")
force("org.bouncycastle:bcpg-jdk18on:1.78.1")
}
// Exclude old BouncyCastle and duplicate xpp3 versions
exclude(group = "org.bouncycastle", module = "bcprov-jdk15on")
exclude(group = "org.bouncycastle", module = "bcpg-jdk15on")
exclude(group = "xpp3", module = "xpp3")
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.material.icons.extended)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
implementation(libs.navigation.compose)
implementation(libs.datastore.preferences)
implementation(libs.coil.compose)
implementation(libs.smack.android)
implementation(libs.smack.android.extensions) {
exclude(group = "org.bouncycastle")
}
implementation(libs.smack.tcp) {
exclude(group = "org.bouncycastle")
}
implementation(libs.smack.im) {
exclude(group = "org.bouncycastle")
}
implementation(libs.smack.extensions) {
exclude(group = "org.bouncycastle")
}
implementation(libs.smack.omemo) {
exclude(group = "org.bouncycastle")
}
implementation(libs.smack.omemo.signal) {
exclude(group = "org.bouncycastle")
}
implementation(libs.smack.openpgp) {
exclude(group = "org.bouncycastle")
}
implementation(libs.okhttp)
implementation(libs.coroutines.android)
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.lifecycle.runtime.compose)
implementation(libs.bouncycastle.bcpg)
implementation(libs.bouncycastle.bcprov)
implementation(libs.accompanist.permissions)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}

21
app/proguard-rules.pro vendido Archivo normal
Ver fichero

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

Ver fichero

@@ -0,0 +1,24 @@
package com.manalejandro.alejabber
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.manalejandro.alejabber", appContext.packageName)
}
}

Ver fichero

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.CAMERA" />
<application
android:name=".AleJabberApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.AleJabber"
android:usesCleartextTraffic="false"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.AleJabber"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.XmppForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false" />
<receiver
android:name=".service.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

Ver fichero

@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
AleJabber Application Icon
===========================
Format : SVG 1.1
Canvas : 512 × 512 px
Design : Chat bubble with XMPP/Jabber lightning bolt and presence indicator dot.
Colors :
- Background : Deep Indigo #1A237E
- Bubble : White #FFFFFF
- Bolt : Vivid Indigo #3D5AFE
- Presence : Green #00E676
Usage : app/src/main/res/drawable/ (Android), docs/, store listing.
-->
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
width="512"
height="512"
role="img"
aria-label="AleJabber application icon">
<title>AleJabber</title>
<desc>XMPP/Jabber client icon: a speech bubble with a lightning bolt symbolising fast, secure messaging.</desc>
<!-- ── Background circle ── -->
<circle cx="256" cy="256" r="256" fill="#1A237E"/>
<!-- ── Subtle inner highlight ── -->
<circle cx="256" cy="230" r="180" fill="#283593" opacity="0.6"/>
<!-- ── Speech bubble (white) ── -->
<path d="
M 110 110
Q 80 110 80 140
L 80 310
Q 80 340 110 340
L 155 340
L 140 420
L 240 340
L 400 340
Q 430 340 430 310
L 430 140
Q 430 110 400 110
Z"
fill="#FFFFFF"/>
<!-- ── Speech bubble inner tint ── -->
<path d="
M 116 118
Q 90 118 90 144
L 90 306
Q 90 332 116 332
L 157 332
L 144 410
L 238 332
L 396 332
Q 422 332 422 306
L 422 144
Q 422 118 396 118
Z"
fill="#C5CAE9" opacity="0.55"/>
<!-- ── XMPP Lightning bolt (indigo) ── -->
<path d="M 300 122 L 210 258 L 258 258 L 192 388 L 322 258 L 270 258 Z"
fill="#3D5AFE"/>
<!-- ── Presence indicator dot (online green) ── -->
<circle cx="400" cy="128" r="32" fill="#00E676"/>
<circle cx="400" cy="128" r="22" fill="#69F0AE"/>
</svg>

Después

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

Archivo binario no mostrado.

Después

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

Ver fichero

@@ -0,0 +1,49 @@
package com.manalejandro.alejabber
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class AleJabberApp : Application() {
override fun onCreate() {
super.onCreate()
createNotificationChannels()
}
private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val nm = getSystemService(NotificationManager::class.java)
val messagesChannel = NotificationChannel(
CHANNEL_MESSAGES,
getString(R.string.notification_channel_messages),
NotificationManager.IMPORTANCE_HIGH
).apply {
description = getString(R.string.notification_channel_messages_desc)
enableVibration(true)
}
val serviceChannel = NotificationChannel(
CHANNEL_SERVICE,
getString(R.string.notification_channel_service),
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.notification_channel_service_desc)
setShowBadge(false)
}
nm.createNotificationChannels(listOf(messagesChannel, serviceChannel))
}
}
companion object {
const val CHANNEL_MESSAGES = "channel_messages"
const val CHANNEL_SERVICE = "channel_service"
const val NOTIFICATION_ID_SERVICE = 1001
}
}

Ver fichero

@@ -0,0 +1,144 @@
package com.manalejandro.alejabber
import android.content.Intent
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Forum
import androidx.compose.material.icons.filled.ManageAccounts
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.Forum
import androidx.compose.material.icons.outlined.ManageAccounts
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.core.content.ContextCompat
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.manalejandro.alejabber.service.XmppForegroundService
import com.manalejandro.alejabber.ui.navigation.AleJabberNavGraph
import com.manalejandro.alejabber.ui.navigation.Screen
import com.manalejandro.alejabber.ui.theme.AleJabberTheme
import com.manalejandro.alejabber.ui.theme.AppTheme
import dagger.hilt.android.AndroidEntryPoint
/**
* Single-Activity entry point.
*
* Navigation structure:
* Accounts (home) ──► Contacts(accountId) ──► Chat
* ──► AddEditAccount
* Rooms ──► Chat
* Settings
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
startXmppService()
setContent {
AleJabberTheme(appTheme = AppTheme.SYSTEM) {
MainAppContent()
}
}
}
private fun startXmppService() {
val intent = Intent(this, XmppForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ContextCompat.startForegroundService(this, intent)
} else {
startService(intent)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainAppContent() {
val navController = rememberNavController()
val navBackStack by navController.currentBackStackEntryAsState()
val currentDestination = navBackStack?.destination
// Bottom nav items: Accounts | Rooms | Settings
// Contacts is NOT in the bottom nav — it's a drill-down from an account.
val bottomNavItems = listOf(
BottomNavItem(
route = Screen.Accounts.route,
labelRes = R.string.nav_accounts,
selectedIcon = Icons.Filled.ManageAccounts,
unselectedIcon = Icons.Outlined.ManageAccounts
),
BottomNavItem(
route = Screen.Rooms.route,
labelRes = R.string.nav_rooms,
selectedIcon = Icons.Filled.Forum,
unselectedIcon = Icons.Outlined.Forum
),
BottomNavItem(
route = Screen.Settings.route,
labelRes = R.string.nav_settings,
selectedIcon = Icons.Filled.Settings,
unselectedIcon = Icons.Outlined.Settings
)
)
// Show bottom nav only on the three top-level destinations
val topLevelRoutes = setOf(Screen.Accounts.route, Screen.Rooms.route, Screen.Settings.route)
val showBottomBar = currentDestination?.hierarchy?.any { it.route in topLevelRoutes } == true
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = {
if (showBottomBar) {
NavigationBar {
bottomNavItems.forEach { item ->
val selected = currentDestination?.hierarchy?.any { it.route == item.route } == true
NavigationBarItem(
selected = selected,
onClick = {
navController.navigate(item.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = {
Icon(
if (selected) item.selectedIcon else item.unselectedIcon,
contentDescription = stringResource(item.labelRes)
)
},
label = { Text(stringResource(item.labelRes)) }
)
}
}
}
}
) { _ ->
AleJabberNavGraph(
navController = navController,
startDestination = Screen.Accounts.route
)
}
}
/** Item descriptor for the bottom navigation bar. */
data class BottomNavItem(
val route: String,
val labelRes: Int,
val selectedIcon: androidx.compose.ui.graphics.vector.ImageVector,
val unselectedIcon: androidx.compose.ui.graphics.vector.ImageVector
)

Ver fichero

@@ -0,0 +1,67 @@
package com.manalejandro.alejabber.data.local
import androidx.room.Database
import androidx.room.migration.Migration
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import com.manalejandro.alejabber.data.local.dao.AccountDao
import com.manalejandro.alejabber.data.local.dao.ContactDao
import com.manalejandro.alejabber.data.local.dao.MessageDao
import com.manalejandro.alejabber.data.local.dao.RoomDao
import com.manalejandro.alejabber.data.local.entity.AccountEntity
import com.manalejandro.alejabber.data.local.entity.ContactEntity
import com.manalejandro.alejabber.data.local.entity.MessageEntity
import com.manalejandro.alejabber.data.local.entity.RoomEntity
@Database(
entities = [
AccountEntity::class,
ContactEntity::class,
MessageEntity::class,
RoomEntity::class
],
version = 2,
exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
abstract fun accountDao(): AccountDao
abstract fun contactDao(): ContactDao
abstract fun messageDao(): MessageDao
abstract fun roomDao(): RoomDao
companion object {
/**
* Migration 1 → 2:
*
* Adds a unique composite index on (accountId, jid) in the contacts table.
* Before creating the index, existing duplicate rows (same accountId + jid)
* are removed, keeping only the row with the highest presence rank (most available).
*
* This fixes the crash:
* java.lang.IllegalArgumentException: Key "<jid>" was already used
* that occurred in ContactsScreen's LazyColumn when the roster sync
* inserted duplicate rows for the same contact.
*/
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
// 1. Remove duplicates — keep only the row with the lowest id (oldest entry)
// for each (accountId, jid) pair.
db.execSQL(
"""
DELETE FROM contacts
WHERE id NOT IN (
SELECT MIN(id)
FROM contacts
GROUP BY accountId, jid
)
""".trimIndent()
)
// 2. Create the unique composite index.
db.execSQL(
"CREATE UNIQUE INDEX IF NOT EXISTS index_contacts_accountId_jid " +
"ON contacts (accountId, jid)"
)
}
}
}
}

Ver fichero

@@ -0,0 +1,30 @@
package com.manalejandro.alejabber.data.local.dao
import androidx.room.*
import com.manalejandro.alejabber.data.local.entity.AccountEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface AccountDao {
@Query("SELECT * FROM accounts ORDER BY jid ASC")
fun getAllAccounts(): Flow<List<AccountEntity>>
@Query("SELECT * FROM accounts WHERE id = :id")
suspend fun getAccountById(id: Long): AccountEntity?
@Query("SELECT * FROM accounts WHERE isEnabled = 1")
fun getEnabledAccounts(): Flow<List<AccountEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAccount(account: AccountEntity): Long
@Update
suspend fun updateAccount(account: AccountEntity)
@Delete
suspend fun deleteAccount(account: AccountEntity)
@Query("DELETE FROM accounts WHERE id = :id")
suspend fun deleteAccountById(id: Long)
}

Ver fichero

@@ -0,0 +1,36 @@
package com.manalejandro.alejabber.data.local.dao
import androidx.room.*
import com.manalejandro.alejabber.data.local.entity.ContactEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface ContactDao {
@Query("SELECT * FROM contacts WHERE accountId = :accountId ORDER BY nickname ASC, jid ASC")
fun getContactsByAccount(accountId: Long): Flow<List<ContactEntity>>
@Query("SELECT * FROM contacts WHERE accountId = :accountId AND jid = :jid LIMIT 1")
suspend fun getContact(accountId: Long, jid: String): ContactEntity?
@Query("SELECT * FROM contacts WHERE accountId = :accountId AND (jid LIKE '%' || :query || '%' OR nickname LIKE '%' || :query || '%')")
fun searchContacts(accountId: Long, query: String): Flow<List<ContactEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertContact(contact: ContactEntity): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertContacts(contacts: List<ContactEntity>)
@Update
suspend fun updateContact(contact: ContactEntity)
@Delete
suspend fun deleteContact(contact: ContactEntity)
@Query("DELETE FROM contacts WHERE accountId = :accountId AND jid = :jid")
suspend fun deleteContact(accountId: Long, jid: String)
@Query("UPDATE contacts SET presence = :presence, statusMessage = :statusMessage WHERE accountId = :accountId AND jid = :jid")
suspend fun updatePresence(accountId: Long, jid: String, presence: String, statusMessage: String)
}

Ver fichero

@@ -0,0 +1,36 @@
package com.manalejandro.alejabber.data.local.dao
import androidx.room.*
import com.manalejandro.alejabber.data.local.entity.MessageEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface MessageDao {
@Query("SELECT * FROM messages WHERE accountId = :accountId AND conversationJid = :conversationJid AND isDeleted = 0 ORDER BY timestamp ASC")
fun getMessages(accountId: Long, conversationJid: String): Flow<List<MessageEntity>>
@Query("SELECT * FROM messages WHERE accountId = :accountId AND conversationJid = :conversationJid ORDER BY timestamp DESC LIMIT 1")
suspend fun getLastMessage(accountId: Long, conversationJid: String): MessageEntity?
@Query("SELECT COUNT(*) FROM messages WHERE accountId = :accountId AND conversationJid = :conversationJid AND isRead = 0 AND direction = 'INCOMING'")
fun getUnreadCount(accountId: Long, conversationJid: String): Flow<Int>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMessage(message: MessageEntity): Long
@Update
suspend fun updateMessage(message: MessageEntity)
@Query("UPDATE messages SET isRead = 1 WHERE accountId = :accountId AND conversationJid = :conversationJid AND direction = 'INCOMING'")
suspend fun markAllAsRead(accountId: Long, conversationJid: String)
@Query("UPDATE messages SET status = :status WHERE id = :id")
suspend fun updateStatus(id: Long, status: String)
@Query("UPDATE messages SET isDeleted = 1 WHERE id = :id")
suspend fun deleteMessage(id: Long)
@Query("DELETE FROM messages WHERE accountId = :accountId AND conversationJid = :conversationJid")
suspend fun clearConversation(accountId: Long, conversationJid: String)
}

Ver fichero

@@ -0,0 +1,36 @@
package com.manalejandro.alejabber.data.local.dao
import androidx.room.*
import com.manalejandro.alejabber.data.local.entity.RoomEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface RoomDao {
@Query("SELECT * FROM rooms WHERE accountId = :accountId ORDER BY isFavorite DESC, lastMessageTime DESC")
fun getRoomsByAccount(accountId: Long): Flow<List<RoomEntity>>
@Query("SELECT * FROM rooms WHERE accountId = :accountId AND isJoined = 1")
fun getJoinedRooms(accountId: Long): Flow<List<RoomEntity>>
@Query("SELECT * FROM rooms WHERE accountId = :accountId AND jid = :jid LIMIT 1")
suspend fun getRoom(accountId: Long, jid: String): RoomEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRoom(room: RoomEntity): Long
@Update
suspend fun updateRoom(room: RoomEntity)
@Delete
suspend fun deleteRoom(room: RoomEntity)
@Query("UPDATE rooms SET isJoined = :isJoined WHERE accountId = :accountId AND jid = :jid")
suspend fun updateJoinStatus(accountId: Long, jid: String, isJoined: Boolean)
@Query("UPDATE rooms SET unreadCount = :count WHERE accountId = :accountId AND jid = :jid")
suspend fun updateUnreadCount(accountId: Long, jid: String, count: Int)
@Query("UPDATE rooms SET lastMessage = :lastMessage, lastMessageTime = :time WHERE accountId = :accountId AND jid = :jid")
suspend fun updateLastMessage(accountId: Long, jid: String, lastMessage: String, time: Long)
}

Ver fichero

@@ -0,0 +1,19 @@
package com.manalejandro.alejabber.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "accounts")
data class AccountEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val jid: String,
val password: String,
val server: String = "",
val port: Int = 5222,
val useTls: Boolean = true,
val resource: String = "AleJabber",
val isEnabled: Boolean = true,
val statusMessage: String = "",
val avatarUrl: String? = null
)

Ver fichero

@@ -0,0 +1,41 @@
package com.manalejandro.alejabber.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Room entity representing an XMPP roster contact.
*
* The composite unique index on (accountId, jid) ensures that calling
* [ContactDao.insertContact] / [ContactDao.insertContacts] with
* [OnConflictStrategy.REPLACE] updates an existing row rather than
* inserting a duplicate — which would cause LazyColumn key-collision crashes.
*/
@Entity(
tableName = "contacts",
foreignKeys = [ForeignKey(
entity = AccountEntity::class,
parentColumns = ["id"],
childColumns = ["accountId"],
onDelete = ForeignKey.CASCADE
)],
indices = [
Index("accountId"),
// Unique composite index — prevents duplicate (account, jid) pairs
Index(value = ["accountId", "jid"], unique = true)
]
)
data class ContactEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val accountId: Long,
val jid: String,
val nickname: String = "",
val groups: String = "", // JSON array
val presence: String = "OFFLINE",
val statusMessage: String = "",
val avatarUrl: String? = null,
val isBlocked: Boolean = false,
val subscriptionState: String = "NONE"
)

Ver fichero

@@ -0,0 +1,70 @@
package com.manalejandro.alejabber.data.local.entity
import com.manalejandro.alejabber.domain.model.*
fun AccountEntity.toDomain(status: ConnectionStatus = ConnectionStatus.OFFLINE) = Account(
id = id, jid = jid, password = password, server = server, port = port,
useTls = useTls, resource = resource, isEnabled = isEnabled,
status = status, statusMessage = statusMessage, avatarUrl = avatarUrl
)
fun Account.toEntity() = AccountEntity(
id = id, jid = jid, password = password, server = server, port = port,
useTls = useTls, resource = resource, isEnabled = isEnabled,
statusMessage = statusMessage, avatarUrl = avatarUrl
)
fun ContactEntity.toDomain() = Contact(
id = id, accountId = accountId, jid = jid, nickname = nickname,
groups = groups.split(",").filter { it.isNotBlank() },
presence = try { PresenceStatus.valueOf(presence) } catch (e: Exception) { PresenceStatus.OFFLINE },
statusMessage = statusMessage, avatarUrl = avatarUrl, isBlocked = isBlocked,
subscriptionState = try { SubscriptionState.valueOf(subscriptionState) } catch (e: Exception) { SubscriptionState.NONE }
)
fun Contact.toEntity() = ContactEntity(
id = id, accountId = accountId, jid = jid, nickname = nickname,
groups = groups.joinToString(","), presence = presence.name,
statusMessage = statusMessage, avatarUrl = avatarUrl, isBlocked = isBlocked,
subscriptionState = subscriptionState.name
)
fun MessageEntity.toDomain() = Message(
id = id, stanzaId = stanzaId, accountId = accountId,
conversationJid = conversationJid, fromJid = fromJid, toJid = toJid,
body = body, timestamp = timestamp,
direction = try { MessageDirection.valueOf(direction) } catch (e: Exception) { MessageDirection.INCOMING },
status = try { MessageStatus.valueOf(status) } catch (e: Exception) { MessageStatus.PENDING },
encryptionType = try { EncryptionType.valueOf(encryptionType) } catch (e: Exception) { EncryptionType.NONE },
mediaType = try { MediaType.valueOf(mediaType) } catch (e: Exception) { MediaType.TEXT },
mediaUrl = mediaUrl, mediaLocalPath = mediaLocalPath, mediaMimeType = mediaMimeType,
mediaSize = mediaSize, mediaName = mediaName, audioDurationMs = audioDurationMs,
isRead = isRead, isEdited = isEdited, isDeleted = isDeleted, replyToId = replyToId
)
fun Message.toEntity() = MessageEntity(
id = id, stanzaId = stanzaId, accountId = accountId,
conversationJid = conversationJid, fromJid = fromJid, toJid = toJid,
body = body, timestamp = timestamp, direction = direction.name, status = status.name,
encryptionType = encryptionType.name, mediaType = mediaType.name,
mediaUrl = mediaUrl, mediaLocalPath = mediaLocalPath, mediaMimeType = mediaMimeType,
mediaSize = mediaSize, mediaName = mediaName, audioDurationMs = audioDurationMs,
isRead = isRead, isEdited = isEdited, isDeleted = isDeleted, replyToId = replyToId
)
fun RoomEntity.toDomain() = Room(
id = id, accountId = accountId, jid = jid, nickname = nickname, name = name,
description = description, topic = topic, password = password,
isJoined = isJoined, isFavorite = isFavorite, participantCount = participantCount,
avatarUrl = avatarUrl, unreadCount = unreadCount, lastMessage = lastMessage,
lastMessageTime = lastMessageTime
)
fun Room.toEntity() = RoomEntity(
id = id, accountId = accountId, jid = jid, nickname = nickname, name = name,
description = description, topic = topic, password = password,
isJoined = isJoined, isFavorite = isFavorite, participantCount = participantCount,
avatarUrl = avatarUrl, unreadCount = unreadCount, lastMessage = lastMessage,
lastMessageTime = lastMessageTime
)

Ver fichero

@@ -0,0 +1,42 @@
package com.manalejandro.alejabber.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "messages",
foreignKeys = [ForeignKey(
entity = AccountEntity::class,
parentColumns = ["id"],
childColumns = ["accountId"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index("accountId"), Index("conversationJid"), Index("timestamp")]
)
data class MessageEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val stanzaId: String = "",
val accountId: Long,
val conversationJid: String,
val fromJid: String,
val toJid: String,
val body: String = "",
val timestamp: Long = System.currentTimeMillis(),
val direction: String, // INCOMING / OUTGOING
val status: String = "PENDING",
val encryptionType: String = "NONE",
val mediaType: String = "TEXT",
val mediaUrl: String? = null,
val mediaLocalPath: String? = null,
val mediaMimeType: String? = null,
val mediaSize: Long = 0,
val mediaName: String? = null,
val audioDurationMs: Long = 0,
val isRead: Boolean = false,
val isEdited: Boolean = false,
val isDeleted: Boolean = false,
val replyToId: Long? = null
)

Ver fichero

@@ -0,0 +1,35 @@
package com.manalejandro.alejabber.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "rooms",
foreignKeys = [ForeignKey(
entity = AccountEntity::class,
parentColumns = ["id"],
childColumns = ["accountId"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index("accountId")]
)
data class RoomEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val accountId: Long,
val jid: String,
val nickname: String = "",
val name: String = "",
val description: String = "",
val topic: String = "",
val password: String = "",
val isJoined: Boolean = false,
val isFavorite: Boolean = false,
val participantCount: Int = 0,
val avatarUrl: String? = null,
val unreadCount: Int = 0,
val lastMessage: String = "",
val lastMessageTime: Long = 0
)

Ver fichero

@@ -0,0 +1,290 @@
package com.manalejandro.alejabber.data.remote
import android.util.Log
import com.manalejandro.alejabber.domain.model.Account
import com.manalejandro.alejabber.domain.model.ConnectionStatus
import com.manalejandro.alejabber.domain.model.PresenceStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jivesoftware.smack.AbstractXMPPConnection
import org.jivesoftware.smack.ConnectionConfiguration
import org.jivesoftware.smack.ReconnectionManager
import org.jivesoftware.smack.SmackException
import org.jivesoftware.smack.XMPPException
import org.jivesoftware.smack.chat2.ChatManager
import org.jivesoftware.smack.packet.Presence
import org.jivesoftware.smack.roster.Roster
import org.jivesoftware.smack.roster.RosterEntry
import org.jivesoftware.smack.roster.RosterListener
import org.jivesoftware.smack.tcp.XMPPTCPConnection
import org.jivesoftware.smack.tcp.XMPPTCPConnectionConfiguration
import org.jxmpp.jid.Jid
import org.jxmpp.jid.impl.JidCreate
import org.jxmpp.jid.parts.Resourcepart
import javax.inject.Inject
import javax.inject.Singleton
data class IncomingMessage(
val accountId: Long,
val from: String,
val body: String,
val timestamp: Long = System.currentTimeMillis()
)
data class PresenceUpdate(
val accountId: Long,
val jid: String,
val status: PresenceStatus,
val statusMessage: String
)
@Singleton
class XmppConnectionManager @Inject constructor() {
private val TAG = "XmppConnectionManager"
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val connections = mutableMapOf<Long, AbstractXMPPConnection>()
// ── Account connection status ─────────────────────────────────────────
private val _connectionStatus = MutableStateFlow<Map<Long, ConnectionStatus>>(emptyMap())
val connectionStatus: StateFlow<Map<Long, ConnectionStatus>> = _connectionStatus.asStateFlow()
// ── Incoming chat messages ────────────────────────────────────────────
private val _incomingMessages = MutableSharedFlow<IncomingMessage>()
val incomingMessages: SharedFlow<IncomingMessage> = _incomingMessages.asSharedFlow()
// ── Live presence map: accountId → (bareJid → PresenceStatus) ────────
// Updated on every presence stanza. Consumers (ContactRepository) combine
// this with Room data so contacts show the correct online/away/offline state
// without having to write every presence change to the database.
private val _rosterPresence =
MutableStateFlow<Map<Long, Map<String, PresenceStatus>>>(emptyMap())
val rosterPresence: StateFlow<Map<Long, Map<String, PresenceStatus>>> =
_rosterPresence.asStateFlow()
// ── Presence updates (kept for backward compatibility) ────────────────
private val _presenceUpdates = MutableSharedFlow<PresenceUpdate>(extraBufferCapacity = 64)
val presenceUpdates: SharedFlow<PresenceUpdate> = _presenceUpdates.asSharedFlow()
// ─────────────────────────────────────────────────────────────────────
fun connect(account: Account) {
scope.launch {
try {
updateStatus(account.id, ConnectionStatus.CONNECTING)
val config = buildConfig(account)
val connection = XMPPTCPConnection(config)
connections[account.id] = connection
connection.connect()
connection.login()
ReconnectionManager.getInstanceFor(connection).apply {
enableAutomaticReconnection()
setReconnectionPolicy(ReconnectionManager.ReconnectionPolicy.RANDOM_INCREASING_DELAY)
}
setupRoster(account.id, connection)
setupMessageListener(account.id, connection)
updateStatus(account.id, ConnectionStatus.ONLINE)
Log.i(TAG, "Connected: ${account.jid}")
} catch (e: XMPPException) {
Log.e(TAG, "XMPP error for ${account.jid}", e)
updateStatus(account.id, ConnectionStatus.ERROR)
} catch (e: SmackException) {
Log.e(TAG, "Smack error for ${account.jid}", e)
updateStatus(account.id, ConnectionStatus.ERROR)
} catch (e: Exception) {
Log.e(TAG, "Connection error for ${account.jid}", e)
updateStatus(account.id, ConnectionStatus.ERROR)
}
}
}
fun disconnect(accountId: Long) {
scope.launch {
try {
connections[accountId]?.disconnect()
connections.remove(accountId)
// Clear presence data for this account
_rosterPresence.update { it - accountId }
updateStatus(accountId, ConnectionStatus.OFFLINE)
} catch (e: Exception) {
Log.e(TAG, "Disconnect error", e)
}
}
}
fun disconnectAll() {
connections.keys.toList().forEach { disconnect(it) }
}
fun sendMessage(accountId: Long, toJid: String, body: String): Boolean {
return try {
val connection = connections[accountId] ?: return false
if (!connection.isConnected) return false
val chatManager = ChatManager.getInstanceFor(connection)
val jid = JidCreate.entityBareFrom(toJid)
val chat = chatManager.chatWith(jid)
chat.send(body)
true
} catch (e: Exception) {
Log.e(TAG, "Send message error", e)
false
}
}
fun getRosterEntries(accountId: Long): List<RosterEntry> {
return try {
val connection = connections[accountId] ?: return emptyList()
val roster = Roster.getInstanceFor(connection)
roster.entries.toList()
} catch (e: Exception) {
Log.e(TAG, "Get roster error", e)
emptyList()
}
}
fun isConnected(accountId: Long): Boolean =
connections[accountId]?.isConnected == true &&
connections[accountId]?.isAuthenticated == true
fun getConnection(accountId: Long): AbstractXMPPConnection? = connections[accountId]
// ─────────────────────────────────────────────────────────────────────
private fun buildConfig(account: Account): XMPPTCPConnectionConfiguration {
val jid = JidCreate.entityBareFrom(account.jid)
val builder = XMPPTCPConnectionConfiguration.builder()
.setUsernameAndPassword(jid.localpart.toString(), account.password)
.setXmppDomain(jid.asDomainBareJid())
.setResource(Resourcepart.from(account.resource.ifBlank { "AleJabber" }))
.setConnectTimeout(30_000)
.setSendPresence(true)
if (account.server.isNotBlank()) builder.setHost(account.server)
if (account.port != 5222) builder.setPort(account.port)
builder.setSecurityMode(
if (account.useTls) ConnectionConfiguration.SecurityMode.required
else ConnectionConfiguration.SecurityMode.disabled
)
return builder.build()
}
private fun setupRoster(accountId: Long, connection: AbstractXMPPConnection) {
val roster = Roster.getInstanceFor(connection)
roster.isRosterLoadedAtLogin = true
// ── Snapshot all current presences once the roster is loaded ──────
scope.launch {
try {
// Wait until Smack has fetched the roster from the server
roster.reloadAndWait()
val snapshot = mutableMapOf<String, PresenceStatus>()
roster.entries.forEach { entry ->
val bareJid = entry.jid.asBareJid().toString()
val p = roster.getPresence(entry.jid.asBareJid())
snapshot[bareJid] = p.toPresenceStatus()
}
_rosterPresence.update { current ->
current.toMutableMap().also { it[accountId] = snapshot }
}
Log.i(TAG, "Roster snapshot loaded for account $accountId: ${snapshot.size} contacts")
} catch (e: Exception) {
Log.w(TAG, "Roster snapshot failed", e)
}
}
// ── Listen for live presence changes ──────────────────────────────
roster.addRosterListener(object : RosterListener {
override fun entriesAdded(addresses: MutableCollection<Jid>?) {
// Refresh snapshot when new contacts are added
addresses?.forEach { jid ->
scope.launch {
try {
val bareJid = jid.asBareJid().toString()
val p = roster.getPresence(jid.asBareJid())
updatePresenceInMap(accountId, bareJid, p.toPresenceStatus(), p.status ?: "")
} catch (_: Exception) {}
}
}
}
override fun entriesUpdated(addresses: MutableCollection<Jid>?) {}
override fun entriesDeleted(addresses: MutableCollection<Jid>?) {}
override fun presenceChanged(presence: Presence) {
scope.launch {
val bareJid = presence.from?.asBareJid()?.toString() ?: return@launch
val presenceStatus = presence.toPresenceStatus()
val statusMsg = presence.status ?: ""
// Update the in-memory map
updatePresenceInMap(accountId, bareJid, presenceStatus, statusMsg)
Log.d(TAG, "Presence changed: $bareJid$presenceStatus")
}
}
})
}
/** Updates the [_rosterPresence] map and emits a [PresenceUpdate]. */
private suspend fun updatePresenceInMap(
accountId: Long,
bareJid: String,
status: PresenceStatus,
statusMsg: String
) {
_rosterPresence.update { current ->
val accountMap = current[accountId]?.toMutableMap() ?: mutableMapOf()
accountMap[bareJid] = status
current.toMutableMap().also { it[accountId] = accountMap }
}
_presenceUpdates.emit(
PresenceUpdate(accountId, bareJid, status, statusMsg)
)
}
private fun setupMessageListener(accountId: Long, connection: AbstractXMPPConnection) {
val chatManager = ChatManager.getInstanceFor(connection)
chatManager.addIncomingListener { from, message, _ ->
val body = message.body ?: return@addIncomingListener
scope.launch {
_incomingMessages.emit(
IncomingMessage(
accountId = accountId,
from = from.asBareJid().toString(),
body = body
)
)
}
}
}
private fun updateStatus(accountId: Long, status: ConnectionStatus) {
_connectionStatus.update { current ->
current.toMutableMap().also { it[accountId] = status }
}
}
}
// ── Extension helpers ─────────────────────────────────────────────────────
private fun Presence.toPresenceStatus(): PresenceStatus = when (type) {
Presence.Type.available -> when (mode) {
Presence.Mode.away, Presence.Mode.xa -> PresenceStatus.AWAY
Presence.Mode.dnd -> PresenceStatus.DND
else -> PresenceStatus.ONLINE
}
else -> PresenceStatus.OFFLINE
}

Ver fichero

@@ -0,0 +1,58 @@
package com.manalejandro.alejabber.data.repository
import com.manalejandro.alejabber.data.local.dao.AccountDao
import com.manalejandro.alejabber.data.local.entity.toDomain
import com.manalejandro.alejabber.data.local.entity.toEntity
import com.manalejandro.alejabber.data.remote.XmppConnectionManager
import com.manalejandro.alejabber.domain.model.Account
import com.manalejandro.alejabber.domain.model.ConnectionStatus
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AccountRepository @Inject constructor(
private val accountDao: AccountDao,
private val xmppManager: XmppConnectionManager
) {
fun getAllAccounts(): Flow<List<Account>> {
return accountDao.getAllAccounts()
.combine(xmppManager.connectionStatus) { entities, statusMap ->
entities.map { entity ->
entity.toDomain(statusMap[entity.id] ?: ConnectionStatus.OFFLINE)
}
}
}
suspend fun getAccountById(id: Long): Account? =
accountDao.getAccountById(id)?.toDomain(
xmppManager.connectionStatus.value[id] ?: ConnectionStatus.OFFLINE
)
suspend fun addAccount(account: Account): Long {
val id = accountDao.insertAccount(account.toEntity())
val savedAccount = account.copy(id = id)
if (savedAccount.isEnabled) {
xmppManager.connect(savedAccount)
}
return id
}
suspend fun updateAccount(account: Account) {
accountDao.updateAccount(account.toEntity())
}
suspend fun deleteAccount(id: Long) {
xmppManager.disconnect(id)
accountDao.deleteAccountById(id)
}
fun connectAccount(account: Account) = xmppManager.connect(account)
fun disconnectAccount(accountId: Long) = xmppManager.disconnect(accountId)
fun isConnected(accountId: Long) = xmppManager.isConnected(accountId)
}

Ver fichero

@@ -0,0 +1,97 @@
package com.manalejandro.alejabber.data.repository
import com.manalejandro.alejabber.data.local.dao.ContactDao
import com.manalejandro.alejabber.data.local.entity.toDomain
import com.manalejandro.alejabber.data.local.entity.toEntity
import com.manalejandro.alejabber.data.remote.XmppConnectionManager
import com.manalejandro.alejabber.domain.model.Contact
import com.manalejandro.alejabber.domain.model.PresenceStatus
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import org.jivesoftware.smack.roster.Roster
import org.jivesoftware.smack.roster.RosterEntry
import org.jxmpp.jid.impl.JidCreate
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ContactRepository @Inject constructor(
private val contactDao: ContactDao,
private val xmppManager: XmppConnectionManager
) {
/**
* Returns a Flow of contacts for [accountId], with **live presence** merged in.
*
* Room provides the persisted roster (name, JID, groups).
* [XmppConnectionManager.rosterPresence] provides the real-time online/away/offline state.
* The two are combined so the UI always shows the current presence without
* writing every presence stanza to the database.
*/
fun getContacts(accountId: Long): Flow<List<Contact>> =
contactDao.getContactsByAccount(accountId)
.combine(xmppManager.rosterPresence) { entities, presenceMap ->
val accountPresence = presenceMap[accountId] ?: emptyMap()
entities.map { entity ->
val livePresence = accountPresence[entity.jid]
if (livePresence != null) {
entity.toDomain().copy(presence = livePresence)
} else {
entity.toDomain()
}
}
}
fun searchContacts(accountId: Long, query: String): Flow<List<Contact>> =
contactDao.searchContacts(accountId, query).map { list -> list.map { it.toDomain() } }
suspend fun addContact(contact: Contact): Long {
val connection = xmppManager.getConnection(contact.accountId)
if (connection != null && connection.isConnected) {
try {
val roster = Roster.getInstanceFor(connection)
val jid = JidCreate.entityBareFrom(contact.jid)
roster.createItemAndRequestSubscription(
jid, contact.nickname.ifBlank { contact.jid }, null
)
} catch (e: Exception) {
// Proceed to save locally even if the server call fails
}
}
return contactDao.insertContact(contact.toEntity())
}
suspend fun removeContact(accountId: Long, jid: String) {
val connection = xmppManager.getConnection(accountId)
if (connection != null && connection.isConnected) {
try {
val roster = Roster.getInstanceFor(connection)
val entry: RosterEntry? = roster.getEntry(JidCreate.entityBareFrom(jid))
entry?.let { roster.removeEntry(it) }
} catch (e: Exception) { /* ignore */ }
}
contactDao.deleteContact(accountId, jid)
}
suspend fun syncRoster(accountId: Long) {
val entries = xmppManager.getRosterEntries(accountId)
val contacts = entries.map { entry ->
Contact(
accountId = accountId,
jid = entry.jid.asBareJid().toString(),
nickname = entry.name ?: entry.jid.asBareJid().toString(),
groups = entry.groups.map { it.name }
).toEntity()
}
if (contacts.isNotEmpty()) contactDao.insertContacts(contacts)
}
suspend fun updatePresence(
accountId: Long,
jid: String,
presence: PresenceStatus,
statusMessage: String
) {
contactDao.updatePresence(accountId, jid, presence.name, statusMessage)
}
}

Ver fichero

@@ -0,0 +1,74 @@
package com.manalejandro.alejabber.data.repository
import com.manalejandro.alejabber.data.local.dao.MessageDao
import com.manalejandro.alejabber.data.local.entity.toDomain
import com.manalejandro.alejabber.data.local.entity.toEntity
import com.manalejandro.alejabber.data.remote.XmppConnectionManager
import com.manalejandro.alejabber.domain.model.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MessageRepository @Inject constructor(
private val messageDao: MessageDao,
private val xmppManager: XmppConnectionManager
) {
fun getMessages(accountId: Long, conversationJid: String): Flow<List<Message>> =
messageDao.getMessages(accountId, conversationJid).map { list -> list.map { it.toDomain() } }
fun getUnreadCount(accountId: Long, conversationJid: String): Flow<Int> =
messageDao.getUnreadCount(accountId, conversationJid)
suspend fun sendMessage(
accountId: Long,
toJid: String,
body: String,
encryptionType: EncryptionType = EncryptionType.NONE
): Long {
val msg = Message(
accountId = accountId,
conversationJid = toJid,
fromJid = "",
toJid = toJid,
body = body,
direction = MessageDirection.OUTGOING,
status = MessageStatus.PENDING,
encryptionType = encryptionType
)
val id = messageDao.insertMessage(msg.toEntity())
val success = xmppManager.sendMessage(accountId, toJid, body)
val status = if (success) MessageStatus.SENT else MessageStatus.FAILED
messageDao.updateStatus(id, status.name)
return id
}
suspend fun saveIncomingMessage(
accountId: Long,
from: String,
body: String,
encryptionType: EncryptionType = EncryptionType.NONE
): Long {
val msg = Message(
accountId = accountId,
conversationJid = from,
fromJid = from,
toJid = "",
body = body,
direction = MessageDirection.INCOMING,
status = MessageStatus.DELIVERED,
encryptionType = encryptionType
)
return messageDao.insertMessage(msg.toEntity())
}
suspend fun markAllAsRead(accountId: Long, conversationJid: String) =
messageDao.markAllAsRead(accountId, conversationJid)
suspend fun deleteMessage(id: Long) = messageDao.deleteMessage(id)
suspend fun clearConversation(accountId: Long, conversationJid: String) =
messageDao.clearConversation(accountId, conversationJid)
}

Ver fichero

@@ -0,0 +1,78 @@
package com.manalejandro.alejabber.data.repository
import com.manalejandro.alejabber.data.local.dao.RoomDao
import com.manalejandro.alejabber.data.local.entity.toDomain
import com.manalejandro.alejabber.data.local.entity.toEntity
import com.manalejandro.alejabber.data.remote.XmppConnectionManager
import com.manalejandro.alejabber.domain.model.Room
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.jivesoftware.smack.SmackException
import org.jivesoftware.smackx.muc.MultiUserChat
import org.jivesoftware.smackx.muc.MultiUserChatManager
import org.jxmpp.jid.impl.JidCreate
import org.jxmpp.jid.parts.Resourcepart
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RoomRepository @Inject constructor(
private val roomDao: RoomDao,
private val xmppManager: XmppConnectionManager
) {
private val joinedChats = mutableMapOf<String, MultiUserChat>()
fun getRooms(accountId: Long): Flow<List<Room>> =
roomDao.getRoomsByAccount(accountId).map { list -> list.map { it.toDomain() } }
fun getJoinedRooms(accountId: Long): Flow<List<Room>> =
roomDao.getJoinedRooms(accountId).map { list -> list.map { it.toDomain() } }
suspend fun joinRoom(accountId: Long, roomJid: String, nickname: String, password: String = ""): Boolean {
return try {
val connection = xmppManager.getConnection(accountId) ?: return false
val mucManager = MultiUserChatManager.getInstanceFor(connection)
val jid = JidCreate.entityBareFrom(roomJid)
val muc = mucManager.getMultiUserChat(jid)
val resource = Resourcepart.from(nickname)
if (password.isNotBlank()) {
muc.join(resource, password)
} else {
muc.join(resource)
}
joinedChats["${accountId}_${roomJid}"] = muc
roomDao.updateJoinStatus(accountId, roomJid, true)
true
} catch (e: SmackException) {
false
} catch (e: Exception) {
false
}
}
suspend fun leaveRoom(accountId: Long, roomJid: String) {
try {
joinedChats["${accountId}_${roomJid}"]?.leave()
joinedChats.remove("${accountId}_${roomJid}")
} catch (e: Exception) { /* ignore */ }
roomDao.updateJoinStatus(accountId, roomJid, false)
}
suspend fun saveRoom(room: Room): Long = roomDao.insertRoom(room.toEntity())
suspend fun deleteRoom(room: Room) = roomDao.deleteRoom(room.toEntity())
fun getMuc(accountId: Long, roomJid: String): MultiUserChat? =
joinedChats["${accountId}_${roomJid}"]
suspend fun sendRoomMessage(accountId: Long, roomJid: String, body: String): Boolean {
return try {
val muc = joinedChats["${accountId}_${roomJid}"] ?: return false
muc.sendMessage(body)
true
} catch (e: Exception) {
false
}
}
}

Ver fichero

@@ -0,0 +1,36 @@
package com.manalejandro.alejabber.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient =
OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build()
@Provides
@Singleton
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.dataStore
}

Ver fichero

@@ -0,0 +1,41 @@
package com.manalejandro.alejabber.di
import android.content.Context
import androidx.room.Room
import com.manalejandro.alejabber.data.local.AppDatabase
import com.manalejandro.alejabber.data.local.dao.AccountDao
import com.manalejandro.alejabber.data.local.dao.ContactDao
import com.manalejandro.alejabber.data.local.dao.MessageDao
import com.manalejandro.alejabber.data.local.dao.RoomDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "alejabber.db")
.addMigrations(AppDatabase.MIGRATION_1_2)
.fallbackToDestructiveMigration(false)
.build()
@Provides
fun provideAccountDao(db: AppDatabase): AccountDao = db.accountDao()
@Provides
fun provideContactDao(db: AppDatabase): ContactDao = db.contactDao()
@Provides
fun provideMessageDao(db: AppDatabase): MessageDao = db.messageDao()
@Provides
fun provideRoomDao(db: AppDatabase): RoomDao = db.roomDao()
}

Ver fichero

@@ -0,0 +1,20 @@
package com.manalejandro.alejabber.domain.model
data class Account(
val id: Long = 0,
val jid: String,
val password: String,
val server: String = "",
val port: Int = 5222,
val useTls: Boolean = true,
val resource: String = "AleJabber",
val isEnabled: Boolean = true,
val status: ConnectionStatus = ConnectionStatus.OFFLINE,
val statusMessage: String = "",
val avatarUrl: String? = null
)
enum class ConnectionStatus {
ONLINE, AWAY, DND, OFFLINE, CONNECTING, ERROR
}

Ver fichero

@@ -0,0 +1,23 @@
package com.manalejandro.alejabber.domain.model
data class Contact(
val id: Long = 0,
val accountId: Long,
val jid: String,
val nickname: String = "",
val groups: List<String> = emptyList(),
val presence: PresenceStatus = PresenceStatus.OFFLINE,
val statusMessage: String = "",
val avatarUrl: String? = null,
val isBlocked: Boolean = false,
val subscriptionState: SubscriptionState = SubscriptionState.NONE
)
enum class PresenceStatus {
ONLINE, AWAY, DND, XA, OFFLINE
}
enum class SubscriptionState {
NONE, PENDING_OUT, PENDING_IN, BOTH, REMOVE
}

Ver fichero

@@ -0,0 +1,41 @@
package com.manalejandro.alejabber.domain.model
data class Message(
val id: Long = 0,
val stanzaId: String = "",
val accountId: Long,
val conversationJid: String,
val fromJid: String,
val toJid: String,
val body: String = "",
val timestamp: Long = System.currentTimeMillis(),
val direction: MessageDirection,
val status: MessageStatus = MessageStatus.PENDING,
val encryptionType: EncryptionType = EncryptionType.NONE,
val mediaType: MediaType = MediaType.TEXT,
val mediaUrl: String? = null,
val mediaLocalPath: String? = null,
val mediaMimeType: String? = null,
val mediaSize: Long = 0,
val mediaName: String? = null,
val audioDurationMs: Long = 0,
val isRead: Boolean = false,
val isEdited: Boolean = false,
val isDeleted: Boolean = false,
val replyToId: Long? = null
)
enum class MessageDirection { INCOMING, OUTGOING }
enum class MessageStatus {
PENDING, SENT, DELIVERED, READ, FAILED
}
enum class EncryptionType {
NONE, OTR, OMEMO, OPENPGP
}
enum class MediaType {
TEXT, IMAGE, VIDEO, AUDIO, FILE, LINK
}

Ver fichero

@@ -0,0 +1,36 @@
package com.manalejandro.alejabber.domain.model
data class Room(
val id: Long = 0,
val accountId: Long,
val jid: String,
val nickname: String = "",
val name: String = "",
val description: String = "",
val topic: String = "",
val password: String = "",
val isJoined: Boolean = false,
val isFavorite: Boolean = false,
val participantCount: Int = 0,
val avatarUrl: String? = null,
val unreadCount: Int = 0,
val lastMessage: String = "",
val lastMessageTime: Long = 0
)
data class RoomParticipant(
val jid: String,
val nickname: String,
val role: ParticipantRole = ParticipantRole.PARTICIPANT,
val affiliation: ParticipantAffiliation = ParticipantAffiliation.NONE,
val presence: PresenceStatus = PresenceStatus.ONLINE
)
enum class ParticipantRole {
MODERATOR, PARTICIPANT, VISITOR, NONE
}
enum class ParticipantAffiliation {
OWNER, ADMIN, MEMBER, OUTCAST, NONE
}

Ver fichero

@@ -0,0 +1,129 @@
package com.manalejandro.alejabber.media
import android.content.Context
import android.media.MediaRecorder
import android.os.Build
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
enum class RecordingState { IDLE, RECORDING, STOPPED }
/**
* Wraps [MediaRecorder] to record audio from the microphone.
*
* Emits real-time elapsed duration via [durationMs] (updated every 100 ms while recording).
* Output format: MPEG-4 / AAC, 44 100 Hz, 128 kbps.
*/
@Singleton
class AudioRecorder @Inject constructor(
@param:ApplicationContext private val context: Context
) {
private var recorder: MediaRecorder? = null
private var outputFile: File? = null
private var startTime: Long = 0L
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var tickerJob: Job? = null
private val _state = MutableStateFlow(RecordingState.IDLE)
val state: StateFlow<RecordingState> = _state.asStateFlow()
private val _durationMs = MutableStateFlow(0L)
val durationMs: StateFlow<Long> = _durationMs.asStateFlow()
/** Starts recording. Returns true on success, false if an error occurs. */
fun startRecording(): Boolean {
return try {
val dir = File(context.cacheDir, "audio").also { it.mkdirs() }
outputFile = File(dir, "audio_${System.currentTimeMillis()}.m4a")
recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
@Suppress("DEPRECATION")
MediaRecorder()
}
recorder!!.apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setAudioSamplingRate(44100)
setAudioEncodingBitRate(128000)
setOutputFile(outputFile!!.absolutePath)
prepare()
start()
}
startTime = System.currentTimeMillis()
_durationMs.value = 0L
_state.value = RecordingState.RECORDING
// Tick every 100 ms so the UI shows a live counter
tickerJob = scope.launch {
while (true) {
delay(100)
_durationMs.value = System.currentTimeMillis() - startTime
}
}
true
} catch (e: Exception) {
cleanup()
false
}
}
/**
* Stops recording and returns the output [File].
* Returns null if recording was not active or an error occurs.
*/
fun stopRecording(): File? {
tickerJob?.cancel()
tickerJob = null
return try {
recorder?.apply {
stop()
release()
}
recorder = null
_durationMs.value = System.currentTimeMillis() - startTime
_state.value = RecordingState.STOPPED
outputFile
} catch (e: Exception) {
cleanup()
null
}
}
/** Cancels the current recording and discards the file. */
fun cancelRecording() {
tickerJob?.cancel()
tickerJob = null
cleanup()
outputFile?.delete()
outputFile = null
_state.value = RecordingState.IDLE
_durationMs.value = 0L
}
/** Resets state back to IDLE after the file has been consumed. */
fun reset() {
_state.value = RecordingState.IDLE
_durationMs.value = 0L
}
private fun cleanup() {
try { recorder?.release() } catch (_: Exception) {}
recorder = null
}
}

Ver fichero

@@ -0,0 +1,84 @@
package com.manalejandro.alejabber.media
import android.content.Context
import android.net.Uri
import android.webkit.MimeTypeMap
import com.manalejandro.alejabber.data.remote.XmppConnectionManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.jivesoftware.smackx.httpfileupload.HttpFileUploadManager
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class HttpUploadManager @Inject constructor(
@ApplicationContext private val context: Context,
private val xmppManager: XmppConnectionManager,
private val okHttpClient: OkHttpClient
) {
/**
* Uploads a file using XEP-0363 http_upload and returns the download URL or null on failure.
*/
suspend fun uploadFile(accountId: Long, uri: Uri): String? = withContext(Dispatchers.IO) {
try {
val connection = xmppManager.getConnection(accountId) ?: return@withContext null
val uploadManager = HttpFileUploadManager.getInstanceFor(connection)
val contentResolver = context.contentResolver
val mimeType = contentResolver.getType(uri) ?: "application/octet-stream"
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: "bin"
val fileName = "upload_${System.currentTimeMillis()}.$extension"
val bytes = contentResolver.openInputStream(uri)?.readBytes() ?: return@withContext null
// Write to temp file and upload
val tempFile = File(context.cacheDir, fileName).also { it.writeBytes(bytes) }
return@withContext uploadFileInternal(uploadManager, tempFile, mimeType, okHttpClient)
} catch (e: Exception) {
null
}
}
suspend fun uploadFile(accountId: Long, file: File, mimeType: String): String? =
withContext(Dispatchers.IO) {
try {
val connection = xmppManager.getConnection(accountId) ?: return@withContext null
val uploadManager = HttpFileUploadManager.getInstanceFor(connection)
return@withContext uploadFileInternal(uploadManager, file, mimeType, okHttpClient)
} catch (e: Exception) {
null
}
}
private fun uploadFileInternal(
uploadManager: HttpFileUploadManager,
file: File,
mimeType: String,
okClient: OkHttpClient
): String? {
return try {
// Request an upload slot (XEP-0363)
val slot = uploadManager.requestSlot(file.name, file.length(), mimeType)
val putUrl = slot.putUrl.toString()
val getUrl = slot.getUrl.toString()
// PUT the file bytes
val response = okClient.newCall(
Request.Builder()
.url(putUrl)
.put(file.readBytes().toRequestBody(mimeType.toMediaType()))
.build()
).execute()
if (response.isSuccessful || response.code == 201) getUrl else null
} catch (e: Exception) {
null
}
}
}

Ver fichero

@@ -0,0 +1,16 @@
package com.manalejandro.alejabber.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
val serviceIntent = Intent(context, XmppForegroundService::class.java)
ContextCompat.startForegroundService(context, serviceIntent)
}
}
}

Ver fichero

@@ -0,0 +1,126 @@
package com.manalejandro.alejabber.service
import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.manalejandro.alejabber.AleJabberApp
import com.manalejandro.alejabber.MainActivity
import com.manalejandro.alejabber.R
import com.manalejandro.alejabber.data.remote.XmppConnectionManager
import com.manalejandro.alejabber.data.repository.AccountRepository
import com.manalejandro.alejabber.data.repository.ContactRepository
import com.manalejandro.alejabber.data.repository.MessageRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class XmppForegroundService : Service() {
@Inject lateinit var xmppManager: XmppConnectionManager
@Inject lateinit var accountRepository: AccountRepository
@Inject lateinit var messageRepository: MessageRepository
@Inject lateinit var contactRepository: ContactRepository
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun onCreate() {
super.onCreate()
startForeground(AleJabberApp.NOTIFICATION_ID_SERVICE, buildForegroundNotification())
listenForIncomingMessages()
listenForPresenceUpdates()
connectAllAccounts()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
xmppManager.disconnectAll()
serviceScope.cancel()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun connectAllAccounts() {
serviceScope.launch {
val accounts = accountRepository.getAllAccounts().first()
accounts.filter { it.isEnabled }.forEach { account ->
accountRepository.connectAccount(account)
}
}
}
private fun listenForIncomingMessages() {
serviceScope.launch {
xmppManager.incomingMessages.collect { incoming ->
val id = messageRepository.saveIncomingMessage(
accountId = incoming.accountId,
from = incoming.from,
body = incoming.body
)
showMessageNotification(incoming.from, incoming.body)
}
}
}
private fun listenForPresenceUpdates() {
serviceScope.launch {
xmppManager.presenceUpdates.collect { update ->
// update.status is already a PresenceStatus — persist it to DB
// so the roster shows the correct state even after restarting the app.
contactRepository.updatePresence(
update.accountId, update.jid, update.status, update.statusMessage
)
}
}
}
private fun showMessageNotification(from: String, body: String) {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, AleJabberApp.CHANNEL_MESSAGES)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(from)
.setContentText(body)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
val nm = getSystemService(NotificationManager::class.java)
nm.notify(from.hashCode(), notification)
}
private fun buildForegroundNotification(): Notification {
val intent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, AleJabberApp.CHANNEL_SERVICE)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.notification_service_running))
.setContentIntent(pendingIntent)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
}

Ver fichero

@@ -0,0 +1,337 @@
package com.manalejandro.alejabber.ui.accounts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.manalejandro.alejabber.R
import com.manalejandro.alejabber.domain.model.Account
import com.manalejandro.alejabber.domain.model.ConnectionStatus
import com.manalejandro.alejabber.domain.model.PresenceStatus
import com.manalejandro.alejabber.ui.components.AvatarWithStatus
import com.manalejandro.alejabber.ui.theme.StatusAway
import com.manalejandro.alejabber.ui.theme.StatusDnd
import com.manalejandro.alejabber.ui.theme.StatusOffline
import com.manalejandro.alejabber.ui.theme.StatusOnline
/**
* Displays all configured XMPP accounts and lets the user:
* - Add a new account (FAB → [onAddAccount])
* - Connect / disconnect each account
* - Tap a connected account to browse its contacts ([onOpenContacts])
* - Edit or delete an account via the overflow menu
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountsScreen(
onAddAccount: () -> Unit,
onEditAccount: (Long) -> Unit,
onOpenContacts: (Long) -> Unit,
viewModel: AccountsViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var deleteTarget by remember { mutableStateOf<Account?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.accounts_title)) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
floatingActionButton = {
// Always visible FAB to add a new account
FloatingActionButton(
onClick = onAddAccount,
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.semantics {
contentDescription = "Add new account"
}
) {
Icon(Icons.Default.Add, contentDescription = null)
}
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
if (uiState.accounts.isEmpty()) {
// Empty-state prompt
Column(
modifier = Modifier
.align(Alignment.Center)
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
Icons.Default.AccountCircle,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)
)
Spacer(Modifier.height(20.dp))
Text(
text = stringResource(R.string.account_no_accounts),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(8.dp))
Text(
text = "Tap + to add your first XMPP account",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
Spacer(Modifier.height(24.dp))
Button(onClick = onAddAccount) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.add_account))
}
}
} else {
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
items(uiState.accounts, key = { it.id }) { account ->
AccountCard(
account = account,
onConnect = { viewModel.connectAccount(account) },
onDisconnect = { viewModel.disconnectAccount(account.id) },
onEdit = { onEditAccount(account.id) },
onDelete = { deleteTarget = account },
onOpen = { onOpenContacts(account.id) }
)
}
// Extra bottom padding so FAB doesn't overlap last item
item { Spacer(Modifier.height(88.dp)) }
}
}
// Loading overlay
AnimatedVisibility(
visible = uiState.isLoading,
enter = fadeIn(), exit = fadeOut(),
modifier = Modifier.align(Alignment.Center)
) {
CircularProgressIndicator()
}
}
}
// ── Delete confirmation ───────────────────────────────────────────────────
deleteTarget?.let { account ->
AlertDialog(
onDismissRequest = { deleteTarget = null },
title = { Text(stringResource(R.string.delete_account)) },
text = { Text(stringResource(R.string.account_delete_confirm, account.jid)) },
confirmButton = {
TextButton(onClick = {
viewModel.deleteAccount(account.id)
deleteTarget = null
}) {
Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { deleteTarget = null }) {
Text(stringResource(R.string.cancel))
}
}
)
}
}
/**
* A card representing one XMPP account.
*
* Tapping the card (when the account is connected) calls [onOpen].
* The connect/disconnect button controls the XMPP connection.
* The overflow menu exposes edit and delete actions.
*/
@Composable
fun AccountCard(
account: Account,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
onOpen: () -> Unit
) {
var menuExpanded by remember { mutableStateOf(false) }
val isOnline = account.status == ConnectionStatus.ONLINE ||
account.status == ConnectionStatus.AWAY ||
account.status == ConnectionStatus.DND
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = isOnline, onClick = onOpen),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = if (isOnline)
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f)
else
MaterialTheme.colorScheme.surfaceVariant
),
elevation = CardDefaults.cardElevation(defaultElevation = if (isOnline) 4.dp else 1.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 18.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Avatar with presence dot — uses AvatarWithStatus so it shows
// a real photo when the account has a vcard avatar URL
AvatarWithStatus(
name = account.jid,
avatarUrl = account.avatarUrl,
presence = account.status.toPresenceStatus(),
size = 48.dp,
contentDescription = "Avatar for ${account.jid}"
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = account.jid,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = account.status.toLabel(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (isOnline) {
Text(
text = "Tap to view contacts →",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
// Connect / Disconnect / Connecting
when (account.status) {
ConnectionStatus.OFFLINE, ConnectionStatus.ERROR -> {
IconButton(
onClick = onConnect,
modifier = Modifier.semantics { contentDescription = "Connect" }
) {
Icon(
Icons.Default.CloudSync,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
}
ConnectionStatus.CONNECTING -> {
CircularProgressIndicator(
modifier = Modifier
.size(24.dp)
.padding(4.dp),
strokeWidth = 2.dp
)
}
else -> {
IconButton(
onClick = onDisconnect,
modifier = Modifier.semantics { contentDescription = "Disconnect" }
) {
Icon(
Icons.Default.CloudOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// Overflow menu
Box {
IconButton(onClick = { menuExpanded = true }) {
Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.more_options))
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.edit_account)) },
leadingIcon = { Icon(Icons.Default.Edit, null) },
onClick = { menuExpanded = false; onEdit() }
)
DropdownMenuItem(
text = {
Text(
stringResource(R.string.delete_account),
color = MaterialTheme.colorScheme.error
)
},
leadingIcon = {
Icon(
Icons.Default.Delete, null,
tint = MaterialTheme.colorScheme.error
)
},
onClick = { menuExpanded = false; onDelete() }
)
}
}
}
}
}
/** Maps [ConnectionStatus] to its indicator colour. */
fun ConnectionStatus.toColor(): Color = when (this) {
ConnectionStatus.ONLINE -> StatusOnline
ConnectionStatus.AWAY -> StatusAway
ConnectionStatus.DND -> StatusDnd
ConnectionStatus.OFFLINE -> StatusOffline
ConnectionStatus.CONNECTING -> StatusAway
ConnectionStatus.ERROR -> Color(0xFFF44336)
}
/** Maps [ConnectionStatus] to the equivalent [PresenceStatus] for [AvatarWithStatus]. */
fun ConnectionStatus.toPresenceStatus(): PresenceStatus = when (this) {
ConnectionStatus.ONLINE -> PresenceStatus.ONLINE
ConnectionStatus.AWAY -> PresenceStatus.AWAY
ConnectionStatus.DND -> PresenceStatus.DND
ConnectionStatus.CONNECTING -> PresenceStatus.AWAY
ConnectionStatus.OFFLINE,
ConnectionStatus.ERROR -> PresenceStatus.OFFLINE
}
/** Human-readable label for a [ConnectionStatus]. */
fun ConnectionStatus.toLabel(): String = when (this) {
ConnectionStatus.ONLINE -> "Online"
ConnectionStatus.AWAY -> "Away"
ConnectionStatus.DND -> "Do Not Disturb"
ConnectionStatus.OFFLINE -> "Offline"
ConnectionStatus.CONNECTING -> "Connecting…"
ConnectionStatus.ERROR -> "Connection error tap to retry"
}

Ver fichero

@@ -0,0 +1,130 @@
package com.manalejandro.alejabber.ui.accounts
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.manalejandro.alejabber.data.repository.AccountRepository
import com.manalejandro.alejabber.domain.model.Account
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class AccountsUiState(
val accounts: List<Account> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
@HiltViewModel
class AccountsViewModel @Inject constructor(
private val accountRepository: AccountRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(AccountsUiState())
val uiState: StateFlow<AccountsUiState> = _uiState.asStateFlow()
init {
loadAccounts()
}
private fun loadAccounts() {
viewModelScope.launch {
accountRepository.getAllAccounts().collect { accounts ->
_uiState.update { it.copy(accounts = accounts, isLoading = false) }
}
}
}
fun connectAccount(account: Account) {
accountRepository.connectAccount(account)
}
fun disconnectAccount(accountId: Long) {
accountRepository.disconnectAccount(accountId)
}
fun deleteAccount(accountId: Long) {
viewModelScope.launch {
accountRepository.deleteAccount(accountId)
}
}
}
// ViewModel for Add/Edit Account
data class AddEditAccountUiState(
val jid: String = "",
val password: String = "",
val server: String = "",
val port: String = "5222",
val useTls: Boolean = true,
val resource: String = "AleJabber",
val isLoading: Boolean = false,
val isSaved: Boolean = false,
val error: String? = null
)
@HiltViewModel
class AddEditAccountViewModel @Inject constructor(
private val accountRepository: AccountRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(AddEditAccountUiState())
val uiState: StateFlow<AddEditAccountUiState> = _uiState.asStateFlow()
fun loadAccount(id: Long) {
viewModelScope.launch {
val account = accountRepository.getAccountById(id) ?: return@launch
_uiState.update {
it.copy(
jid = account.jid,
password = account.password,
server = account.server,
port = account.port.toString(),
useTls = account.useTls,
resource = account.resource
)
}
}
}
fun updateJid(v: String) = _uiState.update { it.copy(jid = v) }
fun updatePassword(v: String) = _uiState.update { it.copy(password = v) }
fun updateServer(v: String) = _uiState.update { it.copy(server = v) }
fun updatePort(v: String) = _uiState.update { it.copy(port = v) }
fun updateUseTls(v: Boolean) = _uiState.update { it.copy(useTls = v) }
fun updateResource(v: String) = _uiState.update { it.copy(resource = v) }
fun saveAccount(existingId: Long? = null) {
val state = _uiState.value
if (state.jid.isBlank() || state.password.isBlank()) {
_uiState.update { it.copy(error = "JID and password are required") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val account = Account(
id = existingId ?: 0,
jid = state.jid.trim(),
password = state.password,
server = state.server.trim(),
port = state.port.toIntOrNull() ?: 5222,
useTls = state.useTls,
resource = state.resource.ifBlank { "AleJabber" }
)
if (existingId != null) {
accountRepository.updateAccount(account)
} else {
accountRepository.addAccount(account)
}
_uiState.update { it.copy(isLoading = false, isSaved = true) }
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
}
}
}

Ver fichero

@@ -0,0 +1,199 @@
package com.manalejandro.alejabber.ui.accounts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.manalejandro.alejabber.R
/**
* Form screen to add a new XMPP account or edit an existing one.
*
* @param accountId Null when creating; the database id when editing.
* @param onNavigateBack Called when the user presses Back or after a successful save.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddEditAccountScreen(
accountId: Long?,
onNavigateBack: () -> Unit,
viewModel: AddEditAccountViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showPassword by remember { mutableStateOf(false) }
LaunchedEffect(accountId) { accountId?.let { viewModel.loadAccount(it) } }
LaunchedEffect(uiState.isSaved) { if (uiState.isSaved) onNavigateBack() }
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
if (accountId == null) stringResource(R.string.add_account)
else stringResource(R.string.edit_account)
)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// JID (user@domain)
OutlinedTextField(
value = uiState.jid,
onValueChange = viewModel::updateJid,
label = { Text(stringResource(R.string.account_username)) },
placeholder = { Text(stringResource(R.string.account_jid_hint)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth(),
isError = uiState.jid.isNotBlank() && !uiState.jid.contains("@"),
supportingText = {
if (uiState.jid.isNotBlank() && !uiState.jid.contains("@"))
Text("Must be in user@domain format")
}
)
// Password
OutlinedTextField(
value = uiState.password,
onValueChange = viewModel::updatePassword,
label = { Text(stringResource(R.string.account_password)) },
singleLine = true,
visualTransformation = if (showPassword) VisualTransformation.None
else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
if (showPassword) Icons.Default.VisibilityOff
else Icons.Default.Visibility,
contentDescription = "Toggle password visibility"
)
}
},
modifier = Modifier.fillMaxWidth()
)
// Server override (optional)
OutlinedTextField(
value = uiState.server,
onValueChange = viewModel::updateServer,
label = { Text(stringResource(R.string.account_server) + " (optional)") },
placeholder = { Text("xmpp.example.com") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
supportingText = { Text("Leave blank to use DNS SRV lookup") }
)
// Port
OutlinedTextField(
value = uiState.port,
onValueChange = viewModel::updatePort,
label = { Text(stringResource(R.string.account_port)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
// Resource name
OutlinedTextField(
value = uiState.resource,
onValueChange = viewModel::updateResource,
label = { Text(stringResource(R.string.account_resource)) },
placeholder = { Text(stringResource(R.string.account_resource_hint)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// TLS toggle
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
stringResource(R.string.account_use_tls),
style = MaterialTheme.typography.bodyLarge
)
Text(
"Require an encrypted TLS connection",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = uiState.useTls,
onCheckedChange = viewModel::updateUseTls
)
}
}
// Error banner
uiState.error?.let { error ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier.fillMaxWidth()
) {
Text(
error,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(12.dp)
)
}
}
Spacer(Modifier.height(8.dp))
// Save button
Button(
onClick = { viewModel.saveAccount(accountId) },
enabled = !uiState.isLoading && uiState.jid.contains("@") && uiState.password.isNotBlank(),
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Text(stringResource(R.string.save), style = MaterialTheme.typography.titleMedium)
}
}
}
}
}

Ver fichero

@@ -0,0 +1,945 @@
package com.manalejandro.alejabber.ui.chat
import android.Manifest
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.manalejandro.alejabber.R
import com.manalejandro.alejabber.domain.model.*
import com.manalejandro.alejabber.media.RecordingState
import com.manalejandro.alejabber.ui.components.AvatarWithStatus
import com.manalejandro.alejabber.ui.components.EncryptionBadge
import com.manalejandro.alejabber.ui.theme.*
import java.text.SimpleDateFormat
import java.util.*
import java.util.regex.Pattern
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
@Composable
fun ChatScreen(
accountId: Long,
conversationJid: String,
isRoom: Boolean = false,
onNavigateBack: () -> Unit,
viewModel: ChatViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO)
val clipboardManager = LocalClipboardManager.current
// Message selected via long-press → shows the action bottom sheet
var selectedMessage by remember { mutableStateOf<Message?>(null) }
// Confirm-delete dialog
var messageToDelete by remember { mutableStateOf<Message?>(null) }
// File picker
val filePicker = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri -> uri?.let { viewModel.sendFile(it) } }
LaunchedEffect(accountId, conversationJid) {
viewModel.init(accountId, conversationJid)
}
// Scroll to bottom on new message
LaunchedEffect(uiState.messages.size) {
if (uiState.messages.isNotEmpty()) {
listState.animateScrollToItem(uiState.messages.size - 1)
}
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
}
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
AvatarWithStatus(
name = uiState.contactName,
avatarUrl = null,
presence = uiState.contactPresence,
size = 36.dp
)
Spacer(Modifier.width(10.dp))
Column {
Text(
uiState.contactName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
AnimatedVisibility(visible = uiState.isTyping) {
Text(
stringResource(R.string.chat_typing, uiState.contactName),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
},
actions = {
// Encryption badge — shown as a pill (lock icon + label).
// Must NOT be inside an IconButton because IconButton clips
// its content to 48×48 dp, hiding the text label.
EncryptionBadge(
encryptionType = uiState.encryptionType,
modifier = Modifier
.clickable { viewModel.toggleEncryptionPicker() }
.padding(horizontal = 12.dp, vertical = 8.dp)
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
// No bottomBar — input is placed inside the content column so imePadding works
) { padding ->
// imePadding() here at the column level makes the whole content
// (messages + input bar) shift up when the soft keyboard appears.
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.imePadding()
) {
// ── Message list (takes all remaining space) ─────────────────
Box(modifier = Modifier.weight(1f)) {
if (uiState.messages.isEmpty()) {
Text(
text = stringResource(R.string.chat_empty),
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth()
.padding(horizontal = 32.dp),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
LazyColumn(
state = listState,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxSize()
) {
val grouped = uiState.messages.groupBy { msg ->
SimpleDateFormat("MMM d, yyyy", Locale.getDefault())
.format(Date(msg.timestamp))
}
grouped.forEach { (date, msgs) ->
item { DateDivider(date) }
items(msgs, key = { it.id }) { message ->
MessageBubble(
message = message,
onLongPress = { selectedMessage = message }
)
}
}
item { Spacer(Modifier.height(8.dp)) }
}
}
// Upload progress banner
if (uiState.isUploading) {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.TopCenter)
)
}
}
// ── Input bar (always at bottom, above keyboard) ─────────────
ChatInput(
text = uiState.inputText,
onTextChange = viewModel::onInputChange,
onSend = viewModel::sendTextMessage,
onAttach = { filePicker.launch("*/*") },
onStartRecording = {
if (micPermission.status.isGranted) viewModel.startRecording()
else micPermission.launchPermissionRequest()
},
onStopRecording = viewModel::stopAndSendRecording,
onCancelRecording = viewModel::cancelRecording,
recordingState = uiState.recordingState,
recordingDuration = uiState.recordingDurationMs,
isUploading = uiState.isUploading
)
}
}
// ── Message action bottom sheet ───────────────────────────────────────
selectedMessage?.let { msg ->
MessageActionsSheet(
message = msg,
clipboardManager = clipboardManager,
onDelete = {
selectedMessage = null
messageToDelete = msg
},
onDismiss = { selectedMessage = null }
)
}
// ── Confirm delete dialog ─────────────────────────────────────────────
messageToDelete?.let { msg ->
AlertDialog(
onDismissRequest = { messageToDelete = null },
icon = { Icon(Icons.Default.DeleteForever, null, tint = MaterialTheme.colorScheme.error) },
title = { Text("Delete message?") },
text = {
Text(
"This will remove the message from this device only. " +
"The recipient may still have a copy.",
style = MaterialTheme.typography.bodyMedium
)
},
confirmButton = {
TextButton(onClick = {
viewModel.deleteMessage(msg.id)
messageToDelete = null
}) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { messageToDelete = null }) { Text("Cancel") }
}
)
}
// ── Encryption picker ─────────────────────────────────────────────────
if (uiState.showEncryptionPicker) {
EncryptionPickerDialog(
current = uiState.encryptionType,
onSelect = viewModel::setEncryption,
onDismiss = viewModel::toggleEncryptionPicker
)
}
// ── Error snackbar ────────────────────────────────────────────────────
uiState.error?.let { LaunchedEffect(it) { viewModel.clearError() } }
}
// ── URL regex ─────────────────────────────────────────────────────────────
private val URL_PATTERN: Pattern = Pattern.compile(
"(https?://|www\\.)[\\w\\-]+(\\.[\\w\\-]+)+([\\w.,@?^=%&:/~+#\\-_]*[\\w@?^=%&/~+#\\-_])?"
)
/** Converts a plain string into an [AnnotatedString] with clickable URL spans. */
fun buildMessageText(text: String, linkColor: Color): AnnotatedString = buildAnnotatedString {
val matcher = URL_PATTERN.matcher(text)
var last = 0
while (matcher.find()) {
// Append plain text before the URL
append(text.substring(last, matcher.start()))
val url = matcher.group()
val fullUrl = if (url.startsWith("http")) url else "https://$url"
// Append the URL with a distinct style and a string annotation
pushStringAnnotation(tag = "URL", annotation = fullUrl)
withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)) {
append(url)
}
pop()
last = matcher.end()
}
// Append remaining plain text
append(text.substring(last))
}
@Composable
fun MessageBubble(message: Message, onLongPress: () -> Unit) {
val isOutgoing = message.direction == MessageDirection.OUTGOING
val darkTheme = isSystemInDarkTheme()
val bubbleColor = when {
isOutgoing && darkTheme -> BubbleSentDark
isOutgoing -> BubbleSent
darkTheme -> BubbleReceivedDark
else -> BubbleReceived
}
val textColor = if (isOutgoing) Color.White else MaterialTheme.colorScheme.onSurface
// URL links are always white on sent bubbles, primary on received
val linkColor = if (isOutgoing) Color.White else MaterialTheme.colorScheme.primary
val uriHandler = LocalUriHandler.current
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
start = if (isOutgoing) 48.dp else 0.dp,
end = if (isOutgoing) 0.dp else 48.dp
),
horizontalAlignment = if (isOutgoing) Alignment.End else Alignment.Start
) {
Box(
modifier = Modifier
.clip(
RoundedCornerShape(
topStart = 18.dp, topEnd = 18.dp,
bottomStart = if (isOutgoing) 18.dp else 4.dp,
bottomEnd = if (isOutgoing) 4.dp else 18.dp
)
)
.background(bubbleColor)
.pointerInput(Unit) {
detectTapGestures(onLongPress = { onLongPress() })
}
.padding(horizontal = 14.dp, vertical = 8.dp)
) {
when (message.mediaType) {
MediaType.TEXT, MediaType.LINK, null -> {
// Build annotated text with clickable URLs
val annotated = remember(message.body) {
buildMessageText(message.body, linkColor)
}
val hasLinks = annotated.getStringAnnotations("URL", 0, annotated.length).isNotEmpty()
if (hasLinks) {
// ClickableText for messages that contain URLs
androidx.compose.foundation.text.ClickableText(
text = annotated,
style = MaterialTheme.typography.bodyMedium.copy(color = textColor),
onClick = { offset ->
annotated.getStringAnnotations("URL", offset, offset)
.firstOrNull()?.let { uriHandler.openUri(it.item) }
}
)
} else {
Text(
text = message.body,
color = textColor,
style = MaterialTheme.typography.bodyMedium
)
}
}
MediaType.IMAGE -> {
AsyncImage(
model = message.mediaUrl ?: message.body,
contentDescription = stringResource(R.string.chat_media_image),
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 200.dp)
.clip(RoundedCornerShape(12.dp))
.clickable {
val url = message.mediaUrl ?: message.body
if (url.startsWith("http")) uriHandler.openUri(url)
}
)
}
MediaType.AUDIO -> {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Headset, null, tint = textColor)
Spacer(Modifier.width(8.dp))
Text(
stringResource(R.string.chat_media_audio),
color = textColor, style = MaterialTheme.typography.bodySmall
)
if (message.audioDurationMs > 0) {
Spacer(Modifier.width(4.dp))
Text(
formatDuration(message.audioDurationMs),
color = textColor.copy(alpha = 0.7f),
style = MaterialTheme.typography.labelSmall
)
}
}
}
MediaType.FILE -> {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable {
val url = message.mediaUrl ?: message.body
if (url.startsWith("http")) uriHandler.openUri(url)
}
) {
Icon(Icons.Default.Attachment, null, tint = textColor)
Spacer(Modifier.width(8.dp))
Text(
message.mediaName ?: stringResource(R.string.chat_media_file),
color = textColor, style = MaterialTheme.typography.bodySmall
)
}
}
else -> Text(message.body, color = textColor)
}
}
// Timestamp + delivery status
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
) {
if (message.encryptionType != EncryptionType.NONE) {
Icon(
Icons.Default.Lock, contentDescription = null,
tint = message.encryptionType.toColor(),
modifier = Modifier.size(10.dp)
)
Spacer(Modifier.width(4.dp))
}
Text(
text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(message.timestamp)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp
)
if (isOutgoing) {
Spacer(Modifier.width(4.dp))
StatusIcon(message.status)
}
}
}
}
// ── Message context action sheet ──────────────────────────────────────────
/**
* Bottom sheet shown on long-press of a message.
* Provides: Copy text · Open URL (if present) · Delete (with confirmation)
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MessageActionsSheet(
message: Message,
clipboardManager: ClipboardManager,
onDelete: () -> Unit,
onDismiss: () -> Unit
) {
val uriHandler = LocalUriHandler.current
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val hasUrl = URL_PATTERN.matcher(message.body).find()
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(modifier = Modifier.padding(bottom = 24.dp)) {
// Header preview
Text(
text = message.body.take(120) + if (message.body.length > 120) "" else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
)
HorizontalDivider()
// ── Copy ──────────────────────────────────────────────────────
ListItem(
headlineContent = { Text("Copy text") },
leadingContent = { Icon(Icons.Default.ContentCopy, null) },
modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(message.body))
onDismiss()
}
)
// ── Open URL ─────────────────────────────────────────────────
if (hasUrl) {
val matcher = URL_PATTERN.matcher(message.body)
if (matcher.find()) {
val url = matcher.group()
val fullUrl = if (url.startsWith("http")) url else "https://$url"
ListItem(
headlineContent = { Text("Open link") },
supportingContent = {
Text(
fullUrl,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 1
)
},
leadingContent = { Icon(Icons.Default.OpenInBrowser, null) },
modifier = Modifier.clickable {
uriHandler.openUri(fullUrl)
onDismiss()
}
)
// Copy link separately
ListItem(
headlineContent = { Text("Copy link") },
leadingContent = { Icon(Icons.Default.Link, null) },
modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(fullUrl))
onDismiss()
}
)
}
}
HorizontalDivider()
// ── Delete ────────────────────────────────────────────────────
ListItem(
headlineContent = {
Text("Delete message", color = MaterialTheme.colorScheme.error)
},
leadingContent = {
Icon(
Icons.Default.DeleteForever, null,
tint = MaterialTheme.colorScheme.error
)
},
modifier = Modifier.clickable { onDelete() }
)
Spacer(Modifier.height(8.dp))
}
}
}
@Composable
fun StatusIcon(status: MessageStatus) {
val (icon, tint, cd) = when (status) {
MessageStatus.PENDING -> Triple(Icons.Default.AccessTime, MaterialTheme.colorScheme.outline, stringResource(R.string.chat_message_sending))
MessageStatus.SENT -> Triple(Icons.Default.Check, MaterialTheme.colorScheme.outline, "Sent")
MessageStatus.DELIVERED -> Triple(Icons.Default.CheckCircleOutline, MaterialTheme.colorScheme.outline, stringResource(R.string.chat_message_delivered))
MessageStatus.READ -> Triple(Icons.Default.CheckCircle, MaterialTheme.colorScheme.primary, stringResource(R.string.chat_message_read))
MessageStatus.FAILED -> Triple(Icons.Default.ErrorOutline, MaterialTheme.colorScheme.error, stringResource(R.string.chat_message_failed))
}
Icon(icon, contentDescription = cd, tint = tint, modifier = Modifier.size(14.dp).semantics { contentDescription = cd })
}
@Composable
fun DateDivider(date: String) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
) {
HorizontalDivider(modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.outlineVariant)
Text(
text = date,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp)
)
HorizontalDivider(modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.outlineVariant)
}
}
// ── Full emoji catalog organized by category ──────────────────────────────
private data class EmojiCategory(val label: String, val icon: String, val emojis: List<String>)
private val EMOJI_CATEGORIES = listOf(
EmojiCategory("Recent", "🕐", listOf(
"😀","😂","🥰","😍","👍","❤️","🎉","🔥","😊","🤔","😅","🙏","💯","","🚀"
)),
EmojiCategory("Faces", "😀", listOf(
"😀","😁","😂","🤣","😃","😄","😅","😆","😉","😊","😋","😎","😍","🥰","😘",
"🥲","😗","😙","😚","🙂","🤗","🤩","🤔","🫡","🤨","😐","😑","😶","🫥","😏",
"😒","🙄","😬","🤥","😌","😔","😪","🤤","😴","😷","🤒","🤕","🤢","🤮","🤧",
"🥵","🥶","🥴","😵","🤯","🤠","🥸","🥳","😎","🤓","🧐","😟","😕","🫤","😣",
"😖","😫","😩","🥺","😢","😭","😤","😠","😡","🤬","😈","👿","💀","☠️","💩",
"🤡","👹","👺","👻","👽","👾","🤖","😺","😸","😹","😻","😼","😽","🙀","😿","😾"
)),
EmojiCategory("Gestures", "👋", listOf(
"👋","🤚","🖐","","🖖","🫱","🫲","🫳","🫴","👌","🤌","🤏","✌️","🤞","🫰",
"🤟","🤘","🤙","👈","👉","👆","🖕","👇","☝️","🫵","👍","👎","","👊","🤛",
"🤜","👏","🙌","🫶","👐","🤲","🤝","🙏","✍️","💅","🤳","💪","🦾","🦿","🦵",
"🦶","👂","🦻","👃","🫀","🫁","🧠","🦷","🦴","👀","👁","👅","👄","🫦","💋"
)),
EmojiCategory("People", "👩", listOf(
"👶","🧒","👦","👧","🧑","👱","👨","🧔","👩","🧓","👴","👵","🙍","🙎","🙅",
"🙆","💁","🙋","🧏","🙇","🤦","🤷","💆","💇","🚶","🧍","🧎","🏃","💃","🕺",
"🧖","🧗","🤸","","🤺","🏇","🏊","🤽","🚣","🧘","🛀","🛌","👫","👬","👭",
"💑","👨‍👩‍👦","👨‍👩‍👧","👨‍👩‍👧‍👦","👨‍👩‍👦‍👦","👨‍👩‍👧‍👧","🪢","👣"
)),
EmojiCategory("Animals", "🐶", listOf(
"🐶","🐱","🐭","🐹","🐰","🦊","🐻","🐼","🐨","🐯","🦁","🐮","🐷","🐸","🐵",
"🙈","🙉","🙊","🐔","🐧","🐦","🐤","🦆","🦅","🦉","🦇","🐺","🐗","🐴","🦄",
"🐝","🪱","🐛","🦋","🐌","🐞","🐜","🪲","🦟","🦗","🕷","🦂","🐢","🐍","🦎",
"🦖","🦕","🐙","🦑","🦐","🦞","🦀","🐡","🐠","🐟","🐬","🐳","🐋","🦈","🐊",
"🐅","🐆","🦓","🦍","🦧","🦣","🐘","🦛","🦏","🐪","🐫","🦒","🦘","🦬","🐃",
"🐂","🐄","🐎","🐖","🐏","🐑","🦙","🐐","🦌","🐕","🐩","🦮","🐈","🐓","🦃",
"🦤","🦚","🦜","🦢","🦩","🕊","🐇","🦝","🦨","🦡","🦫","🦦","🦥","🐁","🐀","🦔"
)),
EmojiCategory("Food", "🍕", listOf(
"🍏","🍎","🍐","🍊","🍋","🍌","🍉","🍇","🍓","🫐","🍈","🍑","🥭","🍍","🥥",
"🥝","🍅","🍆","🥑","🥦","🥬","🥒","🌶","🫑","🧄","🧅","🥔","🍠","🫘","🥐",
"🥖","🍞","🥨","🧀","🥚","🍳","🧈","🥞","🧇","🥓","🥩","🍗","🍖","🌭","🍔",
"🍟","🍕","🫓","🥪","🥙","🧆","🌮","🌯","🫔","🥗","🥘","🫕","🥫","🍝","🍜",
"🍲","🍛","🍣","🍱","🥟","🦪","🍤","🍙","🍚","🍘","🍥","🥮","🍢","🧁","🍰",
"🎂","🍮","🍭","🍬","🍫","🍿","🍩","🍪","🌰","🥜","🍯","🧃","🥤","🧋","",
"🍵","🫖","🍺","🍻","🥂","🍷","🫗","🥃","🍸","🍹","🧉","🍾","🧊","🥄","🍴"
)),
EmojiCategory("Nature", "🌿", listOf(
"🌸","🌺","🌻","🌹","🌷","🌼","💐","🍄","🌾","🍀","🌿","☘️","🍃","🍂","🍁",
"🌵","🌴","🌳","🌲","🎋","🎍","","🌊","🌬","🌀","🌈","","🔥","💧","🌍",
"🌎","🌏","🌑","🌒","🌓","🌔","🌕","🌖","🌗","🌘","🌙","🌚","🌛","🌜","🌝",
"","🌟","💫","","☀️","🌤","","🌥","🌦","🌧","","🌩","🌨","❄️","🌫"
)),
EmojiCategory("Travel", "✈️", listOf(
"🚗","🚕","🚙","🚌","🚎","🏎","🚓","🚑","🚒","🚐","🛻","🚚","🚛","🚜","🏍",
"🛵","🚲","🛴","🛺","🚁","🛸","✈️","🛩","🚀","🛶","","🚤","🛥","🛳","",
"🚂","🚆","🚇","🚈","🚉","🚊","🚞","🚋","🚌","🚍","🚎","🚐","🚑","🚒","🚓",
"🗺","🧭","🏔","","🌋","🏕","🏖","🏜","🏝","🏞","🏟","🏛","🏗","🏘","🏚",
"🏠","🏡","🏢","🏣","🏤","🏥","🏦","🏨","🏩","🏪","🏫","🏬","🏭","🏯","🏰"
)),
EmojiCategory("Objects", "💡", listOf(
"","📱","💻","⌨️","🖥","🖨","🖱","🖲","🕹","💾","💿","📀","📷","📸","📹",
"🎥","📽","🎞","📞","☎️","📟","📠","📺","📻","🧭","","","","🕰","",
"📡","🔋","🔌","💡","🔦","🕯","💰","💴","💵","💶","💷","💸","💳","🪙","💹",
"📧","📨","📩","📪","📫","📬","📭","📮","🗳","✏️","✒️","🖊","🖋","📝","📁",
"📂","🗂","📅","📆","🗒","🗓","📇","📈","📉","📊","📋","📌","📍","📎","🖇",
"📏","📐","✂️","🗃","🗄","🗑","🔒","🔓","🔏","🔐","🔑","🗝","🔨","🪓","",
"🔧","🪛","🔩","🪤","🧲","🪜","🧰","🪝","🧲","💊","💉","🩹","🩺","🔭","🔬"
)),
EmojiCategory("Symbols", "❤️", listOf(
"❤️","🧡","💛","💚","💙","💜","🖤","🤍","🤎","💔","❤️‍🔥","❤️‍🩹","💕","💞",
"💓","💗","💖","💘","💝","💟","☮️","✝️","☯️","🕉","✡️","🔯","🕎","☦️","🛐",
"","","","","","","","","","","","","","🆔","⚜️",
"🔀","🔁","🔂","▶️","","","","","","","🔼","🔽","","","",
"🎦","🔅","🔆","📶","📳","📴","📵","📱","📲","☎️","📞","📟","📠","🔋","🔌",
"","","🔴","🟠","🟡","🟢","🔵","🟣","","","🟤","🔺","🔻","🔷","🔶",
"🔹","🔸","🔲","🔳","▪️","▫️","","","◼️","◻️","🟥","🟧","🟨","🟩","🟦",
"💯","🔞","🔅","🆗","🆙","🆒","🆕","🆓","🔟","🆖","🅰️","🅱️","🆎","🆑","🅾️","🆘"
)),
EmojiCategory("Flags", "🏳️", listOf(
"🏳️","🏴","🚩","🏁","🏳️‍🌈","🏳️‍⚧️","🏴‍☠️",
"🇺🇸","🇬🇧","🇪🇸","🇫🇷","🇩🇪","🇮🇹","🇯🇵","🇨🇳","🇷🇺","🇧🇷",
"🇦🇷","🇦🇺","🇨🇦","🇲🇽","🇰🇷","🇮🇳","🇿🇦","🇳🇬","🇪🇬","🇸🇦",
"🇹🇷","🇮🇩","🇵🇰","🇧🇩","🇵🇭","🇵🇱","🇳🇱","🇧🇪","🇸🇪","🇨🇭"
)),
)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun EmojiPicker(onEmojiClick: (String) -> Unit) {
var selectedTab by remember { mutableIntStateOf(0) }
val category = EMOJI_CATEGORIES[selectedTab]
Column {
// ── Category tab row ──────────────────────────────────────────────
ScrollableTabRow(
selectedTabIndex = selectedTab,
edgePadding = 0.dp,
divider = {}
) {
EMOJI_CATEGORIES.forEachIndexed { index, cat ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
modifier = Modifier.height(44.dp)
) {
Text(
text = cat.icon,
fontSize = 20.sp
)
}
}
}
HorizontalDivider()
// ── Emoji grid for the selected category ──────────────────────────
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 44.dp),
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentPadding = PaddingValues(4.dp)
) {
items(category.emojis) { emoji ->
Text(
text = emoji,
fontSize = 26.sp,
modifier = Modifier
.clickable { onEmojiClick(emoji) }
.padding(6.dp)
)
}
}
}
}
@Composable
fun ChatInput(
text: String,
onTextChange: (String) -> Unit,
onSend: () -> Unit,
onAttach: () -> Unit,
onStartRecording: () -> Unit,
onStopRecording: () -> Unit,
onCancelRecording: () -> Unit,
recordingState: RecordingState,
recordingDuration: Long,
isUploading: Boolean
) {
var showEmojiPicker by remember { mutableStateOf(false) }
Column {
// ── Emoji panel ───────────────────────────────────────────────────
AnimatedVisibility(
visible = showEmojiPicker && recordingState != RecordingState.RECORDING,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
Surface(tonalElevation = 6.dp) {
EmojiPicker(onEmojiClick = { onTextChange(text + it) })
}
}
// ── Input bar ─────────────────────────────────────────────────────
Surface(tonalElevation = 3.dp, shadowElevation = 4.dp) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.Bottom
) {
when (recordingState) {
RecordingState.RECORDING -> {
// ── Recording UI ──────────────────────────────────
Row(
modifier = Modifier
.weight(1f)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Pulsing dot
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val alpha by infiniteTransition.animateFloat(
initialValue = 0.3f, targetValue = 1f,
animationSpec = infiniteRepeatable(tween(600), RepeatMode.Reverse),
label = "pulse_alpha"
)
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.error.copy(alpha = alpha))
)
Spacer(Modifier.width(8.dp))
Text(
formatDuration(recordingDuration),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
Spacer(Modifier.weight(1f))
Text(
stringResource(R.string.chat_record_audio),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Cancel
IconButton(onClick = onCancelRecording) {
Icon(
Icons.Default.Close,
stringResource(R.string.chat_cancel_audio),
tint = MaterialTheme.colorScheme.error
)
}
// Send recording
IconButton(onClick = onStopRecording) {
Icon(
Icons.AutoMirrored.Filled.Send,
stringResource(R.string.chat_send_audio),
tint = MaterialTheme.colorScheme.primary
)
}
}
else -> {
// ── Normal input row ──────────────────────────────
// Attach file
IconButton(
onClick = onAttach,
modifier = Modifier.semantics { contentDescription = "Attach file" }
) {
Icon(
Icons.Default.Attachment,
stringResource(R.string.chat_attach),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Emoji toggle
IconButton(
onClick = { showEmojiPicker = !showEmojiPicker },
modifier = Modifier.semantics { contentDescription = "Emoji picker" }
) {
Icon(
if (showEmojiPicker) Icons.Default.KeyboardAlt
else Icons.Default.EmojiEmotions,
contentDescription = null,
tint = if (showEmojiPicker)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Text field
OutlinedTextField(
value = text,
onValueChange = onTextChange,
placeholder = { Text(stringResource(R.string.chat_hint)) },
shape = RoundedCornerShape(24.dp),
modifier = Modifier.weight(1f),
maxLines = 5
)
// Send OR record
if (text.isNotBlank()) {
IconButton(
onClick = { onSend(); showEmojiPicker = false },
modifier = Modifier
.padding(start = 4.dp)
.semantics { contentDescription = "Send message" }
) {
Icon(
Icons.AutoMirrored.Filled.Send,
stringResource(R.string.chat_send),
tint = MaterialTheme.colorScheme.primary
)
}
} else {
IconButton(
onClick = onStartRecording,
modifier = Modifier
.padding(start = 4.dp)
.semantics { contentDescription = "Record audio message" }
) {
Icon(
Icons.Default.KeyboardVoice,
stringResource(R.string.chat_record_audio),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
}
}
@Composable
fun EncryptionPickerDialog(
current: EncryptionType,
onSelect: (EncryptionType) -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.chat_encryption_select)) },
text = {
Column {
EncryptionType.entries.forEach { type ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.pointerInput(type) {
detectTapGestures { onSelect(type) }
}
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = current == type, onClick = { onSelect(type) })
Spacer(Modifier.width(12.dp))
Column {
Text(type.toDisplayName(), fontWeight = FontWeight.Medium)
Text(type.toDescription(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.close)) }
}
)
}
fun EncryptionType.toColor(): Color = when (this) {
EncryptionType.OTR -> EncryptionOtr
EncryptionType.OMEMO -> EncryptionOmemo
EncryptionType.OPENPGP -> EncryptionPgp
EncryptionType.NONE -> EncryptionNone
}
fun EncryptionType.toDisplayName(): String = when (this) {
EncryptionType.NONE -> "None (Plain text)"
EncryptionType.OTR -> "OTR"
EncryptionType.OMEMO -> "OMEMO"
EncryptionType.OPENPGP -> "OpenPGP"
}
fun EncryptionType.toDescription(): String = when (this) {
EncryptionType.NONE -> "Messages are sent unencrypted"
EncryptionType.OTR -> "Off-the-Record: perfect forward secrecy"
EncryptionType.OMEMO -> "Multi-device end-to-end encryption (recommended)"
EncryptionType.OPENPGP -> "OpenPGP asymmetric encryption"
}
fun formatDuration(ms: Long): String {
val totalSec = ms / 1000
val min = totalSec / 60
val sec = totalSec % 60
return "%d:%02d".format(min, sec)
}

Ver fichero

@@ -0,0 +1,165 @@
package com.manalejandro.alejabber.ui.chat
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.manalejandro.alejabber.data.repository.ContactRepository
import com.manalejandro.alejabber.data.repository.MessageRepository
import com.manalejandro.alejabber.domain.model.*
import com.manalejandro.alejabber.media.AudioRecorder
import com.manalejandro.alejabber.media.HttpUploadManager
import com.manalejandro.alejabber.media.RecordingState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
data class ChatUiState(
val messages: List<Message> = emptyList(),
val contactName: String = "",
val contactPresence: PresenceStatus = PresenceStatus.OFFLINE,
val inputText: String = "",
val encryptionType: EncryptionType = EncryptionType.NONE,
val isTyping: Boolean = false,
val isUploading: Boolean = false,
val recordingState: RecordingState = RecordingState.IDLE,
val recordingDurationMs: Long = 0,
val showEncryptionPicker: Boolean = false,
val error: String? = null
)
@HiltViewModel
class ChatViewModel @Inject constructor(
private val messageRepository: MessageRepository,
private val contactRepository: ContactRepository,
private val httpUploadManager: HttpUploadManager,
private val audioRecorder: AudioRecorder
) : ViewModel() {
private val _uiState = MutableStateFlow(ChatUiState())
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
private var currentAccountId: Long = 0
private var currentJid: String = ""
fun init(accountId: Long, jid: String) {
currentAccountId = accountId
currentJid = jid
viewModelScope.launch {
// Load messages
messageRepository.getMessages(accountId, jid).collect { messages ->
_uiState.update { it.copy(messages = messages) }
}
}
viewModelScope.launch {
// Load contact info
contactRepository.getContacts(accountId)
.take(1)
.collect { contacts ->
val contact = contacts.find { it.jid == jid }
_uiState.update {
it.copy(
contactName = contact?.nickname?.ifBlank { jid } ?: jid,
contactPresence = contact?.presence ?: PresenceStatus.OFFLINE
)
}
}
}
viewModelScope.launch {
// Mark as read
messageRepository.markAllAsRead(accountId, jid)
}
// Observe recording state
viewModelScope.launch {
audioRecorder.state.collect { state ->
_uiState.update { it.copy(recordingState = state) }
}
}
viewModelScope.launch {
audioRecorder.durationMs.collect { ms ->
_uiState.update { it.copy(recordingDurationMs = ms) }
}
}
}
fun onInputChange(text: String) = _uiState.update { it.copy(inputText = text) }
fun sendTextMessage() {
val text = _uiState.value.inputText.trim()
if (text.isBlank()) return
_uiState.update { it.copy(inputText = "") }
viewModelScope.launch {
messageRepository.sendMessage(
accountId = currentAccountId,
toJid = currentJid,
body = text,
encryptionType = _uiState.value.encryptionType
)
}
}
fun sendFile(uri: Uri) {
viewModelScope.launch {
_uiState.update { it.copy(isUploading = true) }
try {
val url = httpUploadManager.uploadFile(currentAccountId, uri)
if (url != null) {
messageRepository.sendMessage(
accountId = currentAccountId,
toJid = currentJid,
body = url,
encryptionType = _uiState.value.encryptionType
)
}
} catch (e: Exception) {
_uiState.update { it.copy(error = "Upload failed: ${e.message}") }
} finally {
_uiState.update { it.copy(isUploading = false) }
}
}
}
fun startRecording(): Boolean = audioRecorder.startRecording()
fun stopAndSendRecording() {
viewModelScope.launch {
val file = audioRecorder.stopRecording() ?: return@launch
_uiState.update { it.copy(isUploading = true) }
try {
val url = httpUploadManager.uploadFile(currentAccountId, file, "audio/mp4")
if (url != null) {
messageRepository.sendMessage(
accountId = currentAccountId,
toJid = currentJid,
body = url,
encryptionType = _uiState.value.encryptionType
)
}
audioRecorder.reset()
} catch (e: Exception) {
_uiState.update { it.copy(error = "Audio upload failed: ${e.message}") }
} finally {
_uiState.update { it.copy(isUploading = false) }
}
}
}
fun cancelRecording() = audioRecorder.cancelRecording()
fun setEncryption(type: EncryptionType) = _uiState.update {
it.copy(encryptionType = type, showEncryptionPicker = false)
}
fun toggleEncryptionPicker() = _uiState.update {
it.copy(showEncryptionPicker = !it.showEncryptionPicker)
}
fun deleteMessage(messageId: Long) {
viewModelScope.launch { messageRepository.deleteMessage(messageId) }
}
fun clearError() = _uiState.update { it.copy(error = null) }
}

Ver fichero

@@ -0,0 +1,105 @@
package com.manalejandro.alejabber.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.manalejandro.alejabber.domain.model.PresenceStatus
import com.manalejandro.alejabber.ui.theme.StatusAway
import com.manalejandro.alejabber.ui.theme.StatusDnd
import com.manalejandro.alejabber.ui.theme.StatusOffline
import com.manalejandro.alejabber.ui.theme.StatusOnline
@Composable
fun AvatarWithStatus(
name: String,
avatarUrl: String?,
presence: PresenceStatus,
modifier: Modifier = Modifier,
size: Dp = 48.dp,
contentDescription: String = ""
) {
Box(modifier = modifier) {
if (!avatarUrl.isNullOrBlank()) {
AsyncImage(
model = avatarUrl,
contentDescription = contentDescription,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(size)
.clip(CircleShape)
)
} else {
InitialsAvatar(name = name, size = size, contentDescription = contentDescription)
}
// Presence dot
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.size(size * 0.27f)
.clip(CircleShape)
.background(presence.toColor())
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0f))
)
}
}
@Composable
fun InitialsAvatar(
name: String,
size: Dp = 48.dp,
contentDescription: String = "",
backgroundColor: Color = Color(0xFF3A5BCC)
) {
val initials = name.split(" ")
.take(2)
.mapNotNull { it.firstOrNull()?.uppercaseChar() }
.joinToString("")
.ifBlank { "?" }
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(size)
.clip(CircleShape)
.background(backgroundColor)
.semantics { this.contentDescription = contentDescription }
) {
Text(
text = initials,
color = Color.White,
fontSize = (size.value * 0.38f).sp,
fontWeight = FontWeight.Bold
)
}
}
fun PresenceStatus.toColor(): Color = when (this) {
PresenceStatus.ONLINE -> StatusOnline
PresenceStatus.AWAY, PresenceStatus.XA -> StatusAway
PresenceStatus.DND -> StatusDnd
PresenceStatus.OFFLINE -> StatusOffline
}
fun PresenceStatus.toLabel(): String = when (this) {
PresenceStatus.ONLINE -> "Online"
PresenceStatus.AWAY -> "Away"
PresenceStatus.XA -> "Extended Away"
PresenceStatus.DND -> "Do Not Disturb"
PresenceStatus.OFFLINE -> "Offline"
}

Ver fichero

@@ -0,0 +1,71 @@
package com.manalejandro.alejabber.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.manalejandro.alejabber.R
import com.manalejandro.alejabber.domain.model.EncryptionType
import com.manalejandro.alejabber.ui.theme.EncryptionNone
import com.manalejandro.alejabber.ui.theme.EncryptionOmemo
import com.manalejandro.alejabber.ui.theme.EncryptionOtr
import com.manalejandro.alejabber.ui.theme.EncryptionPgp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
@Composable
fun EncryptionBadge(
encryptionType: EncryptionType,
modifier: Modifier = Modifier
) {
val (color, label) = encryptionType.toBadgeInfo()
val cdLabel = stringResource(R.string.cd_encryption_badge, label)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.background(color.copy(alpha = 0.15f))
.padding(horizontal = 8.dp, vertical = 3.dp)
.semantics { contentDescription = cdLabel }
) {
Icon(
imageVector = if (encryptionType == EncryptionType.NONE) Icons.Default.LockOpen else Icons.Default.Lock,
contentDescription = null,
tint = color,
modifier = Modifier.size(12.dp)
)
Spacer(Modifier.width(4.dp))
Text(
text = label,
color = color,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold
)
}
}
fun EncryptionType.toBadgeInfo(): Pair<Color, String> = when (this) {
EncryptionType.OTR -> EncryptionOtr to "OTR"
EncryptionType.OMEMO -> EncryptionOmemo to "OMEMO"
EncryptionType.OPENPGP -> EncryptionPgp to "PGP"
EncryptionType.NONE -> EncryptionNone to "Plain"
}

Ver fichero

@@ -0,0 +1,463 @@
package com.manalejandro.alejabber.ui.contacts
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.manalejandro.alejabber.R
import com.manalejandro.alejabber.domain.model.Contact
import com.manalejandro.alejabber.domain.model.PresenceStatus
import com.manalejandro.alejabber.ui.components.AvatarWithStatus
import com.manalejandro.alejabber.ui.components.toColor
import com.manalejandro.alejabber.ui.components.toLabel
/**
* Shows the roster (contact list) for a single XMPP account identified by [accountId].
*
* The user navigates here by tapping a connected account in [AccountsScreen].
* From here, tapping a contact opens [ChatScreen] via [onNavigateToChat].
*
* @param accountId The database id of the account whose contacts are shown.
* @param onNavigateToChat Called with (accountId, contactJid) when a contact is tapped.
* @param onNavigateBack Called when the user presses the back arrow.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactsScreen(
accountId: Long,
onNavigateToChat: (Long, String) -> Unit,
onNavigateBack: () -> Unit,
viewModel: ContactsViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// Contact whose info sheet is shown on long-press
var detailContact by remember { mutableStateOf<Contact?>(null) }
// Contact pending removal confirmation
var removeTarget by remember { mutableStateOf<Contact?>(null) }
LaunchedEffect(accountId) { viewModel.loadForAccount(accountId) }
Scaffold(
topBar = {
Column {
TopAppBar(
title = {
Column {
Text(stringResource(R.string.contacts_title))
uiState.accountJid?.let { jid ->
Text(
text = jid,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
},
actions = {
// Sync roster from server
IconButton(onClick = { viewModel.syncRoster(accountId) }) {
Icon(Icons.Default.Refresh, contentDescription = "Sync roster")
}
}
)
// Inline search bar
SearchBar(
query = uiState.searchQuery,
onQueryChange = viewModel::onSearchQueryChange,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
)
}
},
floatingActionButton = {
FloatingActionButton(
onClick = viewModel::showAddDialog,
modifier = Modifier.semantics { contentDescription = "Add contact" }
) {
Icon(Icons.Default.PersonAdd, contentDescription = null)
}
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
when {
uiState.isLoading -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
uiState.filteredContacts.isEmpty() && uiState.searchQuery.isBlank() -> {
EmptyState(
icon = Icons.Default.People,
message = stringResource(R.string.contacts_empty),
actionLabel = "Sync now",
onAction = { viewModel.syncRoster(accountId) },
modifier = Modifier.align(Alignment.Center)
)
}
uiState.filteredContacts.isEmpty() -> {
EmptyState(
icon = Icons.Default.SearchOff,
message = "No contacts match \"${uiState.searchQuery}\"",
modifier = Modifier.align(Alignment.Center)
)
}
else -> {
ContactList(
contacts = uiState.filteredContacts,
onContactClick = { onNavigateToChat(accountId, it.jid) },
onContactLongPress = { detailContact = it },
onRemoveContact = { removeTarget = it }
)
}
}
}
}
// Add contact dialog
if (uiState.showAddDialog) {
AddContactDialog(
onDismiss = viewModel::hideAddDialog,
onAdd = { jid, nickname -> viewModel.addContact(accountId, jid, nickname) }
)
}
// Contact detail sheet (long-press)
detailContact?.let { contact ->
ContactDetailSheet(
contact = contact,
onChat = { onNavigateToChat(accountId, contact.jid); detailContact = null },
onRemove = { detailContact = null; removeTarget = contact },
onDismiss = { detailContact = null }
)
}
// Confirm remove dialog
removeTarget?.let { contact ->
val displayName = contact.nickname.ifBlank { contact.jid }
AlertDialog(
onDismissRequest = { removeTarget = null },
icon = { Icon(Icons.Default.PersonRemove, null, tint = MaterialTheme.colorScheme.error) },
title = { Text("Remove contact?") },
text = {
Text(
"Remove $displayName from your contact list?\n\n" +
"This will also remove them from your roster on the server.",
style = MaterialTheme.typography.bodyMedium
)
},
confirmButton = {
TextButton(onClick = {
viewModel.removeContact(accountId, contact.jid)
removeTarget = null
}) { Text("Remove", color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton(onClick = { removeTarget = null }) { Text(stringResource(R.string.cancel)) }
}
)
}
}
// ── Contact Detail Bottom Sheet ───────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactDetailSheet(
contact: Contact,
onChat: () -> Unit,
onRemove: () -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val displayName = contact.nickname.ifBlank { contact.jid }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Avatar
AvatarWithStatus(
name = displayName,
avatarUrl = contact.avatarUrl,
presence = contact.presence,
size = 80.dp,
contentDescription = displayName
)
Spacer(Modifier.height(12.dp))
// Name
Text(displayName, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
// JID
Text(
contact.jid,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(4.dp))
// Presence badge
Surface(
shape = RoundedCornerShape(50),
color = contact.presence.toColor().copy(alpha = 0.15f)
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(contact.presence.toColor())
)
Spacer(Modifier.width(6.dp))
Text(
text = contact.presence.toLabel(),
style = MaterialTheme.typography.labelMedium,
color = contact.presence.toColor()
)
}
}
// Status message
if (contact.statusMessage.isNotBlank()) {
Spacer(Modifier.height(8.dp))
Text(
"\"${contact.statusMessage}\"",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Groups
if (contact.groups.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text(
"Groups: ${contact.groups.joinToString(", ")}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(24.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
// Action: Chat
ListItem(
headlineContent = { Text("Start chat") },
leadingContent = { Icon(Icons.AutoMirrored.Filled.Chat, null, tint = MaterialTheme.colorScheme.primary) },
modifier = Modifier.clickable(onClick = onChat)
)
// Action: Remove
ListItem(
headlineContent = { Text("Remove contact", color = MaterialTheme.colorScheme.error) },
leadingContent = { Icon(Icons.Default.PersonRemove, null, tint = MaterialTheme.colorScheme.error) },
modifier = Modifier.clickable(onClick = onRemove)
)
Spacer(Modifier.height(8.dp))
}
}
}
// ─── Reusable composables ─────────────────────────────────────────────────
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text(stringResource(R.string.contacts_search)) },
leadingIcon = { Icon(Icons.Default.Search, null) },
trailingIcon = {
if (query.isNotBlank()) {
IconButton(onClick = { onQueryChange("") }) { Icon(Icons.Default.Clear, null) }
}
},
singleLine = true,
shape = RoundedCornerShape(24.dp),
modifier = modifier
)
}
@Composable
fun ContactList(
contacts: List<Contact>,
onContactClick: (Contact) -> Unit,
onContactLongPress: (Contact) -> Unit,
onRemoveContact: (Contact) -> Unit
) {
val presenceOrder = listOf(
PresenceStatus.ONLINE, PresenceStatus.AWAY, PresenceStatus.DND,
PresenceStatus.XA, PresenceStatus.OFFLINE
)
val deduplicated = contacts
.groupBy { it.jid }
.map { (_, dupes) ->
dupes.minByOrNull { presenceOrder.indexOf(it.presence).takeIf { i -> i >= 0 } ?: Int.MAX_VALUE }!!
}
val grouped = deduplicated.groupBy { it.presence }
LazyColumn(
contentPadding = PaddingValues(vertical = 8.dp),
modifier = Modifier.fillMaxSize()
) {
presenceOrder.forEach { presence ->
val group = grouped[presence] ?: return@forEach
if (group.isEmpty()) return@forEach
item(key = "header_${presence.name}") {
Text(
text = "${presence.toLabel()} (${group.size})",
style = MaterialTheme.typography.labelMedium,
color = presence.toColor(),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)
)
}
items(group, key = { "${presence.name}_${it.jid}" }) { contact ->
ContactItem(
contact = contact,
onClick = { onContactClick(contact) },
onLongPress = { onContactLongPress(contact) },
onRemove = { onRemoveContact(contact) }
)
}
}
item { Spacer(Modifier.height(88.dp)) }
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ContactItem(
contact: Contact,
onClick: () -> Unit,
onLongPress: () -> Unit,
onRemove: () -> Unit
) {
val displayName = contact.nickname.ifBlank { contact.jid }
ListItem(
headlineContent = { Text(displayName, fontWeight = FontWeight.Medium) },
supportingContent = {
Text(
contact.statusMessage.ifBlank { contact.presence.toLabel() },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
leadingContent = {
AvatarWithStatus(
name = displayName,
avatarUrl = contact.avatarUrl,
presence = contact.presence,
contentDescription = stringResource(R.string.cd_avatar, displayName)
)
},
trailingContent = {
IconButton(onClick = onRemove) {
Icon(
Icons.Default.PersonRemove, null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = onLongPress)
.animateContentSize()
.semantics { contentDescription = "Chat with $displayName" }
)
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
@Composable
fun AddContactDialog(
onDismiss: () -> Unit,
onAdd: (String, String) -> Unit
) {
var jid by remember { mutableStateOf("") }
var nickname by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.add_contact)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = jid, onValueChange = { jid = it },
label = { Text(stringResource(R.string.contact_jid)) },
placeholder = { Text("user@example.com") },
singleLine = true, modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = nickname, onValueChange = { nickname = it },
label = { Text(stringResource(R.string.contact_nickname)) },
singleLine = true, modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(onClick = { if (jid.isNotBlank()) onAdd(jid.trim(), nickname.trim()) }, enabled = jid.contains("@")) {
Text(stringResource(R.string.add_contact))
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }
)
}
@Composable
fun EmptyState(
icon: androidx.compose.ui.graphics.vector.ImageVector,
message: String,
actionLabel: String? = null,
onAction: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(icon, null, modifier = Modifier.size(72.dp), tint = MaterialTheme.colorScheme.outline)
Text(message, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (actionLabel != null && onAction != null) {
FilledTonalButton(onClick = onAction) { Text(actionLabel) }
}
}
}

Ver fichero

@@ -0,0 +1,145 @@
package com.manalejandro.alejabber.ui.contacts
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.manalejandro.alejabber.data.repository.AccountRepository
import com.manalejandro.alejabber.data.repository.ContactRepository
import com.manalejandro.alejabber.domain.model.Contact
import com.manalejandro.alejabber.domain.model.PresenceStatus
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* UI state for [ContactsScreen].
*
* @property accountJid JID of the account being displayed, shown in the toolbar subtitle.
* @property allContacts Full roster list from Room for this account.
* @property filteredContacts Roster filtered by [searchQuery].
* @property isLoading True while contacts are being fetched.
* @property searchQuery Current text in the search bar.
* @property showAddDialog Whether the add-contact dialog is visible.
* @property error Non-null when an operation failed.
*/
data class ContactsUiState(
val accountJid: String? = null,
val allContacts: List<Contact> = emptyList(),
val filteredContacts: List<Contact> = emptyList(),
val isLoading: Boolean = true,
val searchQuery: String = "",
val showAddDialog: Boolean = false,
val error: String? = null
)
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@HiltViewModel
class ContactsViewModel @Inject constructor(
private val accountRepository: AccountRepository,
private val contactRepository: ContactRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(ContactsUiState())
val uiState: StateFlow<ContactsUiState> = _uiState.asStateFlow()
private val searchQuery = MutableStateFlow("")
/**
* Load contacts for the given [accountId].
* Safe to call multiple times; cancels the previous collection.
*/
fun loadForAccount(accountId: Long) {
viewModelScope.launch {
// Resolve the JID for the toolbar subtitle
val account = accountRepository.getAccountById(accountId)
_uiState.update { it.copy(accountJid = account?.jid, isLoading = true) }
// Observe contacts + search query together
contactRepository.getContacts(accountId)
.combine(searchQuery) { contacts, query ->
contacts to query
}
.debounce(80)
.collect { (contacts, query) ->
// Deduplicate by JID — the roster sync can insert the same JID
// multiple times (e.g. once as OFFLINE seed + once from the server).
// Keep the entry whose presence is most available.
val presenceRank = mapOf(
PresenceStatus.ONLINE to 0, PresenceStatus.AWAY to 1,
PresenceStatus.DND to 2, PresenceStatus.XA to 3,
PresenceStatus.OFFLINE to 4
)
val deduped = contacts
.groupBy { it.jid }
.map { (_, dupes) ->
dupes.minByOrNull { presenceRank[it.presence] ?: 5 }!!
}
val filtered = if (query.isBlank()) deduped
else deduped.filter {
it.jid.contains(query, ignoreCase = true) ||
it.nickname.contains(query, ignoreCase = true)
}
_uiState.update {
it.copy(
allContacts = deduped,
filteredContacts = filtered,
isLoading = false
)
}
}
}
}
fun onSearchQueryChange(query: String) {
searchQuery.value = query
_uiState.update { it.copy(searchQuery = query) }
}
fun showAddDialog() = _uiState.update { it.copy(showAddDialog = true) }
fun hideAddDialog() = _uiState.update { it.copy(showAddDialog = false) }
fun addContact(accountId: Long, jid: String, nickname: String) {
viewModelScope.launch {
try {
contactRepository.addContact(
Contact(
accountId = accountId,
jid = jid,
nickname = nickname,
presence = PresenceStatus.OFFLINE,
statusMessage = "",
avatarUrl = null,
groups = emptyList()
)
)
hideAddDialog()
} catch (e: Exception) {
_uiState.update { it.copy(error = "Failed to add contact: ${e.message}") }
}
}
}
fun removeContact(accountId: Long, jid: String) {
viewModelScope.launch {
try {
contactRepository.removeContact(accountId, jid)
} catch (e: Exception) {
_uiState.update { it.copy(error = "Failed to remove contact: ${e.message}") }
}
}
}
fun syncRoster(accountId: Long) {
viewModelScope.launch {
try {
contactRepository.syncRoster(accountId)
} catch (e: Exception) {
_uiState.update { it.copy(error = "Sync failed: ${e.message}") }
}
}
}
}

Ver fichero

@@ -0,0 +1,154 @@
package com.manalejandro.alejabber.ui.navigation
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.manalejandro.alejabber.ui.accounts.AccountsScreen
import com.manalejandro.alejabber.ui.accounts.AddEditAccountScreen
import com.manalejandro.alejabber.ui.chat.ChatScreen
import com.manalejandro.alejabber.ui.contacts.ContactsScreen
import com.manalejandro.alejabber.ui.rooms.RoomsScreen
import com.manalejandro.alejabber.ui.settings.SettingsScreen
/** All navigable destinations in the app. */
sealed class Screen(val route: String) {
/** Account list — the app home screen. */
object Accounts : Screen("accounts")
/** Add a new XMPP account. */
object AddAccount : Screen("add_account")
/** Edit an existing account by its database id. */
object EditAccount : Screen("edit_account/{accountId}") {
fun createRoute(accountId: Long) = "edit_account/$accountId"
}
/**
* Contact list for a specific account.
* Navigated to after the user taps a connected account.
*/
object Contacts : Screen("contacts/{accountId}") {
fun createRoute(accountId: Long) = "contacts/$accountId"
}
/** MUC room list — accessible via bottom nav. */
object Rooms : Screen("rooms")
/**
* Chat screen for a 1-to-1 conversation or a MUC room.
*
* @param accountId The local account used to send messages.
* @param jid The bare JID of the contact or room.
* @param isRoom True when [jid] represents a MUC room.
*/
object Chat : Screen("chat/{accountId}/{jid}/{isRoom}") {
fun createRoute(accountId: Long, jid: String, isRoom: Boolean = false) =
"chat/$accountId/${jid.replace("/", "%2F")}/$isRoom"
}
/** Application settings. */
object Settings : Screen("settings")
}
@Composable
fun AleJabberNavGraph(
navController: NavHostController,
startDestination: String = Screen.Accounts.route
) {
NavHost(
navController = navController,
startDestination = startDestination,
enterTransition = {
slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(280))
},
exitTransition = { fadeOut(tween(180)) },
popEnterTransition = { fadeIn(tween(180)) },
popExitTransition = {
slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(280))
}
) {
// ── Accounts ─────────────────────────────────────────────────────────
composable(Screen.Accounts.route) {
AccountsScreen(
onAddAccount = { navController.navigate(Screen.AddAccount.route) },
onEditAccount = { id -> navController.navigate(Screen.EditAccount.createRoute(id)) },
onOpenContacts = { accountId ->
navController.navigate(Screen.Contacts.createRoute(accountId))
}
)
}
// ── Add account ───────────────────────────────────────────────────────
composable(Screen.AddAccount.route) {
AddEditAccountScreen(
accountId = null,
onNavigateBack = { navController.popBackStack() }
)
}
// ── Edit account ──────────────────────────────────────────────────────
composable(
route = Screen.EditAccount.route,
arguments = listOf(navArgument("accountId") { type = NavType.LongType })
) { back ->
AddEditAccountScreen(
accountId = back.arguments?.getLong("accountId"),
onNavigateBack = { navController.popBackStack() }
)
}
// ── Contacts for one account ──────────────────────────────────────────
composable(
route = Screen.Contacts.route,
arguments = listOf(navArgument("accountId") { type = NavType.LongType })
) { back ->
val accountId = back.arguments!!.getLong("accountId")
ContactsScreen(
accountId = accountId,
onNavigateToChat = { accId, jid ->
navController.navigate(Screen.Chat.createRoute(accId, jid))
},
onNavigateBack = { navController.popBackStack() }
)
}
// ── Rooms ─────────────────────────────────────────────────────────────
composable(Screen.Rooms.route) {
RoomsScreen(
onNavigateToRoom = { accountId, jid ->
navController.navigate(Screen.Chat.createRoute(accountId, jid, isRoom = true))
}
)
}
// ── Chat ──────────────────────────────────────────────────────────────
composable(
route = Screen.Chat.route,
arguments = listOf(
navArgument("accountId") { type = NavType.LongType },
navArgument("jid") { type = NavType.StringType },
navArgument("isRoom") { type = NavType.BoolType }
)
) { back ->
ChatScreen(
accountId = back.arguments!!.getLong("accountId"),
conversationJid = back.arguments!!.getString("jid")!!,
isRoom = back.arguments!!.getBoolean("isRoom"),
onNavigateBack = { navController.popBackStack() }
)
}
// ── Settings ──────────────────────────────────────────────────────────
composable(Screen.Settings.route) {
SettingsScreen(onNavigateBack = { navController.popBackStack() })
}
}
}

Ver fichero

@@ -0,0 +1,303 @@
package com.manalejandro.alejabber.ui.rooms
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.manalejandro.alejabber.R
import com.manalejandro.alejabber.domain.model.Account
import com.manalejandro.alejabber.domain.model.Room
import com.manalejandro.alejabber.ui.components.InitialsAvatar
import com.manalejandro.alejabber.ui.contacts.EmptyState
/**
* Displays joined MUC rooms for all connected accounts.
*
* If no account is connected the screen shows an instructional empty-state instead
* of the room list, and the FAB is hidden (there's no server to join a room on).
* Once at least one account is online the FAB appears and lets the user join a room.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomsScreen(
onNavigateToRoom: (Long, String) -> Unit,
viewModel: RoomsViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var roomToLeave by remember { mutableStateOf<Room?>(null) }
Scaffold(
topBar = {
TopAppBar(title = { Text(stringResource(R.string.rooms_title)) })
},
floatingActionButton = {
// Only show FAB when there is at least one connected account
if (uiState.hasConnectedAccount) {
FloatingActionButton(
onClick = viewModel::showJoinDialog,
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
) {
Icon(Icons.Default.Add, stringResource(R.string.join_room))
}
}
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
when {
// ── Loading ────────────────────────────────────────────────
uiState.isLoading -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
// ── No connected account ───────────────────────────────────
!uiState.hasConnectedAccount -> {
EmptyState(
icon = Icons.Default.CloudOff,
message = "Connect to an XMPP account first.\n" +
"Go to Accounts, then tap the cloud icon to connect.",
modifier = Modifier.align(Alignment.Center)
)
}
// ── Connected but no rooms yet ─────────────────────────────
uiState.rooms.isEmpty() -> {
EmptyState(
icon = Icons.Default.Forum,
message = stringResource(R.string.rooms_empty),
actionLabel = stringResource(R.string.join_room),
onAction = viewModel::showJoinDialog,
modifier = Modifier.align(Alignment.Center)
)
}
// ── Room list ──────────────────────────────────────────────
else -> {
LazyColumn(
contentPadding = PaddingValues(vertical = 8.dp),
modifier = Modifier.fillMaxSize()
) {
items(uiState.rooms, key = { "${it.accountId}_${it.jid}" }) { room ->
RoomItem(
room = room,
onClick = { onNavigateToRoom(room.accountId, room.jid) },
onLeave = { roomToLeave = room }
)
}
item { Spacer(Modifier.height(88.dp)) }
}
}
}
}
}
// ── Confirm leave room dialog ──────────────────────────────────────────
roomToLeave?.let { room ->
val displayName = room.name.ifBlank { room.jid }
AlertDialog(
onDismissRequest = { roomToLeave = null },
icon = { Icon(Icons.AutoMirrored.Filled.ExitToApp, null, tint = MaterialTheme.colorScheme.error) },
title = { Text("Leave room?") },
text = {
Text(
"Leave \"$displayName\"?\n\nYou will no longer receive messages from this room.",
style = MaterialTheme.typography.bodyMedium
)
},
confirmButton = {
TextButton(onClick = {
viewModel.leaveRoom(room.accountId, room.jid)
roomToLeave = null
}) { Text("Leave", color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton(onClick = { roomToLeave = null }) { Text("Cancel") }
}
)
}
// ── Join-room dialog ───────────────────────────────────────────────────
if (uiState.showJoinDialog) {
JoinRoomDialog(
connectedAccounts = uiState.connectedAccounts,
onDismiss = viewModel::hideJoinDialog,
onJoin = { accountId, jid, nickname, password ->
viewModel.joinRoom(accountId, jid, nickname, password)
}
)
}
// ── Error snackbar ─────────────────────────────────────────────────────
uiState.error?.let { msg ->
LaunchedEffect(msg) { viewModel.clearError() }
}
}
// ─── RoomItem ──────────────────────────────────────────────────────────────
@Composable
fun RoomItem(room: Room, onClick: () -> Unit, onLeave: () -> Unit) {
var menuExpanded by remember { mutableStateOf(false) }
val displayName = room.name.ifBlank { room.jid }
ListItem(
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(displayName, fontWeight = FontWeight.Medium)
if (room.unreadCount > 0) {
Spacer(Modifier.width(8.dp))
Badge { Text(room.unreadCount.toString()) }
}
}
},
supportingContent = {
Text(
room.topic.ifBlank { room.lastMessage.ifBlank { room.jid } },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
},
leadingContent = { InitialsAvatar(name = displayName, size = 48.dp) },
trailingContent = {
Box {
IconButton(onClick = { menuExpanded = true }) {
Icon(Icons.Default.MoreVert, stringResource(R.string.more_options))
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }
) {
DropdownMenuItem(
text = {
Text(
stringResource(R.string.leave_room),
color = MaterialTheme.colorScheme.error
)
},
leadingIcon = {
Icon(
Icons.AutoMirrored.Filled.ExitToApp, null,
tint = MaterialTheme.colorScheme.error
)
},
onClick = { menuExpanded = false; onLeave() }
)
}
}
},
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
)
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
// ─── JoinRoomDialog ────────────────────────────────────────────────────────
/**
* Dialog for joining a MUC room.
* [connectedAccounts] are the only accounts eligible — disconnected ones are excluded.
*/
@Composable
fun JoinRoomDialog(
connectedAccounts: List<Account>,
onDismiss: () -> Unit,
onJoin: (Long, String, String, String) -> Unit
) {
var selectedAccountId by remember {
mutableStateOf(connectedAccounts.firstOrNull()?.id ?: 0L)
}
var roomJid by remember { mutableStateOf("") }
var nickname by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var accountMenuExpanded by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.join_room)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
// Account selector (always shown so user knows which account is used)
Box {
OutlinedTextField(
value = connectedAccounts.find { it.id == selectedAccountId }?.jid ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Account") },
trailingIcon = {
IconButton(onClick = { accountMenuExpanded = true }) {
Icon(Icons.Default.ArrowDropDown, null)
}
},
modifier = Modifier.fillMaxWidth()
)
DropdownMenu(
expanded = accountMenuExpanded,
onDismissRequest = { accountMenuExpanded = false }
) {
connectedAccounts.forEach { acc ->
DropdownMenuItem(
text = { Text(acc.jid) },
onClick = { selectedAccountId = acc.id; accountMenuExpanded = false }
)
}
}
}
OutlinedTextField(
value = roomJid,
onValueChange = { roomJid = it },
label = { Text(stringResource(R.string.room_jid)) },
placeholder = { Text("room@conference.example.com") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = nickname,
onValueChange = { nickname = it },
label = { Text(stringResource(R.string.room_nickname)) },
placeholder = { Text("Your display name in the room") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.room_password) + " (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
onClick = {
if (roomJid.isNotBlank() && nickname.isNotBlank())
onJoin(selectedAccountId, roomJid.trim(), nickname.trim(), password)
},
enabled = roomJid.contains("@") && nickname.isNotBlank()
) { Text(stringResource(R.string.join_room)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
}
)
}

Ver fichero

@@ -0,0 +1,96 @@
package com.manalejandro.alejabber.ui.rooms
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.manalejandro.alejabber.data.repository.AccountRepository
import com.manalejandro.alejabber.data.repository.RoomRepository
import com.manalejandro.alejabber.domain.model.Account
import com.manalejandro.alejabber.domain.model.ConnectionStatus
import com.manalejandro.alejabber.domain.model.Room
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
data class RoomsUiState(
/** All accounts, used to detect connected ones. */
val accounts: List<Account> = emptyList(),
val rooms: List<Room> = emptyList(),
val isLoading: Boolean = true,
val showJoinDialog: Boolean = false,
val error: String? = null
) {
/** True when at least one account has an active XMPP connection. */
val hasConnectedAccount: Boolean
get() = accounts.any {
it.status == ConnectionStatus.ONLINE ||
it.status == ConnectionStatus.AWAY ||
it.status == ConnectionStatus.DND
}
/** Only accounts that are currently connected — used to populate the join-room dialog. */
val connectedAccounts: List<Account>
get() = accounts.filter {
it.status == ConnectionStatus.ONLINE ||
it.status == ConnectionStatus.AWAY ||
it.status == ConnectionStatus.DND
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class RoomsViewModel @Inject constructor(
private val accountRepository: AccountRepository,
private val roomRepository: RoomRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(RoomsUiState(isLoading = true))
val uiState: StateFlow<RoomsUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
accountRepository.getAllAccounts().collect { accounts ->
_uiState.update { it.copy(accounts = accounts) }
if (accounts.isNotEmpty()) loadRooms(accounts.map { a -> a.id })
else _uiState.update { it.copy(isLoading = false) }
}
}
}
private fun loadRooms(accountIds: List<Long>) {
viewModelScope.launch {
val flows = accountIds.map { id -> roomRepository.getRooms(id) }
combine(flows) { arrays -> arrays.flatMap { it } }
.collect { rooms ->
_uiState.update { it.copy(rooms = rooms, isLoading = false) }
}
}
}
fun showJoinDialog() = _uiState.update { it.copy(showJoinDialog = true) }
fun hideJoinDialog() = _uiState.update { it.copy(showJoinDialog = false) }
fun joinRoom(accountId: Long, roomJid: String, nickname: String, password: String = "") {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
try {
val success = roomRepository.joinRoom(accountId, roomJid, nickname, password)
if (success) {
roomRepository.saveRoom(
Room(accountId = accountId, jid = roomJid, nickname = nickname, isJoined = true)
)
}
_uiState.update { it.copy(isLoading = false, showJoinDialog = false) }
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
}
}
fun leaveRoom(accountId: Long, roomJid: String) {
viewModelScope.launch { roomRepository.leaveRoom(accountId, roomJid) }
}
fun clearError() = _uiState.update { it.copy(error = null) }
}

Ver fichero

@@ -0,0 +1,240 @@
package com.manalejandro.alejabber.ui.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.manalejandro.alejabber.R
import com.manalejandro.alejabber.domain.model.EncryptionType
import com.manalejandro.alejabber.ui.theme.AppTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onNavigateBack: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showThemeDialog by remember { mutableStateOf(false) }
var showEncryptionDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.settings_title)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
) {
// Appearance section
SettingsSectionHeader(stringResource(R.string.settings_appearance))
SettingsItem(
icon = Icons.Default.Palette,
title = stringResource(R.string.settings_theme),
subtitle = uiState.appTheme.toDisplayName(),
onClick = { showThemeDialog = true }
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// Notifications section
SettingsSectionHeader(stringResource(R.string.settings_notifications))
SettingsSwitchItem(
icon = Icons.Default.Notifications,
title = stringResource(R.string.settings_notifications_messages),
checked = uiState.notificationsEnabled,
onCheckedChange = viewModel::setNotifications
)
SettingsSwitchItem(
icon = Icons.Default.Vibration,
title = stringResource(R.string.settings_notifications_vibrate),
checked = uiState.vibrateEnabled,
onCheckedChange = viewModel::setVibrate
)
SettingsSwitchItem(
icon = Icons.AutoMirrored.Filled.VolumeUp,
title = stringResource(R.string.settings_notifications_sound),
checked = uiState.soundEnabled,
onCheckedChange = viewModel::setSound
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// Encryption section
SettingsSectionHeader(stringResource(R.string.settings_encryption))
SettingsItem(
icon = Icons.Default.Lock,
title = stringResource(R.string.settings_default_encryption),
subtitle = uiState.defaultEncryption.name,
onClick = { showEncryptionDialog = true }
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// About section
SettingsSectionHeader(stringResource(R.string.settings_about))
SettingsItem(
icon = Icons.Default.Info,
title = stringResource(R.string.settings_version),
subtitle = "1.0.0",
onClick = {}
)
Spacer(Modifier.height(32.dp))
}
}
// Theme dialog
if (showThemeDialog) {
AlertDialog(
onDismissRequest = { showThemeDialog = false },
title = { Text(stringResource(R.string.settings_theme)) },
text = {
Column {
AppTheme.entries.forEach { theme ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
viewModel.setTheme(theme)
showThemeDialog = false
}
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = uiState.appTheme == theme, onClick = {
viewModel.setTheme(theme)
showThemeDialog = false
})
Spacer(Modifier.width(12.dp))
Text(theme.toDisplayName())
}
}
}
},
confirmButton = {
TextButton(onClick = { showThemeDialog = false }) { Text(stringResource(R.string.cancel)) }
}
)
}
// Encryption default dialog
if (showEncryptionDialog) {
AlertDialog(
onDismissRequest = { showEncryptionDialog = false },
title = { Text(stringResource(R.string.settings_default_encryption)) },
text = {
Column {
EncryptionType.entries.forEach { type ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
viewModel.setDefaultEncryption(type)
showEncryptionDialog = false
}
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = uiState.defaultEncryption == type, onClick = {
viewModel.setDefaultEncryption(type)
showEncryptionDialog = false
})
Spacer(Modifier.width(12.dp))
Text(type.name)
}
}
}
},
confirmButton = {
TextButton(onClick = { showEncryptionDialog = false }) { Text(stringResource(R.string.cancel)) }
}
)
}
}
@Composable
fun SettingsSectionHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(start = 16.dp, top = 20.dp, bottom = 4.dp)
)
}
@Composable
fun SettingsItem(
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String,
subtitle: String = "",
onClick: () -> Unit
) {
ListItem(
headlineContent = { Text(title) },
supportingContent = if (subtitle.isNotBlank()) {
{ Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
} else null,
leadingContent = {
Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
},
modifier = Modifier.clickable(onClick = onClick)
)
}
@Composable
fun SettingsSwitchItem(
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
ListItem(
headlineContent = { Text(title) },
leadingContent = {
Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
},
trailingContent = {
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
)
}
fun AppTheme.toDisplayName(): String = when (this) {
AppTheme.SYSTEM -> "System Default"
AppTheme.LIGHT -> "Light"
AppTheme.DARK -> "Dark"
}

Ver fichero

@@ -0,0 +1,87 @@
package com.manalejandro.alejabber.ui.settings
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.manalejandro.alejabber.domain.model.EncryptionType
import com.manalejandro.alejabber.ui.theme.AppTheme
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
data class SettingsUiState(
val appTheme: AppTheme = AppTheme.SYSTEM,
val notificationsEnabled: Boolean = true,
val vibrateEnabled: Boolean = true,
val soundEnabled: Boolean = true,
val defaultEncryption: EncryptionType = EncryptionType.OMEMO
)
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val dataStore: DataStore<Preferences>
) : ViewModel() {
companion object {
val KEY_THEME = stringPreferencesKey("app_theme")
val KEY_NOTIFICATIONS = booleanPreferencesKey("notifications")
val KEY_VIBRATE = booleanPreferencesKey("vibrate")
val KEY_SOUND = booleanPreferencesKey("sound")
val KEY_DEFAULT_ENCRYPTION = stringPreferencesKey("default_encryption")
}
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
dataStore.data.collect { prefs ->
_uiState.update { state ->
state.copy(
appTheme = try { AppTheme.valueOf(prefs[KEY_THEME] ?: "SYSTEM") } catch (e: Exception) { AppTheme.SYSTEM },
notificationsEnabled = prefs[KEY_NOTIFICATIONS] ?: true,
vibrateEnabled = prefs[KEY_VIBRATE] ?: true,
soundEnabled = prefs[KEY_SOUND] ?: true,
defaultEncryption = try { EncryptionType.valueOf(prefs[KEY_DEFAULT_ENCRYPTION] ?: "OMEMO") } catch (e: Exception) { EncryptionType.OMEMO }
)
}
}
}
}
fun setTheme(theme: AppTheme) {
viewModelScope.launch {
dataStore.edit { it[KEY_THEME] = theme.name }
}
}
fun setNotifications(enabled: Boolean) {
viewModelScope.launch {
dataStore.edit { it[KEY_NOTIFICATIONS] = enabled }
}
}
fun setVibrate(enabled: Boolean) {
viewModelScope.launch {
dataStore.edit { it[KEY_VIBRATE] = enabled }
}
}
fun setSound(enabled: Boolean) {
viewModelScope.launch {
dataStore.edit { it[KEY_SOUND] = enabled }
}
}
fun setDefaultEncryption(type: EncryptionType) {
viewModelScope.launch {
dataStore.edit { it[KEY_DEFAULT_ENCRYPTION] = type.name }
}
}
}

Ver fichero

@@ -0,0 +1,55 @@
package com.manalejandro.alejabber.ui.theme
import androidx.compose.ui.graphics.Color
// Primary - Indigo/Blue
val Primary80 = Color(0xFFB0C4FF)
val Primary40 = Color(0xFF3A5BCC)
val PrimaryContainer80 = Color(0xFFDCE4FF)
val PrimaryContainer40 = Color(0xFF1B3AA0)
// Secondary - Teal
val Secondary80 = Color(0xFFB3EFEF)
val Secondary40 = Color(0xFF1A7A7A)
val SecondaryContainer80 = Color(0xFFCCF5F5)
val SecondaryContainer40 = Color(0xFF0A5858)
// Tertiary - Violet
val Tertiary80 = Color(0xFFD4B8FF)
val Tertiary40 = Color(0xFF6636B8)
// Error
val Error80 = Color(0xFFFFB4AB)
val Error40 = Color(0xFFBA1A1A)
// Neutral
val NeutralVariant80 = Color(0xFFC6C6D0)
val NeutralVariant40 = Color(0xFF46464F)
// Background / Surface
val BackgroundLight = Color(0xFFFBFBFF)
val BackgroundDark = Color(0xFF1B1B1F)
val SurfaceLight = Color(0xFFFBFBFF)
val SurfaceDark = Color(0xFF1B1B1F)
val SurfaceVariantLight = Color(0xFFE4E1EC)
val SurfaceVariantDark = Color(0xFF47464F)
val OnSurfaceVariantLight = Color(0xFF47464F)
val OnSurfaceVariantDark = Color(0xFFC8C5D0)
// Encryption badge colors
val EncryptionOtr = Color(0xFF4CAF50)
val EncryptionOmemo = Color(0xFF2196F3)
val EncryptionPgp = Color(0xFF9C27B0)
val EncryptionNone = Color(0xFF9E9E9E)
// Status colors
val StatusOnline = Color(0xFF4CAF50)
val StatusAway = Color(0xFFFF9800)
val StatusDnd = Color(0xFFF44336)
val StatusOffline = Color(0xFF9E9E9E)
// Chat bubble colors
val BubbleSent = Color(0xFF3A5BCC)
val BubbleReceived = Color(0xFFE4E1EC)
val BubbleSentDark = Color(0xFF5E7CE2)
val BubbleReceivedDark = Color(0xFF47464F)

Ver fichero

@@ -0,0 +1,87 @@
package com.manalejandro.alejabber.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Primary80,
onPrimary = Color(0xFF002082),
primaryContainer = PrimaryContainer40,
onPrimaryContainer = PrimaryContainer80,
secondary = Secondary80,
onSecondary = Color(0xFF003737),
secondaryContainer = SecondaryContainer40,
onSecondaryContainer = SecondaryContainer80,
tertiary = Tertiary80,
onTertiary = Color(0xFF3B0083),
error = Error80,
onError = Color(0xFF690005),
background = BackgroundDark,
onBackground = Color(0xFFE4E1E6),
surface = SurfaceDark,
onSurface = Color(0xFFE4E1E6),
surfaceVariant = SurfaceVariantDark,
onSurfaceVariant = OnSurfaceVariantDark,
outline = Color(0xFF918F9A)
)
private val LightColorScheme = lightColorScheme(
primary = Primary40,
onPrimary = Color.White,
primaryContainer = PrimaryContainer80,
onPrimaryContainer = PrimaryContainer40,
secondary = Secondary40,
onSecondary = Color.White,
secondaryContainer = SecondaryContainer80,
onSecondaryContainer = SecondaryContainer40,
tertiary = Tertiary40,
onTertiary = Color.White,
error = Error40,
onError = Color.White,
background = BackgroundLight,
onBackground = Color(0xFF1B1B1F),
surface = SurfaceLight,
onSurface = Color(0xFF1B1B1F),
surfaceVariant = SurfaceVariantLight,
onSurfaceVariant = OnSurfaceVariantLight,
outline = Color(0xFF77767F)
)
enum class AppTheme { SYSTEM, LIGHT, DARK }
@Composable
fun AleJabberTheme(
appTheme: AppTheme = AppTheme.SYSTEM,
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val darkTheme = when (appTheme) {
AppTheme.DARK -> true
AppTheme.LIGHT -> false
AppTheme.SYSTEM -> isSystemInDarkTheme()
}
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

Ver fichero

@@ -0,0 +1,34 @@
package com.manalejandro.alejabber.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)

Ver fichero

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
AleJabber App Icon - Background Layer
Deep Indigo gradient background for the adaptive icon.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1A237E"
android:pathData="M0,0h108v108h-108z" />
<!-- Subtle radial highlight -->
<path
android:fillColor="#283593"
android:pathData="M54,54 m-40,0 a40,40 0 1,0 80,0 a40,40 0 1,0 -80,0" />
</vector>

Ver fichero

@@ -0,0 +1,36 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group android:scaleX="0.58"
android:scaleY="0.58"
android:translateX="107.52"
android:translateY="107.52">
<path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"
android:fillColor="#1A237E"/>
<path
android:pathData="M256,230m-180,0a180,180 0,1 1,360 0a180,180 0,1 1,-360 0"
android:strokeAlpha="0.6"
android:fillColor="#283593"
android:fillAlpha="0.6"/>
<path
android:pathData="M110,110Q80,110 80,140L80,310Q80,340 110,340L155,340L140,420L240,340L400,340Q430,340 430,310L430,140Q430,110 400,110Z"
android:fillColor="#FFFFFF"/>
<path
android:pathData="M116,118Q90,118 90,144L90,306Q90,332 116,332L157,332L144,410L238,332L396,332Q422,332 422,306L422,144Q422,118 396,118Z"
android:strokeAlpha="0.55"
android:fillColor="#C5CAE9"
android:fillAlpha="0.55"/>
<path
android:pathData="M300,122L210,258L258,258L192,388L322,258L270,258Z"
android:fillColor="#3D5AFE"/>
<path
android:pathData="M400,128m-32,0a32,32 0,1 1,64 0a32,32 0,1 1,-64 0"
android:fillColor="#00E676"/>
<path
android:pathData="M400,128m-22,0a22,22 0,1 1,44 0a22,22 0,1 1,-44 0"
android:fillColor="#69F0AE"/>
</group>
</vector>

Ver fichero

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Ver fichero

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Archivo binario no mostrado.

Después

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

Archivo binario no mostrado.

Después

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

Archivo binario no mostrado.

Después

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

Archivo binario no mostrado.

Después

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

Archivo binario no mostrado.

Después

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

Archivo binario no mostrado.

Después

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

Archivo binario no mostrado.

Después

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

Archivo binario no mostrado.

Después

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

Archivo binario no mostrado.

Después

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

Archivo binario no mostrado.

Después

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

Ver fichero

@@ -0,0 +1,156 @@
<resources>
<string name="app_name">AleJabber</string>
<!-- Navegación -->
<string name="nav_contacts">Contactos</string>
<string name="nav_rooms">Salas</string>
<string name="nav_accounts">Cuentas</string>
<string name="nav_settings">Ajustes</string>
<!-- Cuentas -->
<string name="accounts_title">Cuentas</string>
<string name="add_account">Añadir cuenta</string>
<string name="edit_account">Editar cuenta</string>
<string name="delete_account">Eliminar cuenta</string>
<string name="account_username">Usuario (JID)</string>
<string name="account_password">Contraseña</string>
<string name="account_server">Servidor</string>
<string name="account_port">Puerto</string>
<string name="account_use_tls">Usar TLS</string>
<string name="account_status_online">Conectado</string>
<string name="account_status_offline">Desconectado</string>
<string name="account_status_connecting">Conectando…</string>
<string name="account_status_error">Error de conexión</string>
<string name="account_connect">Conectar</string>
<string name="account_disconnect">Desconectar</string>
<string name="account_no_accounts">No hay cuentas configuradas.\nPulsa + para añadir una.</string>
<string name="account_delete_confirm">¿Eliminar la cuenta %1$s?</string>
<string name="account_jid_hint">usuario@ejemplo.com</string>
<string name="account_resource">Recurso</string>
<string name="account_resource_hint">AleJabber</string>
<!-- Contactos -->
<string name="contacts_title">Contactos</string>
<string name="contacts_search">Buscar contactos…</string>
<string name="contacts_empty">Sin contactos.\nAñade personas con su ID de Jabber.</string>
<string name="add_contact">Añadir contacto</string>
<string name="contact_jid">ID de Jabber (JID)</string>
<string name="contact_nickname">Apodo</string>
<string name="contact_remove">Eliminar contacto</string>
<string name="contact_block">Bloquear contacto</string>
<string name="contact_status_online">En línea</string>
<string name="contact_status_away">Ausente</string>
<string name="contact_status_dnd">No molestar</string>
<string name="contact_status_offline">Desconectado</string>
<!-- Salas / MUC -->
<string name="rooms_title">Salas</string>
<string name="rooms_search">Buscar salas…</string>
<string name="rooms_empty">No te has unido a ninguna sala.</string>
<string name="join_room">Unirse a sala</string>
<string name="room_jid">JID de la sala</string>
<string name="room_nickname">Tu apodo</string>
<string name="room_password">Contraseña (opcional)</string>
<string name="leave_room">Salir de la sala</string>
<string name="room_participants">Participantes</string>
<string name="room_topic">Tema</string>
<string name="browse_rooms">Explorar salas</string>
<!-- Chat -->
<string name="chat_hint">Escribe un mensaje…</string>
<string name="chat_send">Enviar</string>
<string name="chat_attach">Adjuntar archivo</string>
<string name="chat_record_audio">Grabar audio</string>
<string name="chat_stop_recording">Detener grabación</string>
<string name="chat_send_audio">Enviar audio</string>
<string name="chat_cancel_audio">Cancelar audio</string>
<string name="chat_encryption_none">Sin cifrado</string>
<string name="chat_encryption_otr">OTR</string>
<string name="chat_encryption_omemo">OMEMO</string>
<string name="chat_encryption_pgp">OpenPGP</string>
<string name="chat_encryption_select">Seleccionar cifrado</string>
<string name="chat_message_delivered">Entregado</string>
<string name="chat_message_read">Leído</string>
<string name="chat_message_sending">Enviando…</string>
<string name="chat_message_failed">Error al enviar</string>
<string name="chat_typing">%1$s está escribiendo…</string>
<string name="chat_media_image">Imagen</string>
<string name="chat_media_video">Vídeo</string>
<string name="chat_media_audio">Audio</string>
<string name="chat_media_file">Archivo</string>
<string name="chat_media_uploading">Subiendo…</string>
<string name="chat_media_download">Descargar</string>
<string name="chat_empty">Sin mensajes aún.\n¡Di hola!</string>
<string name="chat_otr_started">Sesión OTR iniciada. La conversación está cifrada.</string>
<string name="chat_otr_ended">Sesión OTR finalizada.</string>
<string name="chat_otr_untrusted">Advertencia: Huella OTR no verificada.</string>
<string name="chat_omemo_trusted">OMEMO: Todos los dispositivos son de confianza.</string>
<string name="chat_omemo_untrusted">OMEMO: Dispositivos no verificados detectados.</string>
<!-- Ajustes -->
<string name="settings_title">Ajustes</string>
<string name="settings_appearance">Apariencia</string>
<string name="settings_theme">Tema</string>
<string name="settings_theme_system">Por defecto del sistema</string>
<string name="settings_theme_light">Claro</string>
<string name="settings_theme_dark">Oscuro</string>
<string name="settings_language">Idioma</string>
<string name="settings_language_en">English</string>
<string name="settings_language_es">Español</string>
<string name="settings_language_zh">中文</string>
<string name="settings_notifications">Notificaciones</string>
<string name="settings_notifications_messages">Notificaciones de mensajes</string>
<string name="settings_notifications_vibrate">Vibrar</string>
<string name="settings_notifications_sound">Sonido</string>
<string name="settings_encryption">Cifrado</string>
<string name="settings_omemo_devices">Dispositivos OMEMO</string>
<string name="settings_pgp_keys">Claves OpenPGP</string>
<string name="settings_otr_fingerprints">Huellas OTR</string>
<string name="settings_default_encryption">Cifrado por defecto</string>
<string name="settings_about">Acerca de</string>
<string name="settings_version">Versión</string>
<!-- Común -->
<string name="ok">Aceptar</string>
<string name="cancel">Cancelar</string>
<string name="save">Guardar</string>
<string name="delete">Eliminar</string>
<string name="confirm">Confirmar</string>
<string name="error">Error</string>
<string name="loading">Cargando…</string>
<string name="retry">Reintentar</string>
<string name="close">Cerrar</string>
<string name="search">Buscar</string>
<string name="clear">Limpiar</string>
<string name="back">Atrás</string>
<string name="more_options">Más opciones</string>
<!-- Permisos -->
<string name="permission_microphone_title">Permiso de micrófono</string>
<string name="permission_microphone_message">AleJabber necesita acceso al micrófono para grabar mensajes de audio.</string>
<string name="permission_storage_title">Permiso de almacenamiento</string>
<string name="permission_storage_message">AleJabber necesita acceso al almacenamiento para enviar y recibir archivos.</string>
<string name="permission_camera_title">Permiso de cámara</string>
<string name="permission_camera_message">AleJabber necesita acceso a la cámara para tomar fotos.</string>
<string name="permission_denied">Permiso denegado. Por favor, concédelo en Ajustes.</string>
<string name="open_settings">Abrir Ajustes</string>
<!-- Notificaciones -->
<string name="notification_channel_messages">Mensajes</string>
<string name="notification_channel_messages_desc">Mensajes de chat entrantes</string>
<string name="notification_channel_service">Servicio XMPP</string>
<string name="notification_channel_service_desc">Conexión XMPP en segundo plano</string>
<string name="notification_service_running">AleJabber está conectado</string>
<string name="notification_new_message">Nuevo mensaje de %1$s</string>
<!-- Accesibilidad -->
<string name="cd_avatar">Avatar de %1$s</string>
<string name="cd_status_indicator">Estado: %1$s</string>
<string name="cd_encryption_badge">Cifrado: %1$s</string>
<string name="cd_send_button">Enviar mensaje</string>
<string name="cd_attach_button">Adjuntar archivo</string>
<string name="cd_record_button">Grabar mensaje de audio</string>
<string name="cd_message_delivered">Mensaje entregado</string>
<string name="cd_message_read">Mensaje leído</string>
</resources>

Ver fichero

@@ -0,0 +1,156 @@
<resources>
<string name="app_name">AleJabber</string>
<!-- 导航 -->
<string name="nav_contacts">联系人</string>
<string name="nav_rooms">聊天室</string>
<string name="nav_accounts">账号</string>
<string name="nav_settings">设置</string>
<!-- 账号 -->
<string name="accounts_title">账号管理</string>
<string name="add_account">添加账号</string>
<string name="edit_account">编辑账号</string>
<string name="delete_account">删除账号</string>
<string name="account_username">用户名 (JID)</string>
<string name="account_password">密码</string>
<string name="account_server">服务器</string>
<string name="account_port">端口</string>
<string name="account_use_tls">使用 TLS</string>
<string name="account_status_online">已连接</string>
<string name="account_status_offline">已断开</string>
<string name="account_status_connecting">连接中…</string>
<string name="account_status_error">连接错误</string>
<string name="account_connect">连接</string>
<string name="account_disconnect">断开连接</string>
<string name="account_no_accounts">尚未配置账号。\n点击 + 添加账号。</string>
<string name="account_delete_confirm">删除账号 %1$s</string>
<string name="account_jid_hint">user@example.com</string>
<string name="account_resource">资源</string>
<string name="account_resource_hint">AleJabber</string>
<!-- 联系人 -->
<string name="contacts_title">联系人</string>
<string name="contacts_search">搜索联系人…</string>
<string name="contacts_empty">暂无联系人。\n通过 Jabber ID 添加好友。</string>
<string name="add_contact">添加联系人</string>
<string name="contact_jid">Jabber ID (JID)</string>
<string name="contact_nickname">昵称</string>
<string name="contact_remove">删除联系人</string>
<string name="contact_block">屏蔽联系人</string>
<string name="contact_status_online">在线</string>
<string name="contact_status_away">离开</string>
<string name="contact_status_dnd">请勿打扰</string>
<string name="contact_status_offline">离线</string>
<!-- 聊天室 / MUC -->
<string name="rooms_title">聊天室</string>
<string name="rooms_search">搜索聊天室…</string>
<string name="rooms_empty">尚未加入任何聊天室。</string>
<string name="join_room">加入聊天室</string>
<string name="room_jid">聊天室 JID</string>
<string name="room_nickname">您的昵称</string>
<string name="room_password">聊天室密码(可选)</string>
<string name="leave_room">退出聊天室</string>
<string name="room_participants">参与者</string>
<string name="room_topic">主题</string>
<string name="browse_rooms">浏览聊天室</string>
<!-- 聊天 -->
<string name="chat_hint">输入消息…</string>
<string name="chat_send">发送</string>
<string name="chat_attach">附件</string>
<string name="chat_record_audio">录音</string>
<string name="chat_stop_recording">停止录音</string>
<string name="chat_send_audio">发送音频</string>
<string name="chat_cancel_audio">取消录音</string>
<string name="chat_encryption_none">无加密</string>
<string name="chat_encryption_otr">OTR</string>
<string name="chat_encryption_omemo">OMEMO</string>
<string name="chat_encryption_pgp">OpenPGP</string>
<string name="chat_encryption_select">选择加密方式</string>
<string name="chat_message_delivered">已送达</string>
<string name="chat_message_read">已读</string>
<string name="chat_message_sending">发送中…</string>
<string name="chat_message_failed">发送失败</string>
<string name="chat_typing">%1$s 正在输入…</string>
<string name="chat_media_image">图片</string>
<string name="chat_media_video">视频</string>
<string name="chat_media_audio">音频</string>
<string name="chat_media_file">文件</string>
<string name="chat_media_uploading">上传中…</string>
<string name="chat_media_download">下载</string>
<string name="chat_empty">暂无消息。\n说声你好</string>
<string name="chat_otr_started">OTR 会话已开始,您的对话已加密。</string>
<string name="chat_otr_ended">OTR 会话已结束。</string>
<string name="chat_otr_untrusted">警告OTR 指纹未验证。</string>
<string name="chat_omemo_trusted">OMEMO所有设备已信任。</string>
<string name="chat_omemo_untrusted">OMEMO检测到不受信任的设备。</string>
<!-- 设置 -->
<string name="settings_title">设置</string>
<string name="settings_appearance">外观</string>
<string name="settings_theme">主题</string>
<string name="settings_theme_system">跟随系统</string>
<string name="settings_theme_light">浅色</string>
<string name="settings_theme_dark">深色</string>
<string name="settings_language">语言</string>
<string name="settings_language_en">English</string>
<string name="settings_language_es">Español</string>
<string name="settings_language_zh">中文</string>
<string name="settings_notifications">通知</string>
<string name="settings_notifications_messages">消息通知</string>
<string name="settings_notifications_vibrate">震动</string>
<string name="settings_notifications_sound">声音</string>
<string name="settings_encryption">加密</string>
<string name="settings_omemo_devices">OMEMO 设备</string>
<string name="settings_pgp_keys">OpenPGP 密钥</string>
<string name="settings_otr_fingerprints">OTR 指纹</string>
<string name="settings_default_encryption">默认加密方式</string>
<string name="settings_about">关于</string>
<string name="settings_version">版本</string>
<!-- 通用 -->
<string name="ok">确定</string>
<string name="cancel">取消</string>
<string name="save">保存</string>
<string name="delete">删除</string>
<string name="confirm">确认</string>
<string name="error">错误</string>
<string name="loading">加载中…</string>
<string name="retry">重试</string>
<string name="close">关闭</string>
<string name="search">搜索</string>
<string name="clear">清除</string>
<string name="back">返回</string>
<string name="more_options">更多选项</string>
<!-- 权限 -->
<string name="permission_microphone_title">麦克风权限</string>
<string name="permission_microphone_message">AleJabber 需要麦克风权限来录制语音消息。</string>
<string name="permission_storage_title">存储权限</string>
<string name="permission_storage_message">AleJabber 需要存储权限来发送和接收文件。</string>
<string name="permission_camera_title">相机权限</string>
<string name="permission_camera_message">AleJabber 需要相机权限来拍照。</string>
<string name="permission_denied">权限被拒绝,请在设置中授予权限。</string>
<string name="open_settings">打开设置</string>
<!-- 通知 -->
<string name="notification_channel_messages">消息</string>
<string name="notification_channel_messages_desc">收到的聊天消息</string>
<string name="notification_channel_service">XMPP 服务</string>
<string name="notification_channel_service_desc">后台 XMPP 连接</string>
<string name="notification_service_running">AleJabber 已连接</string>
<string name="notification_new_message">来自 %1$s 的新消息</string>
<!-- 无障碍 -->
<string name="cd_avatar">%1$s 的头像</string>
<string name="cd_status_indicator">状态:%1$s</string>
<string name="cd_encryption_badge">加密:%1$s</string>
<string name="cd_send_button">发送消息</string>
<string name="cd_attach_button">附件</string>
<string name="cd_record_button">录制语音消息</string>
<string name="cd_message_delivered">消息已送达</string>
<string name="cd_message_read">消息已读</string>
</resources>

Ver fichero

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

Ver fichero

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

Ver fichero

@@ -0,0 +1,156 @@
<resources>
<string name="app_name">AleJabber</string>
<!-- Navigation -->
<string name="nav_contacts">Contacts</string>
<string name="nav_rooms">Rooms</string>
<string name="nav_accounts">Accounts</string>
<string name="nav_settings">Settings</string>
<!-- Accounts -->
<string name="accounts_title">Accounts</string>
<string name="add_account">Add Account</string>
<string name="edit_account">Edit Account</string>
<string name="delete_account">Delete Account</string>
<string name="account_username">Username (JID)</string>
<string name="account_password">Password</string>
<string name="account_server">Server</string>
<string name="account_port">Port</string>
<string name="account_use_tls">Use TLS</string>
<string name="account_status_online">Online</string>
<string name="account_status_offline">Offline</string>
<string name="account_status_connecting">Connecting…</string>
<string name="account_status_error">Connection Error</string>
<string name="account_connect">Connect</string>
<string name="account_disconnect">Disconnect</string>
<string name="account_no_accounts">No accounts configured.\nTap + to add one.</string>
<string name="account_delete_confirm">Delete account %1$s?</string>
<string name="account_jid_hint">user@example.com</string>
<string name="account_resource">Resource</string>
<string name="account_resource_hint">AleJabber</string>
<!-- Contacts -->
<string name="contacts_title">Contacts</string>
<string name="contacts_search">Search contacts…</string>
<string name="contacts_empty">No contacts yet.\nAdd people using their Jabber ID.</string>
<string name="add_contact">Add Contact</string>
<string name="contact_jid">Jabber ID (JID)</string>
<string name="contact_nickname">Nickname</string>
<string name="contact_remove">Remove Contact</string>
<string name="contact_block">Block Contact</string>
<string name="contact_status_online">Online</string>
<string name="contact_status_away">Away</string>
<string name="contact_status_dnd">Do Not Disturb</string>
<string name="contact_status_offline">Offline</string>
<!-- Rooms / MUC -->
<string name="rooms_title">Rooms</string>
<string name="rooms_search">Search rooms…</string>
<string name="rooms_empty">No rooms joined yet.</string>
<string name="join_room">Join Room</string>
<string name="room_jid">Room JID</string>
<string name="room_nickname">Your Nickname</string>
<string name="room_password">Room Password (optional)</string>
<string name="leave_room">Leave Room</string>
<string name="room_participants">Participants</string>
<string name="room_topic">Topic</string>
<string name="browse_rooms">Browse Rooms</string>
<!-- Chat -->
<string name="chat_hint">Type a message…</string>
<string name="chat_send">Send</string>
<string name="chat_attach">Attach file</string>
<string name="chat_record_audio">Record audio</string>
<string name="chat_stop_recording">Stop recording</string>
<string name="chat_send_audio">Send audio</string>
<string name="chat_cancel_audio">Cancel audio</string>
<string name="chat_encryption_none">No encryption</string>
<string name="chat_encryption_otr">OTR</string>
<string name="chat_encryption_omemo">OMEMO</string>
<string name="chat_encryption_pgp">OpenPGP</string>
<string name="chat_encryption_select">Select encryption</string>
<string name="chat_message_delivered">Delivered</string>
<string name="chat_message_read">Read</string>
<string name="chat_message_sending">Sending…</string>
<string name="chat_message_failed">Failed to send</string>
<string name="chat_typing">%1$s is typing…</string>
<string name="chat_media_image">Image</string>
<string name="chat_media_video">Video</string>
<string name="chat_media_audio">Audio</string>
<string name="chat_media_file">File</string>
<string name="chat_media_uploading">Uploading…</string>
<string name="chat_media_download">Download</string>
<string name="chat_empty">No messages yet.\nSay hello!</string>
<string name="chat_otr_started">OTR session started. Your conversation is now encrypted.</string>
<string name="chat_otr_ended">OTR session ended.</string>
<string name="chat_otr_untrusted">Warning: OTR fingerprint not verified.</string>
<string name="chat_omemo_trusted">OMEMO: All devices trusted.</string>
<string name="chat_omemo_untrusted">OMEMO: Untrusted devices detected.</string>
<!-- Settings -->
<string name="settings_title">Settings</string>
<string name="settings_appearance">Appearance</string>
<string name="settings_theme">Theme</string>
<string name="settings_theme_system">System Default</string>
<string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</string>
<string name="settings_language">Language</string>
<string name="settings_language_en">English</string>
<string name="settings_language_es">Español</string>
<string name="settings_language_zh">中文</string>
<string name="settings_notifications">Notifications</string>
<string name="settings_notifications_messages">Message notifications</string>
<string name="settings_notifications_vibrate">Vibrate</string>
<string name="settings_notifications_sound">Sound</string>
<string name="settings_encryption">Encryption</string>
<string name="settings_omemo_devices">OMEMO Devices</string>
<string name="settings_pgp_keys">OpenPGP Keys</string>
<string name="settings_otr_fingerprints">OTR Fingerprints</string>
<string name="settings_default_encryption">Default encryption</string>
<string name="settings_about">About</string>
<string name="settings_version">Version</string>
<!-- Common -->
<string name="ok">OK</string>
<string name="cancel">Cancel</string>
<string name="save">Save</string>
<string name="delete">Delete</string>
<string name="confirm">Confirm</string>
<string name="error">Error</string>
<string name="loading">Loading…</string>
<string name="retry">Retry</string>
<string name="close">Close</string>
<string name="search">Search</string>
<string name="clear">Clear</string>
<string name="back">Back</string>
<string name="more_options">More options</string>
<!-- Permissions -->
<string name="permission_microphone_title">Microphone Permission</string>
<string name="permission_microphone_message">AleJabber needs microphone access to record audio messages.</string>
<string name="permission_storage_title">Storage Permission</string>
<string name="permission_storage_message">AleJabber needs storage access to send and receive files.</string>
<string name="permission_camera_title">Camera Permission</string>
<string name="permission_camera_message">AleJabber needs camera access to take photos.</string>
<string name="permission_denied">Permission denied. Please grant it in Settings.</string>
<string name="open_settings">Open Settings</string>
<!-- Notifications -->
<string name="notification_channel_messages">Messages</string>
<string name="notification_channel_messages_desc">Incoming chat messages</string>
<string name="notification_channel_service">XMPP Service</string>
<string name="notification_channel_service_desc">Background XMPP connection</string>
<string name="notification_service_running">AleJabber is connected</string>
<string name="notification_new_message">New message from %1$s</string>
<!-- Accessibility -->
<string name="cd_avatar">%1$s\'s avatar</string>
<string name="cd_status_indicator">Status: %1$s</string>
<string name="cd_encryption_badge">Encryption: %1$s</string>
<string name="cd_send_button">Send message</string>
<string name="cd_attach_button">Attach file</string>
<string name="cd_record_button">Record audio message</string>
<string name="cd_message_delivered">Message delivered</string>
<string name="cd_message_read">Message read</string>
</resources>

Ver fichero

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

Ver fichero

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

Ver fichero

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

Ver fichero

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="shared_files" path="." />
<external-cache-path name="external_cache" path="." />
<files-path name="files" path="." />
</paths>

Ver fichero

@@ -0,0 +1,17 @@
package com.manalejandro.alejabber
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}