diff --git a/app/src/main/java/com/manalejandro/alejabber/MainActivity.kt b/app/src/main/java/com/manalejandro/alejabber/MainActivity.kt index e94bab5..9c43513 100644 --- a/app/src/main/java/com/manalejandro/alejabber/MainActivity.kt +++ b/app/src/main/java/com/manalejandro/alejabber/MainActivity.kt @@ -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 + ) + } } } diff --git a/app/src/main/java/com/manalejandro/alejabber/data/remote/EncryptionManager.kt b/app/src/main/java/com/manalejandro/alejabber/data/remote/EncryptionManager.kt new file mode 100644 index 0000000..7e69cf1 --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/data/remote/EncryptionManager.kt @@ -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>(emptyMap()) + val omemoState: StateFlow> = _omemoState.asStateFlow() + + private var omemoServiceSetup = false + + // ── OTR sessions: (accountId, bareJid) → OtrSession ────────────────── + private val otrSessions = mutableMapOf, 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 { + 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 { + 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 { + 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 { + 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}" + } + } +} diff --git a/app/src/main/java/com/manalejandro/alejabber/data/remote/OtrSession.kt b/app/src/main/java/com/manalejandro/alejabber/data/remote/OtrSession.kt new file mode 100644 index 0000000..013308c --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/data/remote/OtrSession.kt @@ -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 // (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() + } +} + + diff --git a/app/src/main/java/com/manalejandro/alejabber/data/remote/PgpManager.kt b/app/src/main/java/com/manalejandro/alejabber/data/remote/PgpManager.kt new file mode 100644 index 0000000..2c1b53f --- /dev/null +++ b/app/src/main/java/com/manalejandro/alejabber/data/remote/PgpManager.kt @@ -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/.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 = + 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() + .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._@-]"), "_") +} + + + + diff --git a/app/src/main/java/com/manalejandro/alejabber/data/remote/XmppConnectionManager.kt b/app/src/main/java/com/manalejandro/alejabber/data/remote/XmppConnectionManager.kt index ab59b7e..26d569a 100644 --- a/app/src/main/java/com/manalejandro/alejabber/data/remote/XmppConnectionManager.kt +++ b/app/src/main/java/com/manalejandro/alejabber/data/remote/XmppConnectionManager.kt @@ -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(extraBufferCapacity = 64) val presenceUpdates: SharedFlow = _presenceUpdates.asSharedFlow() + // ── Incoming subscription requests ─────────────────────────────────── + private val _subscriptionRequests = MutableSharedFlow(extraBufferCapacity = 32) + val subscriptionRequests: SharedFlow = _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 { diff --git a/app/src/main/java/com/manalejandro/alejabber/data/repository/MessageRepository.kt b/app/src/main/java/com/manalejandro/alejabber/data/repository/MessageRepository.kt index 293be54..cf9b483 100644 --- a/app/src/main/java/com/manalejandro/alejabber/data/repository/MessageRepository.kt +++ b/app/src/main/java/com/manalejandro/alejabber/data/repository/MessageRepository.kt @@ -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()) } diff --git a/app/src/main/java/com/manalejandro/alejabber/service/XmppForegroundService.kt b/app/src/main/java/com/manalejandro/alejabber/service/XmppForegroundService.kt index 3de2b48..3ac4f8f 100644 --- a/app/src/main/java/com/manalejandro/alejabber/service/XmppForegroundService.kt +++ b/app/src/main/java/com/manalejandro/alejabber/service/XmppForegroundService.kt @@ -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 diff --git a/app/src/main/java/com/manalejandro/alejabber/ui/chat/ChatScreen.kt b/app/src/main/java/com/manalejandro/alejabber/ui/chat/ChatScreen.kt index 8c392b0..7f8b4d8 100644 --- a/app/src/main/java/com/manalejandro/alejabber/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/manalejandro/alejabber/ui/chat/ChatScreen.kt @@ -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(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 + ) } } } diff --git a/app/src/main/java/com/manalejandro/alejabber/ui/chat/ChatViewModel.kt b/app/src/main/java/com/manalejandro/alejabber/ui/chat/ChatViewModel.kt index 33f51a0..ad7d5ce 100644 --- a/app/src/main/java/com/manalejandro/alejabber/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/manalejandro/alejabber/ui/chat/ChatViewModel.kt @@ -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 = 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) } } - - diff --git a/app/src/main/java/com/manalejandro/alejabber/ui/contacts/ContactsScreen.kt b/app/src/main/java/com/manalejandro/alejabber/ui/contacts/ContactsScreen.kt index 6787ce9..fbfddf9 100644 --- a/app/src/main/java/com/manalejandro/alejabber/ui/contacts/ContactsScreen.kt +++ b/app/src/main/java/com/manalejandro/alejabber/ui/contacts/ContactsScreen.kt @@ -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 ─────────────────────────────────────────── diff --git a/app/src/main/java/com/manalejandro/alejabber/ui/contacts/ContactsViewModel.kt b/app/src/main/java/com/manalejandro/alejabber/ui/contacts/ContactsViewModel.kt index 26fba48..72bce65 100644 --- a/app/src/main/java/com/manalejandro/alejabber/ui/contacts/ContactsViewModel.kt +++ b/app/src/main/java/com/manalejandro/alejabber/ui/contacts/ContactsViewModel.kt @@ -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) } + } } diff --git a/app/src/main/java/com/manalejandro/alejabber/ui/navigation/NavGraph.kt b/app/src/main/java/com/manalejandro/alejabber/ui/navigation/NavGraph.kt index eace68a..e4b6924 100644 --- a/app/src/main/java/com/manalejandro/alejabber/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/manalejandro/alejabber/ui/navigation/NavGraph.kt @@ -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)) diff --git a/app/src/main/java/com/manalejandro/alejabber/ui/settings/SettingsScreen.kt b/app/src/main/java/com/manalejandro/alejabber/ui/settings/SettingsScreen.kt index 3a12045..4a03bcc 100644 --- a/app/src/main/java/com/manalejandro/alejabber/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/manalejandro/alejabber/ui/settings/SettingsScreen.kt @@ -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(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" } - - - diff --git a/app/src/main/java/com/manalejandro/alejabber/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/manalejandro/alejabber/ui/settings/SettingsViewModel.kt index bbbca0c..2db9f1d 100644 --- a/app/src/main/java/com/manalejandro/alejabber/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/manalejandro/alejabber/ui/settings/SettingsViewModel.kt @@ -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 = emptyList(), // JIDs with stored pub key + val pgpError: String? = null, + val pgpInfo: String? = null ) @HiltViewModel class SettingsViewModel @Inject constructor( - private val dataStore: DataStore + private val dataStore: DataStore, + 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) } +}