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