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.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Forum
import androidx.compose.material.icons.filled.ManageAccounts
@@ -127,11 +129,13 @@ fun MainAppContent() {
}
}
}
) { _ ->
AleJabberNavGraph(
navController = navController,
startDestination = Screen.Accounts.route
)
) { paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) {
AleJabberNavGraph(
navController = navController,
startDestination = Screen.Accounts.route
)
}
}
}

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
)
/** A contact has requested subscription (wants to see our presence). */
data class SubscriptionRequest(
val accountId: Long,
val fromJid: String
)
@Singleton
class XmppConnectionManager @Inject constructor() {
@@ -76,6 +82,10 @@ class XmppConnectionManager @Inject constructor() {
private val _presenceUpdates = MutableSharedFlow<PresenceUpdate>(extraBufferCapacity = 64)
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) {
@@ -161,6 +171,54 @@ class XmppConnectionManager @Inject constructor() {
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 {
@@ -185,6 +243,23 @@ class XmppConnectionManager @Inject constructor() {
private fun setupRoster(accountId: Long, connection: AbstractXMPPConnection) {
val roster = Roster.getInstanceFor(connection)
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 ──────
scope.launch {

Ver fichero

@@ -70,5 +70,9 @@ class MessageRepository @Inject constructor(
suspend fun clearConversation(accountId: Long, conversationJid: String) =
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())
listenForIncomingMessages()
listenForPresenceUpdates()
listenForSubscriptionRequests()
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) {
val intent = Intent(this, MainActivity::class.java).apply {
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.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.manalejandro.alejabber.data.remote.EncryptionManager
import com.manalejandro.alejabber.R
import com.manalejandro.alejabber.domain.model.*
import com.manalejandro.alejabber.media.RecordingState
@@ -81,6 +82,8 @@ fun ChatScreen(
val listState = rememberLazyListState()
val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO)
val clipboard = LocalClipboard.current
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
// Message selected via long-press → shows the action bottom sheet
var selectedMessage by remember { mutableStateOf<Message?>(null) }
@@ -96,6 +99,21 @@ fun ChatScreen(
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
LaunchedEffect(uiState.messages.size) {
if (uiState.messages.isNotEmpty()) {
@@ -114,10 +132,10 @@ fun ChatScreen(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
AvatarWithStatus(
name = uiState.contactName,
avatarUrl = null,
name = uiState.contactName,
avatarUrl = uiState.contactAvatarUrl,
presence = uiState.contactPresence,
size = 36.dp
size = 36.dp
)
Spacer(Modifier.width(10.dp))
Column {
@@ -151,7 +169,8 @@ fun ChatScreen(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
},
snackbarHost = { SnackbarHost(snackbarHostState) }
// No bottomBar — input is placed inside the content column so imePadding works
) { padding ->
// imePadding() here at the column level makes the whole content
@@ -270,9 +289,13 @@ fun ChatScreen(
// ── Encryption picker ─────────────────────────────────────────────────
if (uiState.showEncryptionPicker) {
EncryptionPickerDialog(
current = uiState.encryptionType,
onSelect = viewModel::setEncryption,
onDismiss = viewModel::toggleEncryptionPicker
current = uiState.encryptionType,
omemoState = uiState.omemoState,
pgpHasOwn = uiState.pgpHasOwnKey,
pgpHasCont = uiState.pgpHasContactKey,
otrActive = uiState.otrActive,
onSelect = viewModel::setEncryption,
onDismiss = viewModel::toggleEncryptionPicker
)
}
@@ -861,6 +884,10 @@ fun ChatInput(
@Composable
fun EncryptionPickerDialog(
current: EncryptionType,
omemoState: EncryptionManager.OmemoState,
pgpHasOwn: Boolean,
pgpHasCont: Boolean,
otrActive: Boolean,
onSelect: (EncryptionType) -> Unit,
onDismiss: () -> Unit
) {
@@ -870,21 +897,57 @@ fun EncryptionPickerDialog(
text = {
Column {
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(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.pointerInput(type) {
detectTapGestures { onSelect(type) }
detectTapGestures { if (enabled) onSelect(type) }
}
.padding(12.dp),
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))
Column {
Text(type.toDisplayName(), fontWeight = FontWeight.Medium)
Text(type.toDescription(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(type.toDisplayName(), fontWeight = FontWeight.Medium)
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 androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.manalejandro.alejabber.data.remote.EncryptionManager
import com.manalejandro.alejabber.data.repository.ContactRepository
import com.manalejandro.alejabber.data.repository.MessageRepository
import com.manalejandro.alejabber.domain.model.*
@@ -18,6 +19,7 @@ data class ChatUiState(
val messages: List<Message> = emptyList(),
val contactName: String = "",
val contactPresence: PresenceStatus = PresenceStatus.OFFLINE,
val contactAvatarUrl: String? = null,
val inputText: String = "",
val encryptionType: EncryptionType = EncryptionType.NONE,
val isTyping: Boolean = false,
@@ -25,7 +27,12 @@ data class ChatUiState(
val recordingState: RecordingState = RecordingState.IDLE,
val recordingDurationMs: Long = 0,
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
@@ -33,7 +40,8 @@ class ChatViewModel @Inject constructor(
private val messageRepository: MessageRepository,
private val contactRepository: ContactRepository,
private val httpUploadManager: HttpUploadManager,
private val audioRecorder: AudioRecorder
private val audioRecorder: AudioRecorder,
private val encryptionManager: EncryptionManager
) : ViewModel() {
private val _uiState = MutableStateFlow(ChatUiState())
@@ -47,30 +55,26 @@ class ChatViewModel @Inject constructor(
currentJid = jid
viewModelScope.launch {
// Load messages
messageRepository.getMessages(accountId, jid).collect { messages ->
_uiState.update { it.copy(messages = messages) }
}
}
viewModelScope.launch {
// Load contact info
contactRepository.getContacts(accountId)
.take(1)
.collect { contacts ->
val contact = contacts.find { it.jid == jid }
_uiState.update {
it.copy(
contactName = contact?.nickname?.ifBlank { jid } ?: jid,
contactPresence = contact?.presence ?: PresenceStatus.OFFLINE
)
}
// Keep contact info (name, presence, avatar) live
contactRepository.getContacts(accountId).collect { contacts ->
val contact = contacts.find { it.jid == jid }
_uiState.update {
it.copy(
contactName = contact?.nickname?.ifBlank { jid } ?: jid,
contactPresence = contact?.presence ?: PresenceStatus.OFFLINE,
contactAvatarUrl = contact?.avatarUrl
)
}
}
}
viewModelScope.launch {
// Mark as read
messageRepository.markAllAsRead(accountId, jid)
}
// Observe recording state
viewModelScope.launch {
audioRecorder.state.collect { state ->
_uiState.update { it.copy(recordingState = state) }
@@ -81,6 +85,26 @@ class ChatViewModel @Inject constructor(
_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) }
@@ -90,12 +114,50 @@ class ChatViewModel @Inject constructor(
if (text.isBlank()) return
_uiState.update { it.copy(inputText = "") }
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(
accountId = currentAccountId,
toJid = currentJid,
body = text,
encryptionType = _uiState.value.encryptionType
accountId = currentAccountId,
toJid = currentJid,
body = body,
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) }
}
}
@@ -106,9 +168,9 @@ class ChatViewModel @Inject constructor(
val url = httpUploadManager.uploadFile(currentAccountId, uri)
if (url != null) {
messageRepository.sendMessage(
accountId = currentAccountId,
toJid = currentJid,
body = url,
accountId = currentAccountId,
toJid = currentJid,
body = url,
encryptionType = _uiState.value.encryptionType
)
}
@@ -130,9 +192,9 @@ class ChatViewModel @Inject constructor(
val url = httpUploadManager.uploadFile(currentAccountId, file, "audio/mp4")
if (url != null) {
messageRepository.sendMessage(
accountId = currentAccountId,
toJid = currentJid,
body = url,
accountId = currentAccountId,
toJid = currentJid,
body = url,
encryptionType = _uiState.value.encryptionType
)
}
@@ -147,8 +209,47 @@ class ChatViewModel @Inject constructor(
fun cancelRecording() = audioRecorder.cancelRecording()
fun setEncryption(type: EncryptionType) = _uiState.update {
it.copy(encryptionType = type, showEncryptionPicker = false)
fun setEncryption(type: EncryptionType) {
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 {
@@ -160,6 +261,5 @@ class ChatViewModel @Inject constructor(
}
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
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -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 ───────────────────────────────────────────

Ver fichero

@@ -2,6 +2,7 @@ package com.manalejandro.alejabber.ui.contacts
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.manalejandro.alejabber.data.remote.XmppConnectionManager
import com.manalejandro.alejabber.data.repository.AccountRepository
import com.manalejandro.alejabber.data.repository.ContactRepository
import com.manalejandro.alejabber.domain.model.Contact
@@ -31,14 +32,18 @@ data class ContactsUiState(
val isLoading: Boolean = true,
val searchQuery: String = "",
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)
@HiltViewModel
class ContactsViewModel @Inject constructor(
private val accountRepository: AccountRepository,
private val contactRepository: ContactRepository
private val contactRepository: ContactRepository,
private val xmppManager: XmppConnectionManager
) : ViewModel() {
private val _uiState = MutableStateFlow(ContactsUiState())
@@ -46,6 +51,20 @@ class ContactsViewModel @Inject constructor(
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].
* 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

@@ -63,7 +63,7 @@ fun AleJabberNavGraph(
startDestination: String = Screen.Accounts.route
) {
NavHost(
navController = navController,
navController = navController,
startDestination = startDestination,
enterTransition = {
slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(280))

Ver fichero

@@ -1,5 +1,8 @@
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.layout.*
import androidx.compose.foundation.rememberScrollState
@@ -12,6 +15,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -28,8 +32,40 @@ fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showThemeDialog by remember { mutableStateOf(false) }
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
var showThemeDialog 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(
topBar = {
@@ -41,7 +77,8 @@ fun SettingsScreen(
}
}
)
}
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
Column(
modifier = Modifier
@@ -49,71 +86,213 @@ fun SettingsScreen(
.padding(padding)
.verticalScroll(rememberScrollState())
) {
// Appearance section
// ── Appearance ────────────────────────────────────────────────
SettingsSectionHeader(stringResource(R.string.settings_appearance))
SettingsItem(
icon = Icons.Default.Palette,
title = stringResource(R.string.settings_theme),
icon = Icons.Default.Palette,
title = stringResource(R.string.settings_theme),
subtitle = uiState.appTheme.toDisplayName(),
onClick = { showThemeDialog = true }
onClick = { showThemeDialog = true }
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// Notifications section
// ── Notifications ─────────────────────────────────────────────
SettingsSectionHeader(stringResource(R.string.settings_notifications))
SettingsSwitchItem(
icon = Icons.Default.Notifications,
title = stringResource(R.string.settings_notifications_messages),
checked = uiState.notificationsEnabled,
icon = Icons.Default.Notifications,
title = stringResource(R.string.settings_notifications_messages),
checked = uiState.notificationsEnabled,
onCheckedChange = viewModel::setNotifications
)
SettingsSwitchItem(
icon = Icons.Default.Vibration,
title = stringResource(R.string.settings_notifications_vibrate),
checked = uiState.vibrateEnabled,
icon = Icons.Default.Vibration,
title = stringResource(R.string.settings_notifications_vibrate),
checked = uiState.vibrateEnabled,
onCheckedChange = viewModel::setVibrate
)
SettingsSwitchItem(
icon = Icons.AutoMirrored.Filled.VolumeUp,
title = stringResource(R.string.settings_notifications_sound),
checked = uiState.soundEnabled,
icon = Icons.AutoMirrored.Filled.VolumeUp,
title = stringResource(R.string.settings_notifications_sound),
checked = uiState.soundEnabled,
onCheckedChange = viewModel::setSound
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// Encryption section
// ── Encryption — default ──────────────────────────────────────
SettingsSectionHeader(stringResource(R.string.settings_encryption))
SettingsItem(
icon = Icons.Default.Lock,
title = stringResource(R.string.settings_default_encryption),
icon = Icons.Default.Lock,
title = stringResource(R.string.settings_default_encryption),
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))
// About section
// ── About ─────────────────────────────────────────────────────
SettingsSectionHeader(stringResource(R.string.settings_about))
SettingsItem(
icon = Icons.Default.Info,
title = stringResource(R.string.settings_version),
icon = Icons.Default.Info,
title = stringResource(R.string.settings_version),
subtitle = "1.0.0",
onClick = {}
)
Spacer(Modifier.height(32.dp))
}
}
// Theme dialog
// ── Theme dialog ──────────────────────────────────────────────────────
if (showThemeDialog) {
AlertDialog(
onDismissRequest = { showThemeDialog = false },
@@ -124,17 +303,14 @@ fun SettingsScreen(
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
viewModel.setTheme(theme)
showThemeDialog = false
}
.clickable { viewModel.setTheme(theme); showThemeDialog = false }
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = uiState.appTheme == theme, onClick = {
viewModel.setTheme(theme)
showThemeDialog = false
})
RadioButton(
selected = uiState.appTheme == theme,
onClick = { viewModel.setTheme(theme); showThemeDialog = false }
)
Spacer(Modifier.width(12.dp))
Text(theme.toDisplayName())
}
@@ -147,7 +323,7 @@ fun SettingsScreen(
)
}
// Encryption default dialog
// ── Default encryption dialog ─────────────────────────────────────────
if (showEncryptionDialog) {
AlertDialog(
onDismissRequest = { showEncryptionDialog = false },
@@ -158,19 +334,18 @@ fun SettingsScreen(
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
viewModel.setDefaultEncryption(type)
showEncryptionDialog = false
}
.clickable { viewModel.setDefaultEncryption(type); showEncryptionDialog = false }
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = uiState.defaultEncryption == type, onClick = {
viewModel.setDefaultEncryption(type)
showEncryptionDialog = false
})
RadioButton(
selected = uiState.defaultEncryption == type,
onClick = { viewModel.setDefaultEncryption(type); showEncryptionDialog = false }
)
Spacer(Modifier.width(12.dp))
Text(type.name)
Column {
Text(type.name, fontWeight = FontWeight.Medium)
}
}
}
}
@@ -180,14 +355,126 @@ 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
fun SettingsSectionHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(start = 16.dp, top = 20.dp, bottom = 4.dp)
)
@@ -201,14 +488,12 @@ fun SettingsItem(
onClick: () -> Unit
) {
ListItem(
headlineContent = { Text(title) },
headlineContent = { Text(title) },
supportingContent = if (subtitle.isNotBlank()) {
{ Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
} else null,
leadingContent = {
Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
},
modifier = Modifier.clickable(onClick = onClick)
leadingContent = { Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) },
modifier = Modifier.clickable(onClick = onClick)
)
}
@@ -221,20 +506,13 @@ fun SettingsSwitchItem(
) {
ListItem(
headlineContent = { Text(title) },
leadingContent = {
Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
},
trailingContent = {
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
leadingContent = { Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) },
trailingContent = { Switch(checked = checked, onCheckedChange = onCheckedChange) }
)
}
fun AppTheme.toDisplayName(): String = when (this) {
AppTheme.SYSTEM -> "System Default"
AppTheme.LIGHT -> "Light"
AppTheme.DARK -> "Dark"
AppTheme.LIGHT -> "Light"
AppTheme.DARK -> "Dark"
}

Ver fichero

@@ -7,6 +7,8 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.lifecycle.ViewModel
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.ui.theme.AppTheme
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -19,12 +21,22 @@ data class SettingsUiState(
val notificationsEnabled: Boolean = true,
val vibrateEnabled: 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
class SettingsViewModel @Inject constructor(
private val dataStore: DataStore<Preferences>
private val dataStore: DataStore<Preferences>,
private val encryptionManager: EncryptionManager
) : ViewModel() {
companion object {
@@ -43,45 +55,95 @@ class SettingsViewModel @Inject constructor(
dataStore.data.collect { prefs ->
_uiState.update { state ->
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,
vibrateEnabled = prefs[KEY_VIBRATE] ?: 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) {
viewModelScope.launch {
dataStore.edit { it[KEY_THEME] = theme.name }
}
viewModelScope.launch { dataStore.edit { it[KEY_THEME] = theme.name } }
}
fun setNotifications(enabled: Boolean) {
viewModelScope.launch {
dataStore.edit { it[KEY_NOTIFICATIONS] = enabled }
}
viewModelScope.launch { dataStore.edit { it[KEY_NOTIFICATIONS] = enabled } }
}
fun setVibrate(enabled: Boolean) {
viewModelScope.launch {
dataStore.edit { it[KEY_VIBRATE] = enabled }
}
viewModelScope.launch { dataStore.edit { it[KEY_VIBRATE] = enabled } }
}
fun setSound(enabled: Boolean) {
viewModelScope.launch {
dataStore.edit { it[KEY_SOUND] = enabled }
}
viewModelScope.launch { dataStore.edit { it[KEY_SOUND] = enabled } }
}
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 {
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) }
}