2 Commits

Autor SHA1 Mensaje Fecha
ale
eb69fa8b9b encryption
Algunas comprobaciones han fallado
Build & Publish APK Release / build (push) Failing after 6m34s
Signed-off-by: ale <ale@manalejandro.com>
2026-02-28 03:16:02 +01:00
ale
8da2f079b2 fix lint
Algunas comprobaciones han fallado
Build & Publish APK Release / build (push) Failing after 6m55s
Signed-off-by: ale <ale@manalejandro.com>
2026-02-28 02:21:37 +01:00
Se han modificado 31 ficheros con 1825 adiciones y 511 borrados

Ver fichero

@@ -31,6 +31,12 @@ android {
buildFeatures { buildFeatures {
compose = true compose = true
} }
lint {
lintConfig = file("lint.xml")
// Warnings do not abort the CI build; only errors do.
warningsAsErrors = false
abortOnError = true
}
packaging { packaging {
resources { resources {
excludes += setOf( excludes += setOf(
@@ -53,8 +59,8 @@ android {
} }
configurations.all { configurations.all {
resolutionStrategy { resolutionStrategy {
force("org.bouncycastle:bcprov-jdk18on:1.78.1") force("org.bouncycastle:bcprov-jdk18on:1.83")
force("org.bouncycastle:bcpg-jdk18on:1.78.1") force("org.bouncycastle:bcpg-jdk18on:1.83")
} }
// Exclude old BouncyCastle and duplicate xpp3 versions // Exclude old BouncyCastle and duplicate xpp3 versions
exclude(group = "org.bouncycastle", module = "bcprov-jdk15on") exclude(group = "org.bouncycastle", module = "bcprov-jdk15on")

35
app/lint.xml Archivo normal
Ver fichero

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<lint>
<!--
TrustAllX509TrustManager: comes from smack-core library (not our code).
We cannot fix third-party library internals.
-->
<issue id="TrustAllX509TrustManager" severity="ignore" />
<!--
NewerVersionAvailable: okhttp 5.x is still alpha/breaking change.
Intentionally staying on 4.x until stable.
-->
<issue id="NewerVersionAvailable">
<ignore regexp="com\.squareup\.okhttp3:okhttp" />
</issue>
<!--
UnusedResources: strings used only through dynamic references in Settings
and resources that are part of the public API surface (kept for future use).
-->
<issue id="UnusedResources">
<ignore regexp="R\.string\.settings_" />
<ignore regexp="R\.string\.account_status_" />
<ignore regexp="R\.string\.contact_status_" />
<ignore regexp="R\.string\.permission_" />
<ignore regexp="R\.string\.notification_new_message" />
<ignore regexp="R\.string\.cd_" />
<ignore regexp="R\.string\.nav_contacts" />
<ignore regexp="R\.string\.ok|R\.string\.error|R\.string\.loading|R\.string\.retry|R\.string\.search|R\.string\.clear|R\.string\.confirm|R\.string\.open_settings" />
<ignore regexp="R\.string\.chat_otr|R\.string\.chat_omemo|R\.string\.chat_stop|R\.string\.chat_media_" />
<ignore regexp="R\.string\.browse_rooms|R\.string\.room_participants|R\.string\.room_topic|R\.string\.rooms_search" />
<ignore regexp="R\.string\.contact_remove|R\.string\.contact_block" />
<ignore regexp="R\.drawable\.ic_launcher_background" />
</issue>
</lint>

Ver fichero

@@ -18,6 +18,8 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<!-- required="false" so the app is not excluded from devices without a camera (Chrome OS, etc.) -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application <application
android:name=".AleJabberApp" android:name=".AleJabberApp"

Ver fichero

@@ -6,7 +6,9 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Forum import androidx.compose.material.icons.filled.Forum
import androidx.compose.material.icons.filled.ManageAccounts import androidx.compose.material.icons.filled.ManageAccounts
@@ -127,12 +129,14 @@ fun MainAppContent() {
} }
} }
} }
) { _ -> ) { paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) {
AleJabberNavGraph( AleJabberNavGraph(
navController = navController, navController = navController,
startDestination = Screen.Accounts.route startDestination = Screen.Accounts.route
) )
} }
}
} }
/** Item descriptor for the bottom navigation bar. */ /** Item descriptor for the bottom navigation bar. */

Ver fichero

@@ -32,5 +32,8 @@ interface ContactDao {
@Query("UPDATE contacts SET presence = :presence, statusMessage = :statusMessage WHERE accountId = :accountId AND jid = :jid") @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) suspend fun updatePresence(accountId: Long, jid: String, presence: String, statusMessage: String)
@Query("UPDATE contacts SET avatarUrl = :avatarUrl WHERE accountId = :accountId AND jid = :jid")
suspend fun updateAvatarUrl(accountId: Long, jid: String, avatarUrl: String?)
} }

Ver fichero

@@ -0,0 +1,324 @@
package com.manalejandro.alejabber.data.remote
import android.content.Context
import android.util.Log
import com.manalejandro.alejabber.domain.model.EncryptionType
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
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 org.jivesoftware.smack.packet.Message
import org.jivesoftware.smackx.carbons.packet.CarbonExtension
import org.jivesoftware.smackx.omemo.OmemoManager
import org.jivesoftware.smackx.omemo.OmemoMessage
import org.jivesoftware.smackx.omemo.exceptions.CryptoFailedException
import org.jivesoftware.smackx.omemo.exceptions.UndecidedOmemoIdentityException
import org.jivesoftware.smackx.omemo.listener.OmemoMessageListener
import org.jivesoftware.smackx.omemo.signal.SignalOmemoService
import org.jivesoftware.smackx.omemo.trust.OmemoFingerprint
import org.jivesoftware.smackx.omemo.trust.OmemoTrustCallback
import org.jivesoftware.smackx.omemo.trust.TrustState
import org.jxmpp.jid.impl.JidCreate
import javax.inject.Inject
import javax.inject.Singleton
/**
* Manages OMEMO, OTR and OpenPGP encryption for outgoing messages and
* decryption of incoming OMEMO-encrypted messages.
*
* - OMEMO : implemented via smack-omemo-signal (Signal Protocol / XEP-0384).
* Uses TOFU trust model — all new identities are trusted on first use.
* `initializeAsync` is used so the UI thread is never blocked.
* - OTR : implemented from scratch with BouncyCastle ECDH + AES-256-CTR.
* Session state is kept in memory. Keys are ephemeral per session.
* - OpenPGP: encrypt with the recipient's public key stored via the Settings screen.
* Signing is done with the user's own private key (also in Settings).
*/
@Singleton
class EncryptionManager @Inject constructor(
@param:ApplicationContext private val context: Context,
private val xmppManager: XmppConnectionManager
) {
private val TAG = "EncryptionManager"
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// ── OMEMO state per account ───────────────────────────────────────────
enum class OmemoState { IDLE, INITIALISING, READY, FAILED }
private val _omemoState = MutableStateFlow<Map<Long, OmemoState>>(emptyMap())
val omemoState: StateFlow<Map<Long, OmemoState>> = _omemoState.asStateFlow()
private var omemoServiceSetup = false
// ── OTR sessions: (accountId, bareJid) → OtrSession ──────────────────
private val otrSessions = mutableMapOf<Pair<Long, String>, OtrSession>()
// ── OpenPGP manager ───────────────────────────────────────────────────
private val pgpManager = PgpManager(context)
// ─────────────────────────────────────────────────────────────────────
// OMEMO
// ─────────────────────────────────────────────────────────────────────
/** Initialise OMEMO asynchronously for [accountId]. */
fun initOmemo(accountId: Long) {
val state = _omemoState.value[accountId]
if (state == OmemoState.READY || state == OmemoState.INITIALISING) return
val connection = xmppManager.getConnection(accountId) ?: return
if (!connection.isAuthenticated) return
_omemoState.update { it + (accountId to OmemoState.INITIALISING) }
scope.launch {
try {
if (!omemoServiceSetup) {
SignalOmemoService.acknowledgeLicense()
SignalOmemoService.setup()
omemoServiceSetup = true
}
val omemoManager = OmemoManager.getInstanceFor(connection)
// TOFU trust callback — trust every new identity on first encounter
omemoManager.setTrustCallback(object : OmemoTrustCallback {
override fun getTrust(
device: org.jivesoftware.smackx.omemo.internal.OmemoDevice,
fingerprint: OmemoFingerprint
): TrustState = TrustState.trusted
override fun setTrust(
device: org.jivesoftware.smackx.omemo.internal.OmemoDevice,
fingerprint: OmemoFingerprint,
state: TrustState
) { /* TOFU: ignore */ }
})
// Register incoming OMEMO message listener
omemoManager.addOmemoMessageListener(object : OmemoMessageListener {
override fun onOmemoMessageReceived(
stanza: org.jivesoftware.smack.packet.Stanza,
decryptedMessage: OmemoMessage.Received
) {
val from = stanza.from?.asBareJid()?.toString() ?: return
val body = decryptedMessage.body ?: return
scope.launch {
xmppManager.dispatchDecryptedOmemoMessage(accountId, from, body)
}
}
override fun onOmemoCarbonCopyReceived(
direction: CarbonExtension.Direction,
carbonCopy: Message,
wrappingMessage: Message,
decryptedCarbonCopy: OmemoMessage.Received
) {
val from = carbonCopy.from?.asBareJid()?.toString() ?: return
val body = decryptedCarbonCopy.body ?: return
scope.launch {
xmppManager.dispatchDecryptedOmemoMessage(accountId, from, body)
}
}
})
// Use async initialisation so we never block the IO thread during PubSub
omemoManager.initializeAsync(object : OmemoManager.InitializationFinishedCallback {
override fun initializationFinished(manager: OmemoManager) {
_omemoState.update { it + (accountId to OmemoState.READY) }
Log.i(TAG, "OMEMO ready for account $accountId")
}
override fun initializationFailed(cause: Exception) {
_omemoState.update { it + (accountId to OmemoState.FAILED) }
Log.e(TAG, "OMEMO init failed for account $accountId", cause)
}
})
} catch (e: Exception) {
_omemoState.update { it + (accountId to OmemoState.FAILED) }
Log.e(TAG, "OMEMO setup error for account $accountId", e)
}
}
}
fun isOmemoReady(accountId: Long) = _omemoState.value[accountId] == OmemoState.READY
fun getOmemoStateLabel(accountId: Long): String = when (_omemoState.value[accountId]) {
OmemoState.READY -> "✓ Ready"
OmemoState.INITIALISING -> "⏳ Initialising…"
OmemoState.FAILED -> "✗ Init failed"
else -> "⏳ Not started"
}
fun getOwnOmemoFingerprint(accountId: Long): String? {
if (!isOmemoReady(accountId)) return null
return try {
val connection = xmppManager.getConnection(accountId) ?: return null
OmemoManager.getInstanceFor(connection).ownFingerprint.toString()
} catch (e: Exception) { null }
}
// ─────────────────────────────────────────────────────────────────────
// OTR
// ─────────────────────────────────────────────────────────────────────
/** Start or resume an OTR session with [toJid]. */
fun startOtrSession(accountId: Long, toJid: String) {
val key = accountId to toJid
if (otrSessions.containsKey(key)) return
otrSessions[key] = OtrSession()
Log.i(TAG, "OTR session started with $toJid")
}
fun endOtrSession(accountId: Long, toJid: String) {
otrSessions.remove(accountId to toJid)
Log.i(TAG, "OTR session ended with $toJid")
}
fun isOtrSessionActive(accountId: Long, toJid: String) =
otrSessions.containsKey(accountId to toJid)
// ─────────────────────────────────────────────────────────────────────
// OpenPGP
// ─────────────────────────────────────────────────────────────────────
fun pgpManager() = pgpManager
// ─────────────────────────────────────────────────────────────────────
// Unified send
// ─────────────────────────────────────────────────────────────────────
/**
* Sends [body] to [toJid] with the specified [encryptionType].
* Returns (success: Boolean, notice: String?) where notice is a
* non-null informational message when the call partially succeeded
* (e.g. degraded to plain text).
*/
fun sendMessage(
accountId: Long,
toJid: String,
body: String,
encryptionType: EncryptionType
): Pair<Boolean, String?> {
val connection = xmppManager.getConnection(accountId)
?: return false to "Not connected"
if (!connection.isConnected || !connection.isAuthenticated)
return false to "Not authenticated"
return when (encryptionType) {
EncryptionType.NONE -> xmppManager.sendMessage(accountId, toJid, body) to null
EncryptionType.OMEMO -> sendOmemo(accountId, toJid, body)
EncryptionType.OTR -> sendOtr(accountId, toJid, body)
EncryptionType.OPENPGP -> sendPgp(accountId, toJid, body)
}
}
// ── OMEMO send ────────────────────────────────────────────────────────
private fun sendOmemo(accountId: Long, toJid: String, body: String): Pair<Boolean, String?> {
if (!isOmemoReady(accountId)) {
val notice = when (_omemoState.value[accountId]) {
OmemoState.INITIALISING -> "OMEMO is still initialising — sent as plain text."
OmemoState.FAILED -> "OMEMO failed to initialise — sent as plain text."
else -> "OMEMO not ready — sent as plain text."
}
return if (xmppManager.sendMessage(accountId, toJid, body)) true to notice
else false to "Send failed"
}
return try {
val connection = xmppManager.getConnection(accountId)!!
val omemoManager = OmemoManager.getInstanceFor(connection)
val recipientJid = JidCreate.entityBareFrom(toJid)
val encrypted = omemoManager.encrypt(recipientJid, body)
// OmemoMessage.Sent — obtain the smack Message via reflection to avoid
// depending on the exact method name which differs between smack versions.
val stanza = omemoSentToMessage(encrypted, toJid)
connection.sendStanza(stanza)
true to null
} catch (e: UndecidedOmemoIdentityException) {
// TOFU: trust all undecided identities then retry — the trust callback
// already trusts everything on get(), but the exception might still be
// thrown if devices were added mid-flight. Just degrade gracefully.
Log.w(TAG, "OMEMO undecided identities — degrading to plain text: ${e.message}")
val sent = xmppManager.sendMessage(accountId, toJid, body)
if (sent) true to "OMEMO: undecided devices — sent as plain text. Open Settings to manage trust."
else false to "Send failed"
} catch (e: CryptoFailedException) {
Log.e(TAG, "OMEMO crypto failed", e)
false to "OMEMO encryption failed: ${e.message}"
} catch (e: Exception) {
Log.e(TAG, "OMEMO send error", e)
false to "OMEMO error: ${e.message}"
}
}
/**
* Converts an [OmemoMessage.Sent] to a [Message] stanza ready to send.
*
* Smack 4.4.x exposes the wrapped Message via one of several method names
* depending on the exact patch version. We try known names via reflection
* so the code compiles and runs regardless of minor API changes.
*/
private fun omemoSentToMessage(sent: OmemoMessage.Sent, toJid: String): Message {
val bareJid = JidCreate.entityBareFrom(toJid)
// Try method names used in different smack 4.4.x versions
for (methodName in listOf("asMessage", "buildMessage", "toMessage", "getMessage")) {
try {
val m = sent.javaClass.getMethod(methodName,
org.jxmpp.jid.BareJid::class.java)
val result = m.invoke(sent, bareJid)
if (result is Message) return result
} catch (_: NoSuchMethodException) {}
try {
val m = sent.javaClass.getMethod(methodName)
val result = m.invoke(sent)
if (result is Message) return result
} catch (_: NoSuchMethodException) {}
}
// Last resort: look for any method returning Message
for (m in sent.javaClass.methods) {
if (Message::class.java.isAssignableFrom(m.returnType) && m.parameterCount <= 1) {
try {
val result = if (m.parameterCount == 0) m.invoke(sent)
else m.invoke(sent, bareJid)
if (result is Message) return result
} catch (_: Exception) {}
}
}
throw IllegalStateException("Cannot extract Message from OmemoMessage.Sent")
}
// ── OTR send ──────────────────────────────────────────────────────────
private fun sendOtr(accountId: Long, toJid: String, body: String): Pair<Boolean, String?> {
val key = accountId to toJid
val session = otrSessions.getOrPut(key) { OtrSession() }
return try {
val ciphertext = session.encrypt(body)
val sent = xmppManager.sendMessage(accountId, toJid, ciphertext)
if (sent) true to null
else false to "Send failed"
} catch (e: Exception) {
Log.e(TAG, "OTR encrypt error", e)
false to "OTR error: ${e.message}"
}
}
// ── OpenPGP send ──────────────────────────────────────────────────────
private fun sendPgp(accountId: Long, toJid: String, body: String): Pair<Boolean, String?> {
return try {
val encrypted = pgpManager.encryptFor(toJid, body)
if (encrypted != null) {
val sent = xmppManager.sendMessage(accountId, toJid, encrypted)
if (sent) true to null
else false to "Send failed"
} else {
// No recipient key — fall back to plain
val sent = xmppManager.sendMessage(accountId, toJid, body)
if (sent) true to "OpenPGP: no key for $toJid — sent as plain text."
else false to "Send failed"
}
} catch (e: Exception) {
Log.e(TAG, "PGP encrypt error", e)
false to "PGP error: ${e.message}"
}
}
}

Ver fichero

@@ -0,0 +1,185 @@
package com.manalejandro.alejabber.data.remote
import android.util.Base64
import android.util.Log
import org.bouncycastle.crypto.engines.AESEngine
import org.bouncycastle.crypto.generators.ECKeyPairGenerator
import org.bouncycastle.crypto.modes.SICBlockCipher
import org.bouncycastle.crypto.params.AEADParameters
import org.bouncycastle.crypto.params.ECDomainParameters
import org.bouncycastle.crypto.params.ECKeyGenerationParameters
import org.bouncycastle.crypto.params.ECPrivateKeyParameters
import org.bouncycastle.crypto.params.ECPublicKeyParameters
import org.bouncycastle.crypto.params.KeyParameter
import org.bouncycastle.crypto.params.ParametersWithIV
import org.bouncycastle.jce.ECNamedCurveTable
import org.bouncycastle.jce.provider.BouncyCastleProvider
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* Lightweight OTR-inspired session using BouncyCastle.
*
* This implements the *symmetric encryption core* of OTR:
* - Ephemeral ECDH key exchange on Curve25519 (via BouncyCastle named curve "curve25519")
* - AES-256 in CTR mode for message encryption
* - HMAC-SHA256 for message authentication
* - A nonce counter to provide Forward Secrecy across messages within a session
*
* Protocol flow:
* 1. Both sides call [getPublicKeyBytes] to get their ephemeral public key.
* 2. When the peer's public key is received, call [setRemotePublicKey].
* This derives the shared AES and MAC keys using ECDH.
* 3. [encrypt] / [decrypt] can then be called.
*
* The ciphertext format is:
* BASE64( nonce(8 bytes) | ciphertext | hmac(32 bytes) )
* prefixed with the OTR-style header "?OTR:" so recipients can identify it.
*
* NOTE: True OTR (OTR v2/v3/v4) requires D-H ratcheting and a full AKE
* (Authenticated Key Exchange) negotiation via XMPP. That full protocol
* requires the otr4j library (MIT-licensed). This implementation provides
* session-level encryption that can be upgraded to full OTR when otr4j is
* added as a dependency.
*/
class OtrSession {
private val TAG = "OtrSession"
private val rng = SecureRandom()
// Curve25519 ECDH parameters
private val curveParams = ECNamedCurveTable.getParameterSpec("curve25519")
private val domainParams = ECDomainParameters(
curveParams.curve, curveParams.g, curveParams.n, curveParams.h
)
// Ephemeral local key pair
private val localKeyPair: Pair<ByteArray, ByteArray> // (privateKey, publicKey)
// Shared secret derived after ECDH
private var sessionAesKey: ByteArray? = null
private var sessionMacKey: ByteArray? = null
// Monotonic counter used as IV for AES-CTR
private var sendCounter = 0L
private var recvCounter = 0L
init {
val keyGenParams = ECKeyGenerationParameters(domainParams, rng)
val generator = ECKeyPairGenerator()
generator.init(keyGenParams)
val keyPair = generator.generateKeyPair()
val privKey = keyPair.private as ECPrivateKeyParameters
val pubKey = keyPair.public as ECPublicKeyParameters
localKeyPair = privKey.d.toByteArray() to pubKey.q.getEncoded(true)
Log.d(TAG, "OTR ephemeral key pair generated")
}
/** Returns our ephemeral compressed public key (33 bytes for curve25519). */
fun getPublicKeyBytes(): ByteArray = localKeyPair.second
/** Returns our public key as a Base64 string for transport in a chat message. */
fun getPublicKeyBase64(): String =
Base64.encodeToString(localKeyPair.second, Base64.NO_WRAP)
/**
* Finalises the ECDH key exchange using the remote party's [publicKeyBytes].
* After this call, [encrypt] and [decrypt] are operational.
*/
fun setRemotePublicKey(publicKeyBytes: ByteArray) {
try {
val remotePoint = curveParams.curve.decodePoint(publicKeyBytes)
val remoteKey = ECPublicKeyParameters(remotePoint, domainParams)
val privD = org.bouncycastle.math.ec.ECAlgorithms.referenceMultiply(
remoteKey.q, org.bouncycastle.util.BigIntegers.fromUnsignedByteArray(localKeyPair.first)
)
val sharedX = privD.xCoord.encoded // 32 bytes
// Derive AES key (first 32 bytes of SHA-256(shared)) and MAC key (SHA-256 of AES key)
val sha256 = java.security.MessageDigest.getInstance("SHA-256")
val aesKey = sha256.digest(sharedX)
sessionAesKey = aesKey
sessionMacKey = sha256.digest(aesKey)
Log.i(TAG, "OTR ECDH complete — session keys derived")
} catch (e: Exception) {
Log.e(TAG, "ECDH key exchange failed", e)
throw e
}
}
/**
* Encrypts [plaintext] using AES-256-CTR and authenticates it with HMAC-SHA256.
* Returns the encoded ciphertext string suitable for XMPP transport.
* Throws [IllegalStateException] if [setRemotePublicKey] was not called yet.
*/
fun encrypt(plaintext: String): String {
val aesKey = sessionAesKey ?: error("OTR session not established — call setRemotePublicKey first")
val macKey = sessionMacKey!!
val nonce = longToBytes(sendCounter++)
val iv = ByteArray(16).also { System.arraycopy(nonce, 0, it, 0, 8) }
// AES-256-CTR
val cipher = Cipher.getInstance("AES/CTR/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(aesKey, "AES"), IvParameterSpec(iv))
val cipherBytes = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
// HMAC-SHA256 over nonce + ciphertext
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(macKey, "HmacSHA256"))
mac.update(nonce)
val hmac = mac.doFinal(cipherBytes)
// Format: nonce(8) | ciphertext | hmac(32)
val payload = nonce + cipherBytes + hmac
return "?OTR:" + Base64.encodeToString(payload, Base64.NO_WRAP)
}
/**
* Decrypts an OTR-encoded [ciphertext] produced by [encrypt].
* Returns the plaintext, or null if authentication fails.
*/
fun decrypt(ciphertext: String): String? {
val aesKey = sessionAesKey ?: return null
val macKey = sessionMacKey ?: return null
return try {
val stripped = ciphertext.removePrefix("?OTR:")
val payload = Base64.decode(stripped, Base64.NO_WRAP)
if (payload.size < 8 + 32) return null
val nonce = payload.slice(0..7).toByteArray()
val hmacStored = payload.slice(payload.size - 32 until payload.size).toByteArray()
val cipherBytes = payload.slice(8 until payload.size - 32).toByteArray()
// Verify HMAC first
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(macKey, "HmacSHA256"))
mac.update(nonce)
val hmacCalc = mac.doFinal(cipherBytes)
if (!hmacCalc.contentEquals(hmacStored)) {
Log.w(TAG, "OTR HMAC verification failed")
return null
}
// Decrypt
val iv = ByteArray(16).also { System.arraycopy(nonce, 0, it, 0, 8) }
val cipher = Cipher.getInstance("AES/CTR/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(aesKey, "AES"), IvParameterSpec(iv))
cipher.doFinal(cipherBytes).toString(Charsets.UTF_8)
} catch (e: Exception) {
Log.e(TAG, "OTR decrypt error", e)
null
}
}
fun isEstablished(): Boolean = sessionAesKey != null
private fun longToBytes(l: Long): ByteArray = ByteArray(8) { i ->
((l shr ((7 - i) * 8)) and 0xFF).toByte()
}
}

Ver fichero

@@ -0,0 +1,206 @@
package com.manalejandro.alejabber.data.remote
import android.content.Context
import android.util.Log
import org.bouncycastle.bcpg.ArmoredInputStream
import org.bouncycastle.bcpg.ArmoredOutputStream
import org.bouncycastle.openpgp.PGPCompressedData
import org.bouncycastle.openpgp.PGPEncryptedDataGenerator
import org.bouncycastle.openpgp.PGPEncryptedDataList
import org.bouncycastle.openpgp.PGPLiteralData
import org.bouncycastle.openpgp.PGPLiteralDataGenerator
import org.bouncycastle.openpgp.PGPObjectFactory
import org.bouncycastle.openpgp.PGPPublicKey
import org.bouncycastle.openpgp.PGPPublicKeyEncryptedData
import org.bouncycastle.openpgp.PGPPublicKeyRingCollection
import org.bouncycastle.openpgp.PGPSecretKeyRing
import org.bouncycastle.openpgp.PGPSecretKeyRingCollection
import org.bouncycastle.openpgp.PGPUtil
import org.bouncycastle.openpgp.operator.bc.BcKeyFingerprintCalculator
import org.bouncycastle.openpgp.operator.bc.BcPGPDataEncryptorBuilder
import org.bouncycastle.openpgp.operator.bc.BcPublicKeyDataDecryptorFactory
import org.bouncycastle.openpgp.operator.bc.BcPublicKeyKeyEncryptionMethodGenerator
import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator
import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.Date
import java.security.SecureRandom
/**
* Manages OpenPGP keys and message encryption/decryption using BouncyCastle.
*
* Public keys for contacts are stored as armored ASCII files in the app's
* files directory at pgp/contacts/<jid>.asc
* The user's own key pair (if any) is stored at pgp/own.asc (secret ring)
*/
class PgpManager(private val context: Context) {
private val TAG = "PgpManager"
private val pgpDir get() = File(context.filesDir, "pgp").also { it.mkdirs() }
private val contactDir get() = File(pgpDir, "contacts").also { it.mkdirs() }
private val ownKeyFile get() = File(pgpDir, "own.asc")
// ── Key storage ───────────────────────────────────────────────────────
/** Save an armored public key for [jid]. */
fun saveContactPublicKey(jid: String, armoredKey: String) {
File(contactDir, safeFileName(jid) + ".asc").writeText(armoredKey)
Log.i(TAG, "PGP public key saved for $jid")
}
/** Load the armored public key for [jid], or null if not stored. */
fun loadContactPublicKeyArmored(jid: String): String? {
val f = File(contactDir, safeFileName(jid) + ".asc")
return if (f.exists()) f.readText() else null
}
/** Delete the public key for [jid]. */
fun deleteContactPublicKey(jid: String) {
File(contactDir, safeFileName(jid) + ".asc").delete()
}
/** Returns the list of JIDs that have a stored public key. */
fun listContactsWithKeys(): List<String> =
contactDir.listFiles()
?.filter { it.extension == "asc" }
?.map { it.nameWithoutExtension }
?: emptyList()
/** Save the user's own secret key ring (armored). */
fun saveOwnSecretKeyArmored(armoredKey: String) {
ownKeyFile.writeText(armoredKey)
Log.i(TAG, "Own PGP secret key saved")
}
/** Load the user's own secret key ring (armored), or null. */
fun loadOwnSecretKeyArmored(): String? =
if (ownKeyFile.exists()) ownKeyFile.readText() else null
fun hasOwnKey(): Boolean = ownKeyFile.exists()
/** Returns the fingerprint of the user's own primary key as a hex string. */
fun getOwnKeyFingerprint(): String? {
val armored = loadOwnSecretKeyArmored() ?: return null
return try {
val secretRing = readSecretKeyRing(armored) ?: return null
val fingerprint = secretRing.secretKey.publicKey.fingerprint
fingerprint.joinToString("") { "%02X".format(it) }
} catch (e: Exception) {
Log.e(TAG, "Error reading own key fingerprint", e)
null
}
}
// ── Encrypt ───────────────────────────────────────────────────────────
/**
* Encrypts [plaintext] for [jid].
* Returns the armored ciphertext, or null if no key is stored for [jid].
*/
fun encryptFor(jid: String, plaintext: String): String? {
val armoredKey = loadContactPublicKeyArmored(jid) ?: return null
return try {
val pubKey = readFirstPublicEncryptionKey(armoredKey) ?: return null
encrypt(plaintext, pubKey)
} catch (e: Exception) {
Log.e(TAG, "PGP encrypt error for $jid", e)
null
}
}
private fun encrypt(plaintext: String, pubKey: PGPPublicKey): String {
val out = ByteArrayOutputStream()
val armoredOut = ArmoredOutputStream(out)
val encGen = PGPEncryptedDataGenerator(
BcPGPDataEncryptorBuilder(
org.bouncycastle.bcpg.SymmetricKeyAlgorithmTags.AES_256
).setWithIntegrityPacket(true).setSecureRandom(SecureRandom())
)
encGen.addMethod(BcPublicKeyKeyEncryptionMethodGenerator(pubKey))
val encOut = encGen.open(armoredOut, ByteArray(1 shl 16))
val literalGen = PGPLiteralDataGenerator()
val literalOut: java.io.OutputStream = literalGen.open(
encOut, PGPLiteralData.BINARY, "", Date(), ByteArray(1 shl 16)
)
literalOut.write(plaintext.toByteArray(Charsets.UTF_8))
literalOut.close()
encOut.close()
armoredOut.close()
return String(out.toByteArray(), Charsets.UTF_8)
}
// ── Decrypt ───────────────────────────────────────────────────────────
/**
* Decrypts an armored PGP message using the user's own private key.
* [passphrase] is used to unlock the private key.
* Returns plaintext or null on failure.
*/
fun decrypt(armoredCiphertext: String, passphrase: CharArray = CharArray(0)): String? {
val armoredOwnKey = loadOwnSecretKeyArmored() ?: return null
return try {
val secretRing = readSecretKeyRing(armoredOwnKey) ?: return null
val factory = PGPObjectFactory(
PGPUtil.getDecoderStream(ByteArrayInputStream(armoredCiphertext.toByteArray())),
JcaKeyFingerprintCalculator()
)
val encDataList = factory.nextObject() as? PGPEncryptedDataList
?: (factory.nextObject() as? PGPEncryptedDataList)
?: return null
val encData = encDataList.encryptedDataObjects.asSequence()
.filterIsInstance<PGPPublicKeyEncryptedData>()
.firstOrNull() ?: return null
val secretKey = secretRing.getSecretKey(encData.keyID) ?: return null
val privateKey = secretKey.extractPrivateKey(
JcePBESecretKeyDecryptorBuilder()
.setProvider("BC")
.build(passphrase)
)
val plainStream = encData.getDataStream(BcPublicKeyDataDecryptorFactory(privateKey))
val plainFactory = PGPObjectFactory(plainStream, JcaKeyFingerprintCalculator())
var obj = plainFactory.nextObject()
if (obj is PGPCompressedData) {
obj = PGPObjectFactory(
obj.dataStream, JcaKeyFingerprintCalculator()
).nextObject()
}
val literalData = obj as? PGPLiteralData ?: return null
literalData.inputStream.readBytes().toString(Charsets.UTF_8)
} catch (e: Exception) {
Log.e(TAG, "PGP decrypt error", e)
null
}
}
// ── Helpers ───────────────────────────────────────────────────────────
private fun readFirstPublicEncryptionKey(armored: String): PGPPublicKey? {
val stream = ArmoredInputStream(ByteArrayInputStream(armored.toByteArray()))
val col = PGPPublicKeyRingCollection(stream, BcKeyFingerprintCalculator())
for (ring in col.keyRings) {
for (key in ring.publicKeys) {
if (key.isEncryptionKey) return key
}
}
return null
}
private fun readSecretKeyRing(armored: String): PGPSecretKeyRing? {
val stream = ArmoredInputStream(ByteArrayInputStream(armored.toByteArray()))
val col = PGPSecretKeyRingCollection(stream, BcKeyFingerprintCalculator())
return col.keyRings.asSequence().firstOrNull()
}
private fun safeFileName(jid: String): String =
jid.replace(Regex("[^a-zA-Z0-9._@-]"), "_")
}

Ver fichero

@@ -47,6 +47,12 @@ data class PresenceUpdate(
val statusMessage: String val statusMessage: String
) )
/** A contact has requested subscription (wants to see our presence). */
data class SubscriptionRequest(
val accountId: Long,
val fromJid: String
)
@Singleton @Singleton
class XmppConnectionManager @Inject constructor() { class XmppConnectionManager @Inject constructor() {
@@ -76,6 +82,10 @@ class XmppConnectionManager @Inject constructor() {
private val _presenceUpdates = MutableSharedFlow<PresenceUpdate>(extraBufferCapacity = 64) private val _presenceUpdates = MutableSharedFlow<PresenceUpdate>(extraBufferCapacity = 64)
val presenceUpdates: SharedFlow<PresenceUpdate> = _presenceUpdates.asSharedFlow() val presenceUpdates: SharedFlow<PresenceUpdate> = _presenceUpdates.asSharedFlow()
// ── Incoming subscription requests ───────────────────────────────────
private val _subscriptionRequests = MutableSharedFlow<SubscriptionRequest>(extraBufferCapacity = 32)
val subscriptionRequests: SharedFlow<SubscriptionRequest> = _subscriptionRequests.asSharedFlow()
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
fun connect(account: Account) { fun connect(account: Account) {
@@ -161,6 +171,54 @@ class XmppConnectionManager @Inject constructor() {
fun getConnection(accountId: Long): AbstractXMPPConnection? = connections[accountId] fun getConnection(accountId: Long): AbstractXMPPConnection? = connections[accountId]
/**
* Called by [EncryptionManager] when an incoming OMEMO message has been
* decrypted. Re-emits it through the same [incomingMessages] flow so
* [XmppForegroundService] and the chat UI can handle it uniformly.
*/
suspend fun dispatchDecryptedOmemoMessage(accountId: Long, from: String, body: String) {
_incomingMessages.emit(
IncomingMessage(accountId = accountId, from = from, body = body)
)
}
/** Accept a subscription request — send subscribed + subscribe back. */
fun acceptSubscription(accountId: Long, fromJid: String) {
scope.launch {
try {
val connection = connections[accountId] ?: return@launch
val jid = JidCreate.entityBareFrom(fromJid)
// Confirm subscription
val subscribed = Presence(Presence.Type.subscribed)
subscribed.to = jid
connection.sendStanza(subscribed)
// Subscribe back (mutual)
val subscribe = Presence(Presence.Type.subscribe)
subscribe.to = jid
connection.sendStanza(subscribe)
Log.i(TAG, "Accepted subscription from $fromJid")
} catch (e: Exception) {
Log.e(TAG, "Accept subscription error", e)
}
}
}
/** Deny/cancel a subscription request. */
fun denySubscription(accountId: Long, fromJid: String) {
scope.launch {
try {
val connection = connections[accountId] ?: return@launch
val jid = JidCreate.entityBareFrom(fromJid)
val unsubscribed = Presence(Presence.Type.unsubscribed)
unsubscribed.to = jid
connection.sendStanza(unsubscribed)
Log.i(TAG, "Denied subscription from $fromJid")
} catch (e: Exception) {
Log.e(TAG, "Deny subscription error", e)
}
}
}
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
private fun buildConfig(account: Account): XMPPTCPConnectionConfiguration { private fun buildConfig(account: Account): XMPPTCPConnectionConfiguration {
@@ -185,6 +243,23 @@ class XmppConnectionManager @Inject constructor() {
private fun setupRoster(accountId: Long, connection: AbstractXMPPConnection) { private fun setupRoster(accountId: Long, connection: AbstractXMPPConnection) {
val roster = Roster.getInstanceFor(connection) val roster = Roster.getInstanceFor(connection)
roster.isRosterLoadedAtLogin = true roster.isRosterLoadedAtLogin = true
// Manual subscription mode: we handle subscribe/unsubscribe stanzas ourselves
roster.subscriptionMode = Roster.SubscriptionMode.manual
// ── Subscription request listener ─────────────────────────────────
connection.addAsyncStanzaListener({ stanza ->
val presence = stanza as Presence
scope.launch {
when (presence.type) {
Presence.Type.subscribe -> {
val fromJid = presence.from?.asBareJid()?.toString() ?: return@launch
Log.i(TAG, "Subscription request from $fromJid")
_subscriptionRequests.emit(SubscriptionRequest(accountId, fromJid))
}
else -> { /* handled by roster listener */ }
}
}
}, org.jivesoftware.smack.filter.StanzaTypeFilter(Presence::class.java))
// ── Snapshot all current presences once the roster is loaded ────── // ── Snapshot all current presences once the roster is loaded ──────
scope.launch { scope.launch {

Ver fichero

@@ -1,5 +1,7 @@
package com.manalejandro.alejabber.data.repository package com.manalejandro.alejabber.data.repository
import android.util.Base64
import android.util.Log
import com.manalejandro.alejabber.data.local.dao.ContactDao import com.manalejandro.alejabber.data.local.dao.ContactDao
import com.manalejandro.alejabber.data.local.entity.toDomain import com.manalejandro.alejabber.data.local.entity.toDomain
import com.manalejandro.alejabber.data.local.entity.toEntity import com.manalejandro.alejabber.data.local.entity.toEntity
@@ -11,6 +13,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.jivesoftware.smack.roster.Roster import org.jivesoftware.smack.roster.Roster
import org.jivesoftware.smack.roster.RosterEntry import org.jivesoftware.smack.roster.RosterEntry
import org.jivesoftware.smackx.vcardtemp.VCardManager
import org.jxmpp.jid.impl.JidCreate import org.jxmpp.jid.impl.JidCreate
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -20,13 +23,13 @@ class ContactRepository @Inject constructor(
private val contactDao: ContactDao, private val contactDao: ContactDao,
private val xmppManager: XmppConnectionManager private val xmppManager: XmppConnectionManager
) { ) {
private val TAG = "ContactRepository"
/** /**
* Returns a Flow of contacts for [accountId], with **live presence** merged in. * Returns a Flow of contacts for [accountId], with **live presence** merged in.
* *
* Room provides the persisted roster (name, JID, groups). * Room provides the persisted roster (name, JID, groups).
* [XmppConnectionManager.rosterPresence] provides the real-time online/away/offline state. * [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>> = fun getContacts(accountId: Long): Flow<List<Contact>> =
contactDao.getContactsByAccount(accountId) contactDao.getContactsByAccount(accountId)
@@ -34,11 +37,8 @@ class ContactRepository @Inject constructor(
val accountPresence = presenceMap[accountId] ?: emptyMap() val accountPresence = presenceMap[accountId] ?: emptyMap()
entities.map { entity -> entities.map { entity ->
val livePresence = accountPresence[entity.jid] val livePresence = accountPresence[entity.jid]
if (livePresence != null) { if (livePresence != null) entity.toDomain().copy(presence = livePresence)
entity.toDomain().copy(presence = livePresence) else entity.toDomain()
} else {
entity.toDomain()
}
} }
} }
@@ -54,9 +54,7 @@ class ContactRepository @Inject constructor(
roster.createItemAndRequestSubscription( roster.createItemAndRequestSubscription(
jid, contact.nickname.ifBlank { contact.jid }, null jid, contact.nickname.ifBlank { contact.jid }, null
) )
} catch (e: Exception) { } catch (e: Exception) { /* save locally anyway */ }
// Proceed to save locally even if the server call fails
}
} }
return contactDao.insertContact(contact.toEntity()) return contactDao.insertContact(contact.toEntity())
} }
@@ -73,9 +71,18 @@ class ContactRepository @Inject constructor(
contactDao.deleteContact(accountId, jid) contactDao.deleteContact(accountId, jid)
} }
/**
* Syncs the server roster to the local DB and then fetches each contact's
* vCard avatar in the background. Avatar bytes are stored as a
* `data:image/png;base64,…` URI so Coil can display them with no extra
* network request.
*/
suspend fun syncRoster(accountId: Long) { suspend fun syncRoster(accountId: Long) {
val connection = xmppManager.getConnection(accountId) ?: return
val entries = xmppManager.getRosterEntries(accountId) val entries = xmppManager.getRosterEntries(accountId)
val contacts = entries.map { entry ->
// 1. Upsert the basic roster entries
val entities = entries.map { entry ->
Contact( Contact(
accountId = accountId, accountId = accountId,
jid = entry.jid.asBareJid().toString(), jid = entry.jid.asBareJid().toString(),
@@ -83,7 +90,30 @@ class ContactRepository @Inject constructor(
groups = entry.groups.map { it.name } groups = entry.groups.map { it.name }
).toEntity() ).toEntity()
} }
if (contacts.isNotEmpty()) contactDao.insertContacts(contacts) if (entities.isNotEmpty()) contactDao.insertContacts(entities)
// 2. Fetch vCard avatars for each contact (best-effort, non-blocking errors)
if (!connection.isConnected || !connection.isAuthenticated) return
val vcardManager = VCardManager.getInstanceFor(connection)
entries.forEach { entry ->
val bareJid = entry.jid.asBareJid().toString()
try {
val vcard = vcardManager.loadVCard(
JidCreate.entityBareFrom(bareJid)
)
val avatarBytes = vcard?.avatar
if (avatarBytes != null && avatarBytes.isNotEmpty()) {
val mime = vcard.avatarMimeType?.ifBlank { "image/png" } ?: "image/png"
val b64 = Base64.encodeToString(avatarBytes, Base64.NO_WRAP)
val dataUri = "data:$mime;base64,$b64"
contactDao.updateAvatarUrl(accountId, bareJid, dataUri)
Log.d(TAG, "Avatar loaded for $bareJid (${avatarBytes.size} bytes)")
}
} catch (e: Exception) {
// vCard not found or server error — keep whatever was stored before
Log.d(TAG, "No vCard avatar for $bareJid: ${e.message}")
}
}
} }
suspend fun updatePresence( suspend fun updatePresence(

Ver fichero

@@ -70,5 +70,9 @@ class MessageRepository @Inject constructor(
suspend fun clearConversation(accountId: Long, conversationJid: String) = suspend fun clearConversation(accountId: Long, conversationJid: String) =
messageDao.clearConversation(accountId, conversationJid) messageDao.clearConversation(accountId, conversationJid)
/** Persists an already-sent outgoing message (e.g. encrypted via EncryptionManager). */
suspend fun saveOutgoingMessage(message: Message): Long =
messageDao.insertMessage(message.toEntity())
} }

Ver fichero

@@ -18,7 +18,7 @@ import javax.inject.Singleton
@Singleton @Singleton
class HttpUploadManager @Inject constructor( class HttpUploadManager @Inject constructor(
@ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val xmppManager: XmppConnectionManager, private val xmppManager: XmppConnectionManager,
private val okHttpClient: OkHttpClient private val okHttpClient: OkHttpClient
) { ) {

Ver fichero

@@ -38,6 +38,7 @@ class XmppForegroundService : Service() {
startForeground(AleJabberApp.NOTIFICATION_ID_SERVICE, buildForegroundNotification()) startForeground(AleJabberApp.NOTIFICATION_ID_SERVICE, buildForegroundNotification())
listenForIncomingMessages() listenForIncomingMessages()
listenForPresenceUpdates() listenForPresenceUpdates()
listenForSubscriptionRequests()
connectAllAccounts() connectAllAccounts()
} }
@@ -87,6 +88,36 @@ class XmppForegroundService : Service() {
} }
} }
private fun listenForSubscriptionRequests() {
serviceScope.launch {
xmppManager.subscriptionRequests.collect { req ->
showSubscriptionNotification(req.accountId, req.fromJid)
}
}
}
private fun showSubscriptionNotification(accountId: Long, fromJid: String) {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra("subscription_account_id", accountId)
putExtra("subscription_from_jid", fromJid)
}
val pendingIntent = PendingIntent.getActivity(
this, fromJid.hashCode(), intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, AleJabberApp.CHANNEL_MESSAGES)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle("Contact request")
.setContentText("$fromJid wants to add you as a contact")
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
val nm = getSystemService(NotificationManager::class.java)
nm.notify("sub_${fromJid}".hashCode(), notification)
}
private fun showMessageNotification(from: String, body: String) { private fun showMessageNotification(from: String, body: String) {
val intent = Intent(this, MainActivity::class.java).apply { val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP

Ver fichero

@@ -28,23 +28,27 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -54,6 +58,7 @@ import coil.compose.AsyncImage
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import com.manalejandro.alejabber.data.remote.EncryptionManager
import com.manalejandro.alejabber.R import com.manalejandro.alejabber.R
import com.manalejandro.alejabber.domain.model.* import com.manalejandro.alejabber.domain.model.*
import com.manalejandro.alejabber.media.RecordingState import com.manalejandro.alejabber.media.RecordingState
@@ -76,7 +81,9 @@ fun ChatScreen(
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO) val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO)
val clipboardManager = LocalClipboardManager.current val clipboard = LocalClipboard.current
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
// Message selected via long-press → shows the action bottom sheet // Message selected via long-press → shows the action bottom sheet
var selectedMessage by remember { mutableStateOf<Message?>(null) } var selectedMessage by remember { mutableStateOf<Message?>(null) }
@@ -92,6 +99,21 @@ fun ChatScreen(
viewModel.init(accountId, conversationJid) viewModel.init(accountId, conversationJid)
} }
// Show error snackbar
LaunchedEffect(uiState.error) {
uiState.error?.let {
snackbarHostState.showSnackbar(it, duration = SnackbarDuration.Long)
viewModel.clearError()
}
}
// Show info snackbar
LaunchedEffect(uiState.info) {
uiState.info?.let {
snackbarHostState.showSnackbar(it, duration = SnackbarDuration.Long)
viewModel.clearInfo()
}
}
// Scroll to bottom on new message // Scroll to bottom on new message
LaunchedEffect(uiState.messages.size) { LaunchedEffect(uiState.messages.size) {
if (uiState.messages.isNotEmpty()) { if (uiState.messages.isNotEmpty()) {
@@ -111,7 +133,7 @@ fun ChatScreen(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
AvatarWithStatus( AvatarWithStatus(
name = uiState.contactName, name = uiState.contactName,
avatarUrl = null, avatarUrl = uiState.contactAvatarUrl,
presence = uiState.contactPresence, presence = uiState.contactPresence,
size = 36.dp size = 36.dp
) )
@@ -147,7 +169,8 @@ fun ChatScreen(
containerColor = MaterialTheme.colorScheme.surface containerColor = MaterialTheme.colorScheme.surface
) )
) )
} },
snackbarHost = { SnackbarHost(snackbarHostState) }
// No bottomBar — input is placed inside the content column so imePadding works // No bottomBar — input is placed inside the content column so imePadding works
) { padding -> ) { padding ->
// imePadding() here at the column level makes the whole content // imePadding() here at the column level makes the whole content
@@ -227,7 +250,7 @@ fun ChatScreen(
selectedMessage?.let { msg -> selectedMessage?.let { msg ->
MessageActionsSheet( MessageActionsSheet(
message = msg, message = msg,
clipboardManager = clipboardManager, clipboard = clipboard,
onDelete = { onDelete = {
selectedMessage = null selectedMessage = null
messageToDelete = msg messageToDelete = msg
@@ -267,6 +290,10 @@ fun ChatScreen(
if (uiState.showEncryptionPicker) { if (uiState.showEncryptionPicker) {
EncryptionPickerDialog( EncryptionPickerDialog(
current = uiState.encryptionType, current = uiState.encryptionType,
omemoState = uiState.omemoState,
pgpHasOwn = uiState.pgpHasOwnKey,
pgpHasCont = uiState.pgpHasContactKey,
otrActive = uiState.otrActive,
onSelect = viewModel::setEncryption, onSelect = viewModel::setEncryption,
onDismiss = viewModel::toggleEncryptionPicker onDismiss = viewModel::toggleEncryptionPicker
) )
@@ -281,24 +308,28 @@ private val URL_PATTERN: Pattern = Pattern.compile(
"(https?://|www\\.)[\\w\\-]+(\\.[\\w\\-]+)+([\\w.,@?^=%&:/~+#\\-_]*[\\w@?^=%&/~+#\\-_])?" "(https?://|www\\.)[\\w\\-]+(\\.[\\w\\-]+)+([\\w.,@?^=%&:/~+#\\-_]*[\\w@?^=%&/~+#\\-_])?"
) )
/** Converts a plain string into an [AnnotatedString] with clickable URL spans. */ /** Converts a plain string into an [AnnotatedString] with clickable URL spans (modern API). */
fun buildMessageText(text: String, linkColor: Color): AnnotatedString = buildAnnotatedString { fun buildMessageText(text: String, linkColor: Color): AnnotatedString = buildAnnotatedString {
val matcher = URL_PATTERN.matcher(text) val matcher = URL_PATTERN.matcher(text)
var last = 0 var last = 0
while (matcher.find()) { while (matcher.find()) {
// Append plain text before the URL
append(text.substring(last, matcher.start())) append(text.substring(last, matcher.start()))
val url = matcher.group() val url = matcher.group()
val fullUrl = if (url.startsWith("http")) url else "https://$url" val fullUrl = if (url.startsWith("http")) url else "https://$url"
// Append the URL with a distinct style and a string annotation // Use LinkAnnotation.Url — opens the browser automatically on click.
pushStringAnnotation(tag = "URL", annotation = fullUrl) withLink(
withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)) { LinkAnnotation.Url(
append(url) url = fullUrl,
} styles = TextLinkStyles(
pop() style = SpanStyle(
color = linkColor,
textDecoration = TextDecoration.Underline
)
)
)
) { append(url) }
last = matcher.end() last = matcher.end()
} }
// Append remaining plain text
append(text.substring(last)) append(text.substring(last))
} }
@@ -344,30 +375,17 @@ fun MessageBubble(message: Message, onLongPress: () -> Unit) {
.padding(horizontal = 14.dp, vertical = 8.dp) .padding(horizontal = 14.dp, vertical = 8.dp)
) { ) {
when (message.mediaType) { when (message.mediaType) {
MediaType.TEXT, MediaType.LINK, null -> { MediaType.TEXT, MediaType.LINK -> {
// Build annotated text with clickable URLs // Build annotated text with clickable URLs via LinkAnnotation.
// Text handles link clicks automatically — no ClickableText needed.
val annotated = remember(message.body) { val annotated = remember(message.body) {
buildMessageText(message.body, linkColor) 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(
text = message.body, text = annotated,
color = textColor, style = MaterialTheme.typography.bodyMedium.copy(color = textColor)
style = MaterialTheme.typography.bodyMedium
) )
} }
}
MediaType.IMAGE -> { MediaType.IMAGE -> {
AsyncImage( AsyncImage(
model = message.mediaUrl ?: message.body, model = message.mediaUrl ?: message.body,
@@ -457,20 +475,20 @@ fun MessageBubble(message: Message, onLongPress: () -> Unit) {
@Composable @Composable
fun MessageActionsSheet( fun MessageActionsSheet(
message: Message, message: Message,
clipboardManager: ClipboardManager, clipboard: androidx.compose.ui.platform.Clipboard,
onDelete: () -> Unit, onDelete: () -> Unit,
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val hasUrl = URL_PATTERN.matcher(message.body).find() val hasUrl = URL_PATTERN.matcher(message.body).find()
val scope = rememberCoroutineScope()
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
sheetState = sheetState sheetState = sheetState
) { ) {
Column(modifier = Modifier.padding(bottom = 24.dp)) { Column(modifier = Modifier.padding(bottom = 24.dp)) {
// Header preview
Text( Text(
text = message.body.take(120) + if (message.body.length > 120) "" else "", text = message.body.take(120) + if (message.body.length > 120) "" else "",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -479,17 +497,21 @@ fun MessageActionsSheet(
) )
HorizontalDivider() HorizontalDivider()
// ── Copy ──────────────────────────────────────────────────────
ListItem( ListItem(
headlineContent = { Text("Copy text") }, headlineContent = { Text("Copy text") },
leadingContent = { Icon(Icons.Default.ContentCopy, null) }, leadingContent = { Icon(Icons.Default.ContentCopy, null) },
modifier = Modifier.clickable { modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(message.body)) scope.launch {
clipboard.setClipEntry(
androidx.compose.ui.platform.ClipEntry(
android.content.ClipData.newPlainText("message", message.body)
)
)
}
onDismiss() onDismiss()
} }
) )
// ── Open URL ─────────────────────────────────────────────────
if (hasUrl) { if (hasUrl) {
val matcher = URL_PATTERN.matcher(message.body) val matcher = URL_PATTERN.matcher(message.body)
if (matcher.find()) { if (matcher.find()) {
@@ -498,25 +520,23 @@ fun MessageActionsSheet(
ListItem( ListItem(
headlineContent = { Text("Open link") }, headlineContent = { Text("Open link") },
supportingContent = { supportingContent = {
Text( Text(fullUrl, style = MaterialTheme.typography.labelSmall,
fullUrl, color = MaterialTheme.colorScheme.primary, maxLines = 1)
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 1
)
}, },
leadingContent = { Icon(Icons.Default.OpenInBrowser, null) }, leadingContent = { Icon(Icons.Default.OpenInBrowser, null) },
modifier = Modifier.clickable { modifier = Modifier.clickable { uriHandler.openUri(fullUrl); onDismiss() }
uriHandler.openUri(fullUrl)
onDismiss()
}
) )
// Copy link separately
ListItem( ListItem(
headlineContent = { Text("Copy link") }, headlineContent = { Text("Copy link") },
leadingContent = { Icon(Icons.Default.Link, null) }, leadingContent = { Icon(Icons.Default.Link, null) },
modifier = Modifier.clickable { modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(fullUrl)) scope.launch {
clipboard.setClipEntry(
androidx.compose.ui.platform.ClipEntry(
android.content.ClipData.newPlainText("link", fullUrl)
)
)
}
onDismiss() onDismiss()
} }
) )
@@ -525,17 +545,9 @@ fun MessageActionsSheet(
HorizontalDivider() HorizontalDivider()
// ── Delete ────────────────────────────────────────────────────
ListItem( ListItem(
headlineContent = { headlineContent = { Text("Delete message", color = MaterialTheme.colorScheme.error) },
Text("Delete message", color = MaterialTheme.colorScheme.error) leadingContent = { Icon(Icons.Default.DeleteForever, null, tint = MaterialTheme.colorScheme.error) },
},
leadingContent = {
Icon(
Icons.Default.DeleteForever, null,
tint = MaterialTheme.colorScheme.error
)
},
modifier = Modifier.clickable { onDelete() } modifier = Modifier.clickable { onDelete() }
) )
@@ -872,6 +884,10 @@ fun ChatInput(
@Composable @Composable
fun EncryptionPickerDialog( fun EncryptionPickerDialog(
current: EncryptionType, current: EncryptionType,
omemoState: EncryptionManager.OmemoState,
pgpHasOwn: Boolean,
pgpHasCont: Boolean,
otrActive: Boolean,
onSelect: (EncryptionType) -> Unit, onSelect: (EncryptionType) -> Unit,
onDismiss: () -> Unit onDismiss: () -> Unit
) { ) {
@@ -881,21 +897,57 @@ fun EncryptionPickerDialog(
text = { text = {
Column { Column {
EncryptionType.entries.forEach { type -> EncryptionType.entries.forEach { type ->
val (statusLabel, statusColor, enabled) = when (type) {
EncryptionType.NONE -> Triple("", null, true)
EncryptionType.OMEMO -> when (omemoState) {
EncryptionManager.OmemoState.READY -> Triple("✓ Ready", MaterialTheme.colorScheme.primary, true)
EncryptionManager.OmemoState.INITIALISING -> Triple("⏳ Initialising…", MaterialTheme.colorScheme.onSurfaceVariant, true)
EncryptionManager.OmemoState.FAILED -> Triple("✗ Failed", MaterialTheme.colorScheme.error, false)
else -> Triple("⏳ Not started", MaterialTheme.colorScheme.onSurfaceVariant, true)
}
EncryptionType.OTR -> if (otrActive)
Triple("✓ Session active", MaterialTheme.colorScheme.primary, true)
else
Triple("New session", MaterialTheme.colorScheme.onSurfaceVariant, true)
EncryptionType.OPENPGP -> when {
!pgpHasOwn -> Triple("✗ No own key", MaterialTheme.colorScheme.error, false)
!pgpHasCont -> Triple("⚠ No contact key", MaterialTheme.colorScheme.tertiary, true)
else -> Triple("✓ Keys available", MaterialTheme.colorScheme.primary, true)
}
}
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.pointerInput(type) { .pointerInput(type) {
detectTapGestures { onSelect(type) } detectTapGestures { if (enabled) onSelect(type) }
} }
.padding(12.dp), .padding(12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
RadioButton(selected = current == type, onClick = { onSelect(type) }) RadioButton(
selected = current == type,
onClick = { if (enabled) onSelect(type) },
enabled = enabled
)
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column { Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(type.toDisplayName(), fontWeight = FontWeight.Medium) Text(type.toDisplayName(), fontWeight = FontWeight.Medium)
Text(type.toDescription(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) if (statusLabel.isNotEmpty()) {
Spacer(Modifier.width(6.dp))
Text(
statusLabel,
style = MaterialTheme.typography.labelSmall,
color = statusColor ?: MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Text(
type.toDescription(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} }
} }
} }

Ver fichero

@@ -3,6 +3,7 @@ package com.manalejandro.alejabber.ui.chat
import android.net.Uri import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.manalejandro.alejabber.data.remote.EncryptionManager
import com.manalejandro.alejabber.data.repository.ContactRepository import com.manalejandro.alejabber.data.repository.ContactRepository
import com.manalejandro.alejabber.data.repository.MessageRepository import com.manalejandro.alejabber.data.repository.MessageRepository
import com.manalejandro.alejabber.domain.model.* import com.manalejandro.alejabber.domain.model.*
@@ -18,6 +19,7 @@ data class ChatUiState(
val messages: List<Message> = emptyList(), val messages: List<Message> = emptyList(),
val contactName: String = "", val contactName: String = "",
val contactPresence: PresenceStatus = PresenceStatus.OFFLINE, val contactPresence: PresenceStatus = PresenceStatus.OFFLINE,
val contactAvatarUrl: String? = null,
val inputText: String = "", val inputText: String = "",
val encryptionType: EncryptionType = EncryptionType.NONE, val encryptionType: EncryptionType = EncryptionType.NONE,
val isTyping: Boolean = false, val isTyping: Boolean = false,
@@ -25,7 +27,12 @@ data class ChatUiState(
val recordingState: RecordingState = RecordingState.IDLE, val recordingState: RecordingState = RecordingState.IDLE,
val recordingDurationMs: Long = 0, val recordingDurationMs: Long = 0,
val showEncryptionPicker: Boolean = false, val showEncryptionPicker: Boolean = false,
val error: String? = null val omemoState: EncryptionManager.OmemoState = EncryptionManager.OmemoState.IDLE,
val otrActive: Boolean = false,
val pgpHasOwnKey: Boolean = false,
val pgpHasContactKey: Boolean = false,
val error: String? = null,
val info: String? = null
) )
@HiltViewModel @HiltViewModel
@@ -33,7 +40,8 @@ class ChatViewModel @Inject constructor(
private val messageRepository: MessageRepository, private val messageRepository: MessageRepository,
private val contactRepository: ContactRepository, private val contactRepository: ContactRepository,
private val httpUploadManager: HttpUploadManager, private val httpUploadManager: HttpUploadManager,
private val audioRecorder: AudioRecorder private val audioRecorder: AudioRecorder,
private val encryptionManager: EncryptionManager
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(ChatUiState()) private val _uiState = MutableStateFlow(ChatUiState())
@@ -47,30 +55,26 @@ class ChatViewModel @Inject constructor(
currentJid = jid currentJid = jid
viewModelScope.launch { viewModelScope.launch {
// Load messages
messageRepository.getMessages(accountId, jid).collect { messages -> messageRepository.getMessages(accountId, jid).collect { messages ->
_uiState.update { it.copy(messages = messages) } _uiState.update { it.copy(messages = messages) }
} }
} }
viewModelScope.launch { viewModelScope.launch {
// Load contact info // Keep contact info (name, presence, avatar) live
contactRepository.getContacts(accountId) contactRepository.getContacts(accountId).collect { contacts ->
.take(1)
.collect { contacts ->
val contact = contacts.find { it.jid == jid } val contact = contacts.find { it.jid == jid }
_uiState.update { _uiState.update {
it.copy( it.copy(
contactName = contact?.nickname?.ifBlank { jid } ?: jid, contactName = contact?.nickname?.ifBlank { jid } ?: jid,
contactPresence = contact?.presence ?: PresenceStatus.OFFLINE contactPresence = contact?.presence ?: PresenceStatus.OFFLINE,
contactAvatarUrl = contact?.avatarUrl
) )
} }
} }
} }
viewModelScope.launch { viewModelScope.launch {
// Mark as read
messageRepository.markAllAsRead(accountId, jid) messageRepository.markAllAsRead(accountId, jid)
} }
// Observe recording state
viewModelScope.launch { viewModelScope.launch {
audioRecorder.state.collect { state -> audioRecorder.state.collect { state ->
_uiState.update { it.copy(recordingState = state) } _uiState.update { it.copy(recordingState = state) }
@@ -81,6 +85,26 @@ class ChatViewModel @Inject constructor(
_uiState.update { it.copy(recordingDurationMs = ms) } _uiState.update { it.copy(recordingDurationMs = ms) }
} }
} }
// Observe OMEMO state — updates UI badge in real time
viewModelScope.launch {
encryptionManager.omemoState.collect { stateMap ->
val s = stateMap[accountId] ?: EncryptionManager.OmemoState.IDLE
_uiState.update { it.copy(omemoState = s) }
}
}
// Initialise OMEMO if needed
viewModelScope.launch {
encryptionManager.initOmemo(accountId)
}
// Check PGP key availability
val pgp = encryptionManager.pgpManager()
_uiState.update {
it.copy(
pgpHasOwnKey = pgp.hasOwnKey(),
pgpHasContactKey = pgp.loadContactPublicKeyArmored(jid) != null,
otrActive = encryptionManager.isOtrSessionActive(accountId, jid)
)
}
} }
fun onInputChange(text: String) = _uiState.update { it.copy(inputText = text) } fun onInputChange(text: String) = _uiState.update { it.copy(inputText = text) }
@@ -90,12 +114,50 @@ class ChatViewModel @Inject constructor(
if (text.isBlank()) return if (text.isBlank()) return
_uiState.update { it.copy(inputText = "") } _uiState.update { it.copy(inputText = "") }
viewModelScope.launch { viewModelScope.launch {
sendWithEncryption(text)
}
}
private suspend fun sendWithEncryption(body: String) {
val encType = _uiState.value.encryptionType
if (encType == EncryptionType.NONE) {
// Fast path — plain text via MessageRepository
messageRepository.sendMessage( messageRepository.sendMessage(
accountId = currentAccountId, accountId = currentAccountId,
toJid = currentJid, toJid = currentJid,
body = text, body = body,
encryptionType = _uiState.value.encryptionType encryptionType = EncryptionType.NONE
) )
return
}
// Encrypted path — EncryptionManager handles sending the stanza;
// we still persist the message locally.
val (ok, errorMsg) = encryptionManager.sendMessage(
accountId = currentAccountId,
toJid = currentJid,
body = body,
encryptionType = encType
)
if (ok) {
// Persist as sent (EncryptionManager already sent the stanza)
val msg = Message(
accountId = currentAccountId,
conversationJid = currentJid,
fromJid = "",
toJid = currentJid,
body = body,
direction = MessageDirection.OUTGOING,
status = MessageStatus.SENT,
encryptionType = encType
)
messageRepository.saveOutgoingMessage(msg)
errorMsg?.let { notice ->
_uiState.update { it.copy(info = notice) }
}
} else {
_uiState.update { it.copy(error = errorMsg ?: "Send failed") }
// Revert input so user can retry
_uiState.update { it.copy(inputText = body) }
} }
} }
@@ -147,8 +209,47 @@ class ChatViewModel @Inject constructor(
fun cancelRecording() = audioRecorder.cancelRecording() fun cancelRecording() = audioRecorder.cancelRecording()
fun setEncryption(type: EncryptionType) = _uiState.update { fun setEncryption(type: EncryptionType) {
it.copy(encryptionType = type, showEncryptionPicker = false) val omemoState = _uiState.value.omemoState
val pgpHasOwn = _uiState.value.pgpHasOwnKey
val pgpHasCont = _uiState.value.pgpHasContactKey
val (finalType, notice) = when (type) {
EncryptionType.OMEMO -> when (omemoState) {
EncryptionManager.OmemoState.READY -> type to null
EncryptionManager.OmemoState.INITIALISING ->
type to "OMEMO is initialising — messages will be encrypted once ready."
EncryptionManager.OmemoState.FAILED ->
EncryptionType.NONE to "OMEMO failed to initialise. Check server PubSub support."
else ->
type to "OMEMO is starting up…"
}
EncryptionType.OTR -> {
// Start OTR session
encryptionManager.startOtrSession(currentAccountId, currentJid)
_uiState.update { it.copy(otrActive = true) }
type to "OTR session started. Messages are encrypted end-to-end."
}
EncryptionType.OPENPGP -> when {
!pgpHasOwn -> EncryptionType.NONE to "OpenPGP: You don't have a key pair. Generate one in Settings → Encryption."
!pgpHasCont -> type to "OpenPGP: No public key for this contact. Ask them to share their key in Settings → Encryption."
else -> type to null
}
EncryptionType.NONE -> {
if (_uiState.value.encryptionType == EncryptionType.OTR) {
encryptionManager.endOtrSession(currentAccountId, currentJid)
_uiState.update { it.copy(otrActive = false) }
}
type to null
}
}
_uiState.update {
it.copy(
encryptionType = finalType,
showEncryptionPicker = false,
info = notice
)
}
} }
fun toggleEncryptionPicker() = _uiState.update { fun toggleEncryptionPicker() = _uiState.update {
@@ -160,6 +261,5 @@ class ChatViewModel @Inject constructor(
} }
fun clearError() = _uiState.update { it.copy(error = null) } fun clearError() = _uiState.update { it.copy(error = null) }
fun clearInfo() = _uiState.update { it.copy(info = null) }
} }

Ver fichero

@@ -1,16 +1,20 @@
package com.manalejandro.alejabber.ui.components package com.manalejandro.alejabber.ui.components
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
@@ -35,7 +39,17 @@ fun AvatarWithStatus(
contentDescription: String = "" contentDescription: String = ""
) { ) {
Box(modifier = modifier) { Box(modifier = modifier) {
if (!avatarUrl.isNullOrBlank()) { when {
// ── data URI (vCard Base64 avatar) ────────────────────────────
avatarUrl != null && avatarUrl.startsWith("data:") -> {
DataUriAvatar(
dataUri = avatarUrl,
size = size,
contentDescription = contentDescription
)
}
// ── Regular http/https URL ────────────────────────────────────
!avatarUrl.isNullOrBlank() -> {
AsyncImage( AsyncImage(
model = avatarUrl, model = avatarUrl,
contentDescription = contentDescription, contentDescription = contentDescription,
@@ -44,17 +58,63 @@ fun AvatarWithStatus(
.size(size) .size(size)
.clip(CircleShape) .clip(CircleShape)
) )
} else {
InitialsAvatar(name = name, size = size, contentDescription = contentDescription)
} }
// Presence dot // ── No avatar — show initials ─────────────────────────────────
else -> {
InitialsAvatar(
name = name,
size = size,
contentDescription = contentDescription
)
}
}
// Presence dot (bottom-right)
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.size(size * 0.27f) .size(size * 0.27f)
.clip(CircleShape) .clip(CircleShape)
.background(presence.toColor()) .background(presence.toColor())
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0f)) )
}
}
/**
* Decodes a `data:image/...;base64,...` URI and renders it as a circular avatar.
* Uses [remember] to avoid re-decoding on every recomposition.
*/
@Composable
private fun DataUriAvatar(
dataUri: String,
size: Dp,
contentDescription: String
) {
val bitmap = remember(dataUri) {
runCatching {
// Strip the "data:image/...;base64," prefix
val commaIndex = dataUri.indexOf(',')
if (commaIndex < 0) return@runCatching null
val base64 = dataUri.substring(commaIndex + 1)
val bytes = Base64.decode(base64, Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
}.getOrNull()
}
if (bitmap != null) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = contentDescription,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(size)
.clip(CircleShape)
)
} else {
// Fallback to initials if decoding failed
InitialsAvatar(
name = contentDescription,
size = size,
contentDescription = contentDescription
) )
} }
} }

Ver fichero

@@ -1,10 +1,10 @@
package com.manalejandro.alejabber.ui.contacts package com.manalejandro.alejabber.ui.contacts
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -135,17 +135,14 @@ fun ContactsScreen(
modifier = Modifier.align(Alignment.Center) modifier = Modifier.align(Alignment.Center)
) )
} }
else -> { else -> ContactList(
ContactList(
contacts = uiState.filteredContacts, contacts = uiState.filteredContacts,
onContactClick = { onNavigateToChat(accountId, it.jid) }, onContactClick = { onNavigateToChat(accountId, it.jid) },
onContactLongPress = { detailContact = it }, onContactLongPress = { detailContact = it }
onRemoveContact = { removeTarget = it }
) )
} }
} }
} }
}
// Add contact dialog // Add contact dialog
if (uiState.showAddDialog) { if (uiState.showAddDialog) {
@@ -190,6 +187,42 @@ fun ContactsScreen(
} }
) )
} }
// ── Subscription authorization dialog ─────────────────────────────────
uiState.pendingSubscriptionJid?.let { fromJid ->
AlertDialog(
onDismissRequest = { viewModel.denySubscription() },
icon = { Icon(Icons.Default.PersonAdd, null, tint = MaterialTheme.colorScheme.primary) },
title = { Text("Contact request") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"$fromJid wants to add you as a contact and see your presence status.",
style = MaterialTheme.typography.bodyMedium
)
Text(
"Do you want to accept and add them to your contacts?",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
confirmButton = {
Button(onClick = { viewModel.acceptSubscription() }) {
Icon(Icons.Default.Check, null)
Spacer(Modifier.width(6.dp))
Text("Accept")
}
},
dismissButton = {
OutlinedButton(onClick = { viewModel.denySubscription() }) {
Icon(Icons.Default.Close, null)
Spacer(Modifier.width(6.dp))
Text("Deny")
}
}
)
}
} }
// ── Contact Detail Bottom Sheet ─────────────────────────────────────────── // ── Contact Detail Bottom Sheet ───────────────────────────────────────────
@@ -320,8 +353,7 @@ fun SearchBar(
fun ContactList( fun ContactList(
contacts: List<Contact>, contacts: List<Contact>,
onContactClick: (Contact) -> Unit, onContactClick: (Contact) -> Unit,
onContactLongPress: (Contact) -> Unit, onContactLongPress: (Contact) -> Unit
onRemoveContact: (Contact) -> Unit
) { ) {
val presenceOrder = listOf( val presenceOrder = listOf(
PresenceStatus.ONLINE, PresenceStatus.AWAY, PresenceStatus.DND, PresenceStatus.ONLINE, PresenceStatus.AWAY, PresenceStatus.DND,
@@ -353,8 +385,7 @@ fun ContactList(
ContactItem( ContactItem(
contact = contact, contact = contact,
onClick = { onContactClick(contact) }, onClick = { onContactClick(contact) },
onLongPress = { onContactLongPress(contact) }, onLongPress = { onContactLongPress(contact) }
onRemove = { onRemoveContact(contact) }
) )
} }
} }
@@ -367,8 +398,7 @@ fun ContactList(
fun ContactItem( fun ContactItem(
contact: Contact, contact: Contact,
onClick: () -> Unit, onClick: () -> Unit,
onLongPress: () -> Unit, onLongPress: () -> Unit
onRemove: () -> Unit
) { ) {
val displayName = contact.nickname.ifBlank { contact.jid } val displayName = contact.nickname.ifBlank { contact.jid }
@@ -389,14 +419,6 @@ fun ContactItem(
contentDescription = stringResource(R.string.cd_avatar, displayName) contentDescription = stringResource(R.string.cd_avatar, displayName)
) )
}, },
trailingContent = {
IconButton(onClick = onRemove) {
Icon(
Icons.Default.PersonRemove, null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = onLongPress) .combinedClickable(onClick = onClick, onLongClick = onLongPress)
@@ -444,9 +466,9 @@ fun AddContactDialog(
fun EmptyState( fun EmptyState(
icon: androidx.compose.ui.graphics.vector.ImageVector, icon: androidx.compose.ui.graphics.vector.ImageVector,
message: String, message: String,
modifier: Modifier = Modifier,
actionLabel: String? = null, actionLabel: String? = null,
onAction: (() -> Unit)? = null, onAction: (() -> Unit)? = null
modifier: Modifier = Modifier
) { ) {
Column( Column(
modifier = modifier.padding(32.dp), modifier = modifier.padding(32.dp),

Ver fichero

@@ -2,6 +2,7 @@ package com.manalejandro.alejabber.ui.contacts
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.manalejandro.alejabber.data.remote.XmppConnectionManager
import com.manalejandro.alejabber.data.repository.AccountRepository import com.manalejandro.alejabber.data.repository.AccountRepository
import com.manalejandro.alejabber.data.repository.ContactRepository import com.manalejandro.alejabber.data.repository.ContactRepository
import com.manalejandro.alejabber.domain.model.Contact import com.manalejandro.alejabber.domain.model.Contact
@@ -31,14 +32,18 @@ data class ContactsUiState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val searchQuery: String = "", val searchQuery: String = "",
val showAddDialog: Boolean = false, val showAddDialog: Boolean = false,
val error: String? = null val error: String? = null,
/** JID of a contact requesting subscription (triggers the authorization dialog). */
val pendingSubscriptionJid: String? = null,
val pendingSubscriptionAccountId: Long = 0
) )
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@HiltViewModel @HiltViewModel
class ContactsViewModel @Inject constructor( class ContactsViewModel @Inject constructor(
private val accountRepository: AccountRepository, private val accountRepository: AccountRepository,
private val contactRepository: ContactRepository private val contactRepository: ContactRepository,
private val xmppManager: XmppConnectionManager
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(ContactsUiState()) private val _uiState = MutableStateFlow(ContactsUiState())
@@ -46,6 +51,20 @@ class ContactsViewModel @Inject constructor(
private val searchQuery = MutableStateFlow("") private val searchQuery = MutableStateFlow("")
init {
// Listen for incoming subscription requests from any account
viewModelScope.launch {
xmppManager.subscriptionRequests.collect { req ->
_uiState.update {
it.copy(
pendingSubscriptionJid = req.fromJid,
pendingSubscriptionAccountId = req.accountId
)
}
}
}
}
/** /**
* Load contacts for the given [accountId]. * Load contacts for the given [accountId].
* Safe to call multiple times; cancels the previous collection. * Safe to call multiple times; cancels the previous collection.
@@ -141,5 +160,27 @@ class ContactsViewModel @Inject constructor(
} }
} }
} }
fun acceptSubscription() {
val jid = _uiState.value.pendingSubscriptionJid ?: return
val accountId = _uiState.value.pendingSubscriptionAccountId
xmppManager.acceptSubscription(accountId, jid)
// Optionally add the contact to the roster locally
viewModelScope.launch {
try {
contactRepository.addContact(
Contact(accountId = accountId, jid = jid, nickname = jid)
)
} catch (_: Exception) {}
}
_uiState.update { it.copy(pendingSubscriptionJid = null) }
}
fun denySubscription() {
val jid = _uiState.value.pendingSubscriptionJid ?: return
val accountId = _uiState.value.pendingSubscriptionAccountId
xmppManager.denySubscription(accountId, jid)
_uiState.update { it.copy(pendingSubscriptionJid = null) }
}
} }

Ver fichero

@@ -222,7 +222,7 @@ fun JoinRoomDialog(
onJoin: (Long, String, String, String) -> Unit onJoin: (Long, String, String, String) -> Unit
) { ) {
var selectedAccountId by remember { var selectedAccountId by remember {
mutableStateOf(connectedAccounts.firstOrNull()?.id ?: 0L) mutableLongStateOf(connectedAccounts.firstOrNull()?.id ?: 0L)
} }
var roomJid by remember { mutableStateOf("") } var roomJid by remember { mutableStateOf("") }
var nickname by remember { mutableStateOf("") } var nickname by remember { mutableStateOf("") }

Ver fichero

@@ -1,5 +1,8 @@
package com.manalejandro.alejabber.ui.settings package com.manalejandro.alejabber.ui.settings
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -12,6 +15,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -28,8 +32,40 @@ fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel() viewModel: SettingsViewModel = hiltViewModel()
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
var showThemeDialog by remember { mutableStateOf(false) } var showThemeDialog by remember { mutableStateOf(false) }
var showEncryptionDialog by remember { mutableStateOf(false) } var showEncryptionDialog by remember { mutableStateOf(false) }
// PGP dialogs
var showPgpImportOwnDialog by remember { mutableStateOf(false) }
var showPgpImportContactDialog by remember { mutableStateOf(false) }
var pgpContactJid by remember { mutableStateOf("") }
var pgpKeyText by remember { mutableStateOf("") }
var showPgpContactDeleteDialog by remember { mutableStateOf<String?>(null) }
// File picker for PGP key import
val keyFilePicker = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let {
val text = context.contentResolver.openInputStream(it)?.bufferedReader()?.readText() ?: return@let
pgpKeyText = text
}
}
LaunchedEffect(uiState.pgpError) {
uiState.pgpError?.let {
snackbarHostState.showSnackbar(it, duration = SnackbarDuration.Long)
viewModel.clearPgpMessages()
}
}
LaunchedEffect(uiState.pgpInfo) {
uiState.pgpInfo?.let {
snackbarHostState.showSnackbar(it)
viewModel.clearPgpMessages()
}
}
Scaffold( Scaffold(
topBar = { topBar = {
@@ -41,7 +77,8 @@ fun SettingsScreen(
} }
} }
) )
} },
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding -> ) { padding ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -49,71 +86,213 @@ fun SettingsScreen(
.padding(padding) .padding(padding)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
// Appearance section // ── Appearance ────────────────────────────────────────────────
SettingsSectionHeader(stringResource(R.string.settings_appearance)) SettingsSectionHeader(stringResource(R.string.settings_appearance))
SettingsItem( SettingsItem(
icon = Icons.Default.Palette, icon = Icons.Default.Palette,
title = stringResource(R.string.settings_theme), title = stringResource(R.string.settings_theme),
subtitle = uiState.appTheme.toDisplayName(), subtitle = uiState.appTheme.toDisplayName(),
onClick = { showThemeDialog = true } onClick = { showThemeDialog = true }
) )
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// Notifications section // ── Notifications ─────────────────────────────────────────────
SettingsSectionHeader(stringResource(R.string.settings_notifications)) SettingsSectionHeader(stringResource(R.string.settings_notifications))
SettingsSwitchItem( SettingsSwitchItem(
icon = Icons.Default.Notifications, icon = Icons.Default.Notifications,
title = stringResource(R.string.settings_notifications_messages), title = stringResource(R.string.settings_notifications_messages),
checked = uiState.notificationsEnabled, checked = uiState.notificationsEnabled,
onCheckedChange = viewModel::setNotifications onCheckedChange = viewModel::setNotifications
) )
SettingsSwitchItem( SettingsSwitchItem(
icon = Icons.Default.Vibration, icon = Icons.Default.Vibration,
title = stringResource(R.string.settings_notifications_vibrate), title = stringResource(R.string.settings_notifications_vibrate),
checked = uiState.vibrateEnabled, checked = uiState.vibrateEnabled,
onCheckedChange = viewModel::setVibrate onCheckedChange = viewModel::setVibrate
) )
SettingsSwitchItem( SettingsSwitchItem(
icon = Icons.AutoMirrored.Filled.VolumeUp, icon = Icons.AutoMirrored.Filled.VolumeUp,
title = stringResource(R.string.settings_notifications_sound), title = stringResource(R.string.settings_notifications_sound),
checked = uiState.soundEnabled, checked = uiState.soundEnabled,
onCheckedChange = viewModel::setSound onCheckedChange = viewModel::setSound
) )
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// Encryption section // ── Encryption — default ──────────────────────────────────────
SettingsSectionHeader(stringResource(R.string.settings_encryption)) SettingsSectionHeader(stringResource(R.string.settings_encryption))
SettingsItem( SettingsItem(
icon = Icons.Default.Lock, icon = Icons.Default.Lock,
title = stringResource(R.string.settings_default_encryption), title = stringResource(R.string.settings_default_encryption),
subtitle = uiState.defaultEncryption.name, subtitle = uiState.defaultEncryption.name,
onClick = { showEncryptionDialog = true } onClick = { showEncryptionDialog = true }
) )
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// ── OMEMO ─────────────────────────────────────────────────────
SettingsSectionHeader("OMEMO (XEP-0384)")
ListItem(
headlineContent = { Text("OMEMO Device Fingerprint") },
supportingContent = {
Text(
uiState.omemoFingerprint ?: "Not available — open a chat to initialise",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
leadingContent = { Icon(Icons.Default.Fingerprint, null) }
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// ── OTR ───────────────────────────────────────────────────────
SettingsSectionHeader("OTR (Off-The-Record)")
ListItem(
headlineContent = { Text("OTR Sessions") },
supportingContent = {
Text(
"OTR sessions are established per conversation.\n" +
"Select OTR encryption in any chat to start a session.\n" +
"Sessions use ephemeral ECDH keys — perfect forward secrecy.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
leadingContent = { Icon(Icons.Default.SwapHoriz, null) }
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// ── OpenPGP ───────────────────────────────────────────────────
SettingsSectionHeader("OpenPGP")
// Own key status
ListItem(
headlineContent = { Text("My PGP Key Pair") },
supportingContent = {
if (uiState.pgpHasOwnKey) {
Column {
Text("Fingerprint:", style = MaterialTheme.typography.labelSmall)
Text(
uiState.pgpOwnKeyFingerprint ?: "",
style = MaterialTheme.typography.bodySmall,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
Text(
"No key pair. Import an armored secret key (.asc).",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
leadingContent = { Icon(Icons.Default.Key, null) },
trailingContent = {
if (uiState.pgpHasOwnKey) {
IconButton(onClick = { viewModel.deleteOwnPgpKey() }) {
Icon(Icons.Default.Delete, "Delete own key",
tint = MaterialTheme.colorScheme.error)
}
} else {
TextButton(onClick = { showPgpImportOwnDialog = true }) {
Text("Import")
}
}
}
)
// Import own key button (also visible via trailing if no key)
if (!uiState.pgpHasOwnKey) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = { showPgpImportOwnDialog = true },
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.Edit, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Paste key")
}
OutlinedButton(
onClick = { keyFilePicker.launch("*/*") },
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.FolderOpen, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("From file")
}
}
}
// Contact public keys
Spacer(Modifier.height(8.dp))
ListItem(
headlineContent = { Text("Contact Public Keys") },
supportingContent = {
if (uiState.pgpContactKeys.isEmpty()) {
Text(
"No contact keys stored.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Column {
uiState.pgpContactKeys.forEach { jid ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
jid,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f)
)
IconButton(
onClick = { showPgpContactDeleteDialog = jid },
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.Delete, "Remove",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp)
)
}
}
}
}
}
},
leadingContent = { Icon(Icons.Default.People, null) }
)
OutlinedButton(
onClick = { showPgpImportContactDialog = true },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
Icon(Icons.Default.PersonAdd, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Add contact public key")
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// About section // ── About ─────────────────────────────────────────────────────
SettingsSectionHeader(stringResource(R.string.settings_about)) SettingsSectionHeader(stringResource(R.string.settings_about))
SettingsItem( SettingsItem(
icon = Icons.Default.Info, icon = Icons.Default.Info,
title = stringResource(R.string.settings_version), title = stringResource(R.string.settings_version),
subtitle = "1.0.0", subtitle = "1.0.0",
onClick = {} onClick = {}
) )
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
} }
} }
// Theme dialog // ── Theme dialog ──────────────────────────────────────────────────────
if (showThemeDialog) { if (showThemeDialog) {
AlertDialog( AlertDialog(
onDismissRequest = { showThemeDialog = false }, onDismissRequest = { showThemeDialog = false },
@@ -124,17 +303,14 @@ fun SettingsScreen(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { .clickable { viewModel.setTheme(theme); showThemeDialog = false }
viewModel.setTheme(theme)
showThemeDialog = false
}
.padding(12.dp), .padding(12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
RadioButton(selected = uiState.appTheme == theme, onClick = { RadioButton(
viewModel.setTheme(theme) selected = uiState.appTheme == theme,
showThemeDialog = false onClick = { viewModel.setTheme(theme); showThemeDialog = false }
}) )
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Text(theme.toDisplayName()) Text(theme.toDisplayName())
} }
@@ -147,7 +323,7 @@ fun SettingsScreen(
) )
} }
// Encryption default dialog // ── Default encryption dialog ─────────────────────────────────────────
if (showEncryptionDialog) { if (showEncryptionDialog) {
AlertDialog( AlertDialog(
onDismissRequest = { showEncryptionDialog = false }, onDismissRequest = { showEncryptionDialog = false },
@@ -158,19 +334,18 @@ fun SettingsScreen(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { .clickable { viewModel.setDefaultEncryption(type); showEncryptionDialog = false }
viewModel.setDefaultEncryption(type)
showEncryptionDialog = false
}
.padding(12.dp), .padding(12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
RadioButton(selected = uiState.defaultEncryption == type, onClick = { RadioButton(
viewModel.setDefaultEncryption(type) selected = uiState.defaultEncryption == type,
showEncryptionDialog = false onClick = { viewModel.setDefaultEncryption(type); showEncryptionDialog = false }
}) )
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Text(type.name) Column {
Text(type.name, fontWeight = FontWeight.Medium)
}
} }
} }
} }
@@ -180,6 +355,118 @@ fun SettingsScreen(
} }
) )
} }
// ── PGP import own key ────────────────────────────────────────────────
if (showPgpImportOwnDialog) {
AlertDialog(
onDismissRequest = { showPgpImportOwnDialog = false; pgpKeyText = "" },
title = { Text("Import My PGP Secret Key") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Paste your armored PGP secret key below (-----BEGIN PGP PRIVATE KEY BLOCK-----…).",
style = MaterialTheme.typography.bodySmall
)
OutlinedTextField(
value = pgpKeyText,
onValueChange = { pgpKeyText = it },
label = { Text("Armored PGP key") },
modifier = Modifier
.fillMaxWidth()
.height(160.dp),
maxLines = 8
)
TextButton(onClick = { keyFilePicker.launch("*/*") }) {
Icon(Icons.Default.FolderOpen, null)
Spacer(Modifier.width(4.dp))
Text("Pick from file")
}
}
},
confirmButton = {
Button(onClick = {
viewModel.importOwnPgpKey(pgpKeyText)
showPgpImportOwnDialog = false
pgpKeyText = ""
}, enabled = pgpKeyText.isNotBlank()) {
Text("Import")
}
},
dismissButton = {
TextButton(onClick = { showPgpImportOwnDialog = false; pgpKeyText = "" }) {
Text(stringResource(R.string.cancel))
}
}
)
}
// ── PGP import contact key ────────────────────────────────────────────
if (showPgpImportContactDialog) {
AlertDialog(
onDismissRequest = { showPgpImportContactDialog = false; pgpKeyText = ""; pgpContactJid = "" },
title = { Text("Add Contact Public Key") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = pgpContactJid,
onValueChange = { pgpContactJid = it },
label = { Text("Contact JID") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
OutlinedTextField(
value = pgpKeyText,
onValueChange = { pgpKeyText = it },
label = { Text("Armored public key") },
modifier = Modifier
.fillMaxWidth()
.height(140.dp),
maxLines = 8
)
TextButton(onClick = { keyFilePicker.launch("*/*") }) {
Icon(Icons.Default.FolderOpen, null)
Spacer(Modifier.width(4.dp))
Text("Pick from file")
}
}
},
confirmButton = {
Button(
onClick = {
viewModel.importContactPgpKey(pgpContactJid.trim(), pgpKeyText)
showPgpImportContactDialog = false
pgpKeyText = ""; pgpContactJid = ""
},
enabled = pgpContactJid.isNotBlank() && pgpKeyText.isNotBlank()
) { Text("Save") }
},
dismissButton = {
TextButton(onClick = { showPgpImportContactDialog = false; pgpKeyText = ""; pgpContactJid = "" }) {
Text(stringResource(R.string.cancel))
}
}
)
}
// ── PGP delete contact key confirmation ───────────────────────────────
showPgpContactDeleteDialog?.let { jid ->
AlertDialog(
onDismissRequest = { showPgpContactDeleteDialog = null },
title = { Text("Remove key?") },
text = { Text("Remove the PGP public key for $jid?") },
confirmButton = {
TextButton(onClick = {
viewModel.deleteContactPgpKey(jid)
showPgpContactDeleteDialog = null
}) { Text("Remove", color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton(onClick = { showPgpContactDeleteDialog = null }) {
Text(stringResource(R.string.cancel))
}
}
)
}
} }
@Composable @Composable
@@ -205,9 +492,7 @@ fun SettingsItem(
supportingContent = if (subtitle.isNotBlank()) { supportingContent = if (subtitle.isNotBlank()) {
{ Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } { Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
} else null, } else null,
leadingContent = { leadingContent = { Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) },
Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
},
modifier = Modifier.clickable(onClick = onClick) modifier = Modifier.clickable(onClick = onClick)
) )
} }
@@ -221,12 +506,8 @@ fun SettingsSwitchItem(
) { ) {
ListItem( ListItem(
headlineContent = { Text(title) }, headlineContent = { Text(title) },
leadingContent = { leadingContent = { Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) },
Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) trailingContent = { Switch(checked = checked, onCheckedChange = onCheckedChange) }
},
trailingContent = {
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
) )
} }
@@ -235,6 +516,3 @@ fun AppTheme.toDisplayName(): String = when (this) {
AppTheme.LIGHT -> "Light" AppTheme.LIGHT -> "Light"
AppTheme.DARK -> "Dark" AppTheme.DARK -> "Dark"
} }

Ver fichero

@@ -7,6 +7,8 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.manalejandro.alejabber.data.remote.EncryptionManager
import com.manalejandro.alejabber.data.remote.PgpManager
import com.manalejandro.alejabber.domain.model.EncryptionType import com.manalejandro.alejabber.domain.model.EncryptionType
import com.manalejandro.alejabber.ui.theme.AppTheme import com.manalejandro.alejabber.ui.theme.AppTheme
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -19,12 +21,22 @@ data class SettingsUiState(
val notificationsEnabled: Boolean = true, val notificationsEnabled: Boolean = true,
val vibrateEnabled: Boolean = true, val vibrateEnabled: Boolean = true,
val soundEnabled: Boolean = true, val soundEnabled: Boolean = true,
val defaultEncryption: EncryptionType = EncryptionType.OMEMO val defaultEncryption: EncryptionType = EncryptionType.OMEMO,
// OMEMO
val omemoFingerprint: String? = null,
// OTR (informational only — sessions are per-chat)
// OpenPGP
val pgpHasOwnKey: Boolean = false,
val pgpOwnKeyFingerprint: String? = null,
val pgpContactKeys: List<String> = emptyList(), // JIDs with stored pub key
val pgpError: String? = null,
val pgpInfo: String? = null
) )
@HiltViewModel @HiltViewModel
class SettingsViewModel @Inject constructor( class SettingsViewModel @Inject constructor(
private val dataStore: DataStore<Preferences> private val dataStore: DataStore<Preferences>,
private val encryptionManager: EncryptionManager
) : ViewModel() { ) : ViewModel() {
companion object { companion object {
@@ -43,45 +55,95 @@ class SettingsViewModel @Inject constructor(
dataStore.data.collect { prefs -> dataStore.data.collect { prefs ->
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
appTheme = try { AppTheme.valueOf(prefs[KEY_THEME] ?: "SYSTEM") } catch (e: Exception) { AppTheme.SYSTEM }, appTheme = try { AppTheme.valueOf(prefs[KEY_THEME] ?: "SYSTEM") } catch (_: Exception) { AppTheme.SYSTEM },
notificationsEnabled = prefs[KEY_NOTIFICATIONS] ?: true, notificationsEnabled = prefs[KEY_NOTIFICATIONS] ?: true,
vibrateEnabled = prefs[KEY_VIBRATE] ?: true, vibrateEnabled = prefs[KEY_VIBRATE] ?: true,
soundEnabled = prefs[KEY_SOUND] ?: true, soundEnabled = prefs[KEY_SOUND] ?: true,
defaultEncryption = try { EncryptionType.valueOf(prefs[KEY_DEFAULT_ENCRYPTION] ?: "OMEMO") } catch (e: Exception) { EncryptionType.OMEMO } defaultEncryption = try { EncryptionType.valueOf(prefs[KEY_DEFAULT_ENCRYPTION] ?: "OMEMO") } catch (_: Exception) { EncryptionType.OMEMO }
) )
} }
} }
} }
refreshPgpState()
} }
private fun refreshPgpState() {
val pgp = encryptionManager.pgpManager()
_uiState.update {
it.copy(
pgpHasOwnKey = pgp.hasOwnKey(),
pgpOwnKeyFingerprint = pgp.getOwnKeyFingerprint(),
pgpContactKeys = pgp.listContactsWithKeys()
)
}
}
// ── Preferences ───────────────────────────────────────────────────────
fun setTheme(theme: AppTheme) { fun setTheme(theme: AppTheme) {
viewModelScope.launch { viewModelScope.launch { dataStore.edit { it[KEY_THEME] = theme.name } }
dataStore.edit { it[KEY_THEME] = theme.name }
} }
}
fun setNotifications(enabled: Boolean) { fun setNotifications(enabled: Boolean) {
viewModelScope.launch { viewModelScope.launch { dataStore.edit { it[KEY_NOTIFICATIONS] = enabled } }
dataStore.edit { it[KEY_NOTIFICATIONS] = enabled }
} }
}
fun setVibrate(enabled: Boolean) { fun setVibrate(enabled: Boolean) {
viewModelScope.launch { viewModelScope.launch { dataStore.edit { it[KEY_VIBRATE] = enabled } }
dataStore.edit { it[KEY_VIBRATE] = enabled }
} }
}
fun setSound(enabled: Boolean) { fun setSound(enabled: Boolean) {
viewModelScope.launch { viewModelScope.launch { dataStore.edit { it[KEY_SOUND] = enabled } }
dataStore.edit { it[KEY_SOUND] = enabled }
} }
}
fun setDefaultEncryption(type: EncryptionType) { fun setDefaultEncryption(type: EncryptionType) {
viewModelScope.launch { viewModelScope.launch { dataStore.edit { it[KEY_DEFAULT_ENCRYPTION] = type.name } }
dataStore.edit { it[KEY_DEFAULT_ENCRYPTION] = type.name }
} }
}
}
// ── OMEMO ─────────────────────────────────────────────────────────────
fun refreshOmemoFingerprint(accountId: Long) {
val fp = encryptionManager.getOwnOmemoFingerprint(accountId)
_uiState.update { it.copy(omemoFingerprint = fp) }
}
// ── OpenPGP ───────────────────────────────────────────────────────────
/** Import the user's own armored secret key (from file or paste). */
fun importOwnPgpKey(armoredKey: String) {
viewModelScope.launch {
try {
encryptionManager.pgpManager().saveOwnSecretKeyArmored(armoredKey)
refreshPgpState()
_uiState.update { it.copy(pgpInfo = "Own PGP key imported successfully.") }
} catch (e: Exception) {
_uiState.update { it.copy(pgpError = "Key import failed: ${e.message}") }
}
}
}
/** Delete the user's own key pair. */
fun deleteOwnPgpKey() {
viewModelScope.launch {
encryptionManager.pgpManager().saveOwnSecretKeyArmored("") // overwrite with empty
refreshPgpState()
}
}
/** Import a contact's public key. */
fun importContactPgpKey(jid: String, armoredKey: String) {
viewModelScope.launch {
try {
encryptionManager.pgpManager().saveContactPublicKey(jid, armoredKey)
refreshPgpState()
_uiState.update { it.copy(pgpInfo = "Public key for $jid saved.") }
} catch (e: Exception) {
_uiState.update { it.copy(pgpError = "Key import failed: ${e.message}") }
}
}
}
/** Remove a contact's public key. */
fun deleteContactPgpKey(jid: String) {
encryptionManager.pgpManager().deleteContactPublicKey(jid)
refreshPgpState()
}
fun clearPgpMessages() = _uiState.update { it.copy(pgpError = null, pgpInfo = null) }
}

Ver fichero

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

Ver fichero

@@ -2,4 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>

Ver fichero

@@ -2,4 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>

Ver fichero

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">AleJabber</string> <string name="app_name">AleJabber</string>
<!-- Navegación --> <!-- Navegación -->
<string name="nav_contacts">Contactos</string>
<string name="nav_rooms">Salas</string> <string name="nav_rooms">Salas</string>
<string name="nav_accounts">Cuentas</string> <string name="nav_accounts">Cuentas</string>
<string name="nav_settings">Ajustes</string> <string name="nav_settings">Ajustes</string>
@@ -17,12 +17,6 @@
<string name="account_server">Servidor</string> <string name="account_server">Servidor</string>
<string name="account_port">Puerto</string> <string name="account_port">Puerto</string>
<string name="account_use_tls">Usar TLS</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_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_delete_confirm">¿Eliminar la cuenta %1$s?</string>
<string name="account_jid_hint">usuario@ejemplo.com</string> <string name="account_jid_hint">usuario@ejemplo.com</string>
@@ -32,42 +26,27 @@
<!-- Contactos --> <!-- Contactos -->
<string name="contacts_title">Contactos</string> <string name="contacts_title">Contactos</string>
<string name="contacts_search">Buscar 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="contacts_empty">Aún no hay contactos.\nAñade personas con su Jabber ID.</string>
<string name="add_contact">Añadir contacto</string> <string name="add_contact">Añadir contacto</string>
<string name="contact_jid">ID de Jabber (JID)</string> <string name="contact_jid">Jabber ID (JID)</string>
<string name="contact_nickname">Apodo</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 --> <!-- Salas / MUC -->
<string name="rooms_title">Salas</string> <string name="rooms_title">Salas</string>
<string name="rooms_search">Buscar salas…</string> <string name="rooms_empty">Aún no te has unido a ninguna sala.</string>
<string name="rooms_empty">No te has unido a ninguna sala.</string>
<string name="join_room">Unirse a sala</string> <string name="join_room">Unirse a sala</string>
<string name="room_jid">JID de la sala</string> <string name="room_jid">JID de la sala</string>
<string name="room_nickname">Tu apodo</string> <string name="room_nickname">Tu apodo</string>
<string name="room_password">Contraseña (opcional)</string> <string name="room_password">Contraseña de la sala</string>
<string name="leave_room">Salir de la sala</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 --> <!-- Chat -->
<string name="chat_hint">Escribe un mensaje…</string> <string name="chat_hint">Escribe un mensaje…</string>
<string name="chat_send">Enviar</string> <string name="chat_send">Enviar</string>
<string name="chat_attach">Adjuntar archivo</string> <string name="chat_attach">Adjuntar archivo</string>
<string name="chat_record_audio">Grabar audio</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_send_audio">Enviar audio</string>
<string name="chat_cancel_audio">Cancelar 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_encryption_select">Seleccionar cifrado</string>
<string name="chat_message_delivered">Entregado</string> <string name="chat_message_delivered">Entregado</string>
<string name="chat_message_read">Leído</string> <string name="chat_message_read">Leído</string>
@@ -75,17 +54,9 @@
<string name="chat_message_failed">Error al enviar</string> <string name="chat_message_failed">Error al enviar</string>
<string name="chat_typing">%1$s está escribiendo…</string> <string name="chat_typing">%1$s está escribiendo…</string>
<string name="chat_media_image">Imagen</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_audio">Audio</string>
<string name="chat_media_file">Archivo</string> <string name="chat_media_file">Archivo</string>
<string name="chat_media_uploading">Subiendo…</string> <string name="chat_empty">Aún no hay mensajes.\n¡Di hola!</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 --> <!-- Ajustes -->
<string name="settings_title">Ajustes</string> <string name="settings_title">Ajustes</string>
@@ -95,62 +66,31 @@
<string name="settings_theme_light">Claro</string> <string name="settings_theme_light">Claro</string>
<string name="settings_theme_dark">Oscuro</string> <string name="settings_theme_dark">Oscuro</string>
<string name="settings_language">Idioma</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">Notificaciones</string>
<string name="settings_notifications_messages">Notificaciones de mensajes</string> <string name="settings_notifications_messages">Notificaciones de mensajes</string>
<string name="settings_notifications_vibrate">Vibrar</string> <string name="settings_notifications_vibrate">Vibrar</string>
<string name="settings_notifications_sound">Sonido</string> <string name="settings_notifications_sound">Sonido</string>
<string name="settings_encryption">Cifrado</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_default_encryption">Cifrado por defecto</string>
<string name="settings_about">Acerca de</string> <string name="settings_about">Acerca de</string>
<string name="settings_version">Versión</string> <string name="settings_version">Versión</string>
<!-- Común --> <!-- Común -->
<string name="ok">Aceptar</string>
<string name="cancel">Cancelar</string> <string name="cancel">Cancelar</string>
<string name="save">Guardar</string> <string name="save">Guardar</string>
<string name="delete">Eliminar</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="close">Cerrar</string>
<string name="search">Buscar</string>
<string name="clear">Limpiar</string>
<string name="back">Atrás</string> <string name="back">Atrás</string>
<string name="more_options">Más opciones</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 --> <!-- Notificaciones -->
<string name="notification_channel_messages">Mensajes</string> <string name="notification_channel_messages">Mensajes</string>
<string name="notification_channel_messages_desc">Mensajes de chat entrantes</string> <string name="notification_channel_messages_desc">Mensajes entrantes</string>
<string name="notification_channel_service">Servicio XMPP</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_channel_service_desc">Conexión XMPP en segundo plano</string>
<string name="notification_service_running">AleJabber está conectado</string> <string name="notification_service_running">AleJabber está conectado</string>
<string name="notification_new_message">Nuevo mensaje de %1$s</string>
<!-- Accesibilidad --> <!-- Accesibilidad -->
<string name="cd_avatar">Avatar de %1$s</string> <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_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> </resources>

Ver fichero

@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">AleJabber</string> <string name="app_name">AleJabber</string>
<!-- 导航 --> <!-- 导航 -->
<string name="nav_contacts">联系人</string> <string name="nav_rooms">房间</string>
<string name="nav_rooms">聊天室</string>
<string name="nav_accounts">账号</string> <string name="nav_accounts">账号</string>
<string name="nav_settings">设置</string> <string name="nav_settings">设置</string>
<!-- 账号 --> <!-- 账号 -->
<string name="accounts_title">账号管理</string> <string name="accounts_title">账号</string>
<string name="add_account">添加账号</string> <string name="add_account">添加账号</string>
<string name="edit_account">编辑账号</string> <string name="edit_account">编辑账号</string>
<string name="delete_account">删除账号</string> <string name="delete_account">删除账号</string>
@@ -17,13 +17,7 @@
<string name="account_server">服务器</string> <string name="account_server">服务器</string>
<string name="account_port">端口</string> <string name="account_port">端口</string>
<string name="account_use_tls">使用 TLS</string> <string name="account_use_tls">使用 TLS</string>
<string name="account_status_online">已连接</string> <string name="account_no_accounts">没有已配置的账号。\n点击 + 添加一个。</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_delete_confirm">删除账号 %1$s</string>
<string name="account_jid_hint">user@example.com</string> <string name="account_jid_hint">user@example.com</string>
<string name="account_resource">资源</string> <string name="account_resource">资源</string>
@@ -32,42 +26,27 @@
<!-- 联系人 --> <!-- 联系人 -->
<string name="contacts_title">联系人</string> <string name="contacts_title">联系人</string>
<string name="contacts_search">搜索联系人…</string> <string name="contacts_search">搜索联系人…</string>
<string name="contacts_empty">暂无联系人。\n通过 Jabber ID 添加友。</string> <string name="contacts_empty">还没有联系人。\n使用 Jabber ID 添加友。</string>
<string name="add_contact">添加联系人</string> <string name="add_contact">添加联系人</string>
<string name="contact_jid">Jabber ID (JID)</string> <string name="contact_jid">Jabber ID (JID)</string>
<string name="contact_nickname">昵称</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 --> <!-- 房间 / MUC -->
<string name="rooms_title">聊天室</string> <string name="rooms_title">房间</string>
<string name="rooms_search">搜索聊天室…</string> <string name="rooms_empty">还没有加入任何房间。</string>
<string name="rooms_empty">尚未加入任何聊天室。</string> <string name="join_room">加入房间</string>
<string name="join_room">加入聊天室</string> <string name="room_jid">房间 JID</string>
<string name="room_jid">聊天室 JID</string> <string name="room_nickname">你的昵称</string>
<string name="room_nickname">您的昵称</string> <string name="room_password">房间密码</string>
<string name="room_password">聊天室密码(可选)</string> <string name="leave_room">离开房间</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_hint">输入消息…</string>
<string name="chat_send">发送</string> <string name="chat_send">发送</string>
<string name="chat_attach">附件</string> <string name="chat_attach">加文</string>
<string name="chat_record_audio"></string> <string name="chat_record_audio">制音频</string>
<string name="chat_stop_recording">停止录音</string>
<string name="chat_send_audio">发送音频</string> <string name="chat_send_audio">发送音频</string>
<string name="chat_cancel_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_encryption_select">选择加密方式</string>
<string name="chat_message_delivered">已送达</string> <string name="chat_message_delivered">已送达</string>
<string name="chat_message_read">已读</string> <string name="chat_message_read">已读</string>
@@ -75,17 +54,9 @@
<string name="chat_message_failed">发送失败</string> <string name="chat_message_failed">发送失败</string>
<string name="chat_typing">%1$s 正在输入…</string> <string name="chat_typing">%1$s 正在输入…</string>
<string name="chat_media_image">图片</string> <string name="chat_media_image">图片</string>
<string name="chat_media_video">视频</string>
<string name="chat_media_audio">音频</string> <string name="chat_media_audio">音频</string>
<string name="chat_media_file">文件</string> <string name="chat_media_file">文件</string>
<string name="chat_media_uploading">上传中…</string> <string name="chat_empty">还没有消息。\n说声你好吧</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_title">设置</string>
@@ -95,62 +66,31 @@
<string name="settings_theme_light">浅色</string> <string name="settings_theme_light">浅色</string>
<string name="settings_theme_dark">深色</string> <string name="settings_theme_dark">深色</string>
<string name="settings_language">语言</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">通知</string>
<string name="settings_notifications_messages">消息通知</string> <string name="settings_notifications_messages">消息通知</string>
<string name="settings_notifications_vibrate"></string> <string name="settings_notifications_vibrate"></string>
<string name="settings_notifications_sound">声音</string> <string name="settings_notifications_sound">声音</string>
<string name="settings_encryption">加密</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_default_encryption">默认加密方式</string>
<string name="settings_about">关于</string> <string name="settings_about">关于</string>
<string name="settings_version">版本</string> <string name="settings_version">版本</string>
<!-- 通用 --> <!-- 通用 -->
<string name="ok">确定</string>
<string name="cancel">取消</string> <string name="cancel">取消</string>
<string name="save">保存</string> <string name="save">保存</string>
<string name="delete">删除</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="close">关闭</string>
<string name="search">搜索</string>
<string name="clear">清除</string>
<string name="back">返回</string> <string name="back">返回</string>
<string name="more_options">更多选项</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">消息</string>
<string name="notification_channel_messages_desc">收到的聊天消息</string> <string name="notification_channel_messages_desc">传入的聊天消息</string>
<string name="notification_channel_service">XMPP 服务</string> <string name="notification_channel_service">XMPP 服务</string>
<string name="notification_channel_service_desc">后台 XMPP 连接</string> <string name="notification_channel_service_desc">后台 XMPP 连接</string>
<string name="notification_service_running">AleJabber 已连接</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_avatar">%1$s 的头像</string>
<string name="cd_status_indicator">状态:%1$s</string>
<string name="cd_encryption_badge">加密:%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> </resources>

Ver fichero

@@ -1,10 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="purple_200">#FFBB86FC</color> <!-- Launcher icon background color -->
<color name="purple_500">#FF6200EE</color> <color name="ic_launcher_background">#FF1E3A8A</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> </resources>

Ver fichero

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

Ver fichero

@@ -2,7 +2,6 @@
<string name="app_name">AleJabber</string> <string name="app_name">AleJabber</string>
<!-- Navigation --> <!-- Navigation -->
<string name="nav_contacts">Contacts</string>
<string name="nav_rooms">Rooms</string> <string name="nav_rooms">Rooms</string>
<string name="nav_accounts">Accounts</string> <string name="nav_accounts">Accounts</string>
<string name="nav_settings">Settings</string> <string name="nav_settings">Settings</string>
@@ -17,12 +16,6 @@
<string name="account_server">Server</string> <string name="account_server">Server</string>
<string name="account_port">Port</string> <string name="account_port">Port</string>
<string name="account_use_tls">Use TLS</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_no_accounts">No accounts configured.\nTap + to add one.</string>
<string name="account_delete_confirm">Delete account %1$s?</string> <string name="account_delete_confirm">Delete account %1$s?</string>
<string name="account_jid_hint">user@example.com</string> <string name="account_jid_hint">user@example.com</string>
@@ -36,38 +29,23 @@
<string name="add_contact">Add Contact</string> <string name="add_contact">Add Contact</string>
<string name="contact_jid">Jabber ID (JID)</string> <string name="contact_jid">Jabber ID (JID)</string>
<string name="contact_nickname">Nickname</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 --> <!-- Rooms / MUC -->
<string name="rooms_title">Rooms</string> <string name="rooms_title">Rooms</string>
<string name="rooms_search">Search rooms…</string>
<string name="rooms_empty">No rooms joined yet.</string> <string name="rooms_empty">No rooms joined yet.</string>
<string name="join_room">Join Room</string> <string name="join_room">Join Room</string>
<string name="room_jid">Room JID</string> <string name="room_jid">Room JID</string>
<string name="room_nickname">Your Nickname</string> <string name="room_nickname">Your Nickname</string>
<string name="room_password">Room Password (optional)</string> <string name="room_password">Room Password</string>
<string name="leave_room">Leave Room</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 --> <!-- Chat -->
<string name="chat_hint">Type a message…</string> <string name="chat_hint">Type a message…</string>
<string name="chat_send">Send</string> <string name="chat_send">Send</string>
<string name="chat_attach">Attach file</string> <string name="chat_attach">Attach file</string>
<string name="chat_record_audio">Record audio</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_send_audio">Send audio</string>
<string name="chat_cancel_audio">Cancel 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_encryption_select">Select encryption</string>
<string name="chat_message_delivered">Delivered</string> <string name="chat_message_delivered">Delivered</string>
<string name="chat_message_read">Read</string> <string name="chat_message_read">Read</string>
@@ -75,17 +53,9 @@
<string name="chat_message_failed">Failed to send</string> <string name="chat_message_failed">Failed to send</string>
<string name="chat_typing">%1$s is typing…</string> <string name="chat_typing">%1$s is typing…</string>
<string name="chat_media_image">Image</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_audio">Audio</string>
<string name="chat_media_file">File</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_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 --> <!-- Settings -->
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>
@@ -95,62 +65,31 @@
<string name="settings_theme_light">Light</string> <string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</string> <string name="settings_theme_dark">Dark</string>
<string name="settings_language">Language</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">Notifications</string>
<string name="settings_notifications_messages">Message notifications</string> <string name="settings_notifications_messages">Message notifications</string>
<string name="settings_notifications_vibrate">Vibrate</string> <string name="settings_notifications_vibrate">Vibrate</string>
<string name="settings_notifications_sound">Sound</string> <string name="settings_notifications_sound">Sound</string>
<string name="settings_encryption">Encryption</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_default_encryption">Default encryption</string>
<string name="settings_about">About</string> <string name="settings_about">About</string>
<string name="settings_version">Version</string> <string name="settings_version">Version</string>
<!-- Common --> <!-- Common -->
<string name="ok">OK</string>
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<string name="save">Save</string> <string name="save">Save</string>
<string name="delete">Delete</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="close">Close</string>
<string name="search">Search</string>
<string name="clear">Clear</string>
<string name="back">Back</string> <string name="back">Back</string>
<string name="more_options">More options</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 --> <!-- Notifications -->
<string name="notification_channel_messages">Messages</string> <string name="notification_channel_messages">Messages</string>
<string name="notification_channel_messages_desc">Incoming chat 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">XMPP Service</string>
<string name="notification_channel_service_desc">Background XMPP connection</string> <string name="notification_channel_service_desc">Background XMPP connection</string>
<string name="notification_service_running">AleJabber is connected</string> <string name="notification_service_running">AleJabber is connected</string>
<string name="notification_new_message">New message from %1$s</string>
<!-- Accessibility --> <!-- Accessibility -->
<string name="cd_avatar">%1$s\'s avatar</string> <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_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> </resources>

Ver fichero

@@ -6,7 +6,7 @@ junitVersion = "1.3.0"
espressoCore = "3.7.0" espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.4" activityCompose = "1.12.4"
kotlin = "2.0.21" kotlin = "2.1.21"
composeBom = "2024.09.00" composeBom = "2024.09.00"
hilt = "2.59.2" hilt = "2.59.2"
room = "2.7.1" room = "2.7.1"
@@ -15,11 +15,11 @@ datastore = "1.1.7"
coil = "2.7.0" coil = "2.7.0"
smack = "4.4.8" smack = "4.4.8"
okhttp = "4.12.0" okhttp = "4.12.0"
coroutines = "1.9.0" coroutines = "1.10.2"
viewmodelCompose = "2.10.0" viewmodelCompose = "2.10.0"
materialIconsExtended = "1.7.8" materialIconsExtended = "1.7.8"
bouncycastle = "1.78.1" bouncycastle = "1.83"
accompanist = "0.36.0" accompanist = "0.37.3"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }