encryption
Algunas comprobaciones han fallado
Build & Publish APK Release / build (push) Failing after 6m34s

Signed-off-by: ale <ale@manalejandro.com>
Este commit está contenido en:
ale
2026-02-28 03:16:02 +01:00
padre 8da2f079b2
commit eb69fa8b9b
Se han modificado 14 ficheros con 1552 adiciones y 143 borrados

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,13 +129,15 @@ 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. */
data class BottomNavItem( data class BottomNavItem(

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

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

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

@@ -58,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
@@ -81,6 +82,8 @@ fun ChatScreen(
val listState = rememberLazyListState() val listState = rememberLazyListState()
val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO) val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO)
val clipboard = LocalClipboard.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) }
@@ -96,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()) {
@@ -115,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
) )
@@ -151,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
@@ -271,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
) )
@@ -861,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
) { ) {
@@ -870,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,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
@@ -187,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 ───────────────────────────────────────────

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

@@ -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 { 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 { viewModelScope.launch {
dataStore.edit { it[KEY_DEFAULT_ENCRYPTION] = type.name } 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) }
}