Comparar commits
2 Commits
| Autor | SHA1 | Fecha | |
|---|---|---|---|
|
7f6c69c265
|
|||
|
eb69fa8b9b
|
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
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.launch
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import 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.
|
||||
*
|
||||
* ## OTR handshake protocol
|
||||
* OTR requires an ephemeral ECDH key exchange before any message can be encrypted.
|
||||
* The handshake uses two special XMPP message bodies:
|
||||
*
|
||||
* Initiator → Responder: `?OTR-INIT:<base64(pubkey)>`
|
||||
* Responder → Initiator: `?OTR-ACK:<base64(pubkey)>`
|
||||
*
|
||||
* On receiving ACK/INIT the session keys are derived and subsequent messages
|
||||
* are encrypted as `?OTR:<base64(nonce|ciphertext|hmac)>`.
|
||||
*
|
||||
* Incoming OTR control messages are intercepted in [handleIncomingMessage]
|
||||
* (called from XmppForegroundService / XmppConnectionManager) before they
|
||||
* reach the UI, so the user never sees the raw key exchange strings.
|
||||
*/
|
||||
@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())
|
||||
|
||||
init {
|
||||
// Register OTR message interceptor so handshake messages are processed
|
||||
// transparently and never reach the chat UI as raw text.
|
||||
xmppManager.setMessageInterceptor { accountId, from, body ->
|
||||
handleIncomingMessage(accountId, from, body)
|
||||
}
|
||||
}
|
||||
|
||||
// ── OMEMO ────────────────────────────────────────────────────────────
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Lifecycle of an OTR session:
|
||||
* IDLE → no session
|
||||
* AWAITING_ACK → we sent INIT, waiting for the remote ACK
|
||||
* AWAITING_INIT → we received INIT, sent ACK, waiting to confirm
|
||||
* ESTABLISHED → ECDH complete, can encrypt/decrypt
|
||||
*/
|
||||
enum class OtrHandshakeState { AWAITING_ACK, AWAITING_INIT, ESTABLISHED }
|
||||
|
||||
private data class OtrEntry(
|
||||
val session: OtrSession,
|
||||
var state: OtrHandshakeState
|
||||
)
|
||||
|
||||
private val otrEntries = mutableMapOf<Pair<Long, String>, OtrEntry>()
|
||||
|
||||
/** Emitted whenever an OTR session reaches ESTABLISHED. */
|
||||
data class OtrStateEvent(val accountId: Long, val jid: String, val state: OtrHandshakeState)
|
||||
|
||||
private val _otrStateChanges = MutableSharedFlow<OtrStateEvent>(extraBufferCapacity = 16)
|
||||
val otrStateChanges: SharedFlow<OtrStateEvent> = _otrStateChanges.asSharedFlow()
|
||||
|
||||
// ── OpenPGP ───────────────────────────────────────────────────────────
|
||||
private val pgpManager = PgpManager(context)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// OMEMO
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
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) }
|
||||
with(scope) {
|
||||
launch {
|
||||
try {
|
||||
if (!omemoServiceSetup) {
|
||||
SignalOmemoService.acknowledgeLicense()
|
||||
SignalOmemoService.setup()
|
||||
omemoServiceSetup = true
|
||||
}
|
||||
val omemoManager = OmemoManager.getInstanceFor(connection)
|
||||
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 */ }
|
||||
})
|
||||
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
|
||||
val encScope = scope
|
||||
encScope.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
|
||||
val encScope = scope
|
||||
encScope.launch { xmppManager.dispatchDecryptedOmemoMessage(accountId, from, body) }
|
||||
}
|
||||
})
|
||||
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)
|
||||
}
|
||||
} // launch
|
||||
} // with(scope)
|
||||
}
|
||||
|
||||
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 (_: Exception) { null }
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// OTR — public API
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Initiates an OTR key exchange with [toJid].
|
||||
* Sends `?OTR-INIT:<base64pubkey>` and waits for the ACK.
|
||||
* Returns a user-visible status string.
|
||||
*/
|
||||
fun startOtrSession(accountId: Long, toJid: String): String {
|
||||
val key = accountId to toJid
|
||||
// If already established, no-op
|
||||
val existing = otrEntries[key]
|
||||
if (existing?.state == OtrHandshakeState.ESTABLISHED) return "OTR session already active."
|
||||
|
||||
val session = OtrSession()
|
||||
otrEntries[key] = OtrEntry(session, OtrHandshakeState.AWAITING_ACK)
|
||||
|
||||
val pubKeyB64 = session.getPublicKeyBase64()
|
||||
val initMsg = "?OTR-INIT:$pubKeyB64"
|
||||
val sent = xmppManager.sendMessage(accountId, toJid, initMsg)
|
||||
return if (sent) "OTR key exchange started — waiting for the other side…"
|
||||
else "OTR: could not send key exchange message."
|
||||
}
|
||||
|
||||
fun endOtrSession(accountId: Long, toJid: String) {
|
||||
otrEntries.remove(accountId to toJid)
|
||||
Log.i(TAG, "OTR session ended with $toJid")
|
||||
}
|
||||
|
||||
/** True only when the ECDH handshake is complete and messages can be encrypted. */
|
||||
fun isOtrSessionEstablished(accountId: Long, toJid: String): Boolean =
|
||||
otrEntries[accountId to toJid]?.state == OtrHandshakeState.ESTABLISHED
|
||||
|
||||
fun isOtrSessionActive(accountId: Long, toJid: String): Boolean =
|
||||
otrEntries.containsKey(accountId to toJid)
|
||||
|
||||
fun getOtrHandshakeState(accountId: Long, toJid: String): OtrHandshakeState? =
|
||||
otrEntries[accountId to toJid]?.state
|
||||
|
||||
/**
|
||||
* Inspects an incoming message body for OTR control strings.
|
||||
*
|
||||
* @return `true` if the message was an OTR control message (should NOT be shown in UI),
|
||||
* `false` if it is a normal/encrypted chat message to display.
|
||||
*/
|
||||
fun handleIncomingMessage(accountId: Long, fromJid: String, body: String): Boolean {
|
||||
return when {
|
||||
body.startsWith("?OTR-INIT:") -> {
|
||||
// Remote started a session — respond with our key and establish session
|
||||
val remoteKeyB64 = body.removePrefix("?OTR-INIT:")
|
||||
val key = accountId to fromJid
|
||||
val session = OtrSession()
|
||||
otrEntries[key] = OtrEntry(session, OtrHandshakeState.AWAITING_INIT)
|
||||
|
||||
try {
|
||||
val remoteKeyBytes = android.util.Base64.decode(remoteKeyB64, android.util.Base64.NO_WRAP)
|
||||
session.setRemotePublicKey(remoteKeyBytes)
|
||||
otrEntries[key]!!.state = OtrHandshakeState.ESTABLISHED
|
||||
scope.launch { _otrStateChanges.emit(OtrStateEvent(accountId, fromJid, OtrHandshakeState.ESTABLISHED)) }
|
||||
Log.i(TAG, "OTR session established with $fromJid (we responded)")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "OTR INIT key error from $fromJid", e)
|
||||
otrEntries.remove(key)
|
||||
}
|
||||
|
||||
// Always send our public key back as ACK
|
||||
val ackMsg = "?OTR-ACK:${session.getPublicKeyBase64()}"
|
||||
xmppManager.sendMessage(accountId, fromJid, ackMsg)
|
||||
true
|
||||
}
|
||||
|
||||
body.startsWith("?OTR-ACK:") -> {
|
||||
// Remote acknowledged our INIT — complete our side of the handshake
|
||||
val remoteKeyB64 = body.removePrefix("?OTR-ACK:")
|
||||
val key = accountId to fromJid
|
||||
val entry = otrEntries[key]
|
||||
if (entry == null) {
|
||||
Log.w(TAG, "Received OTR-ACK from $fromJid but no pending session")
|
||||
return true
|
||||
}
|
||||
try {
|
||||
val remoteKeyBytes = android.util.Base64.decode(remoteKeyB64, android.util.Base64.NO_WRAP)
|
||||
entry.session.setRemotePublicKey(remoteKeyBytes)
|
||||
entry.state = OtrHandshakeState.ESTABLISHED
|
||||
scope.launch { _otrStateChanges.emit(OtrStateEvent(accountId, fromJid, OtrHandshakeState.ESTABLISHED)) }
|
||||
Log.i(TAG, "OTR session established with $fromJid (we initiated)")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "OTR ACK key error from $fromJid", e)
|
||||
otrEntries.remove(key)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
body.startsWith("?OTR:") -> {
|
||||
// Encrypted OTR message — decrypt and let the caller save it
|
||||
false // caller handles it
|
||||
}
|
||||
|
||||
else -> false // plain text or other protocol
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts an incoming OTR-encrypted message body.
|
||||
* Returns the plaintext, or null if decryption fails.
|
||||
*/
|
||||
fun decryptOtrMessage(accountId: Long, fromJid: String, body: String): String? {
|
||||
val entry = otrEntries[accountId to fromJid] ?: return null
|
||||
if (entry.state != OtrHandshakeState.ESTABLISHED) return null
|
||||
return entry.session.decrypt(body)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// OpenPGP
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
fun pgpManager() = pgpManager
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Unified send
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
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)
|
||||
val stanza = omemoSentToMessage(encrypted, toJid)
|
||||
connection.sendStanza(stanza)
|
||||
true to null
|
||||
} catch (e: UndecidedOmemoIdentityException) {
|
||||
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."
|
||||
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}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun omemoSentToMessage(sent: OmemoMessage.Sent, toJid: String): Message {
|
||||
val bareJid = JidCreate.entityBareFrom(toJid)
|
||||
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) {}
|
||||
}
|
||||
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 entry = otrEntries[key]
|
||||
|
||||
// If no session at all, start the handshake and inform the user
|
||||
if (entry == null) {
|
||||
val notice = startOtrSession(accountId, toJid)
|
||||
return false to "OTR handshake initiated. $notice Please resend your message once the session is ready."
|
||||
}
|
||||
|
||||
// Handshake in progress — can't encrypt yet
|
||||
if (entry.state != OtrHandshakeState.ESTABLISHED) {
|
||||
return false to "OTR key exchange in progress — please wait a moment and try again."
|
||||
}
|
||||
|
||||
return try {
|
||||
val ciphertext = entry.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 {
|
||||
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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
185
app/src/main/java/com/manalejandro/alejabber/data/remote/OtrSession.kt
Archivo normal
185
app/src/main/java/com/manalejandro/alejabber/data/remote/OtrSession.kt
Archivo normal
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
206
app/src/main/java/com/manalejandro/alejabber/data/remote/PgpManager.kt
Archivo normal
206
app/src/main/java/com/manalejandro/alejabber/data/remote/PgpManager.kt
Archivo normal
@@ -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._@-]"), "_")
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,22 @@ 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()
|
||||
|
||||
/**
|
||||
* A message interceptor can consume an incoming message before it reaches
|
||||
* the [incomingMessages] flow. Returns true to suppress the message from
|
||||
* the flow (OTR control messages, etc.), false to let it through.
|
||||
*/
|
||||
private var messageInterceptor: ((accountId: Long, from: String, body: String) -> Boolean)? = null
|
||||
|
||||
/** Register a single message interceptor (replaces any previous one). */
|
||||
fun setMessageInterceptor(interceptor: (accountId: Long, from: String, body: String) -> Boolean) {
|
||||
messageInterceptor = interceptor
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
fun connect(account: Account) {
|
||||
@@ -161,6 +183,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 +255,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 {
|
||||
@@ -259,14 +346,19 @@ class XmppConnectionManager @Inject constructor() {
|
||||
val chatManager = ChatManager.getInstanceFor(connection)
|
||||
chatManager.addIncomingListener { from, message, _ ->
|
||||
val body = message.body ?: return@addIncomingListener
|
||||
val fromStr = from.asBareJid().toString()
|
||||
scope.launch {
|
||||
_incomingMessages.emit(
|
||||
IncomingMessage(
|
||||
accountId = accountId,
|
||||
from = from.asBareJid().toString(),
|
||||
body = body
|
||||
// Let interceptors (e.g. OTR handshake) consume the message first
|
||||
val consumed = messageInterceptor?.invoke(accountId, fromStr, body) ?: false
|
||||
if (!consumed) {
|
||||
_incomingMessages.emit(
|
||||
IncomingMessage(
|
||||
accountId = accountId,
|
||||
from = fromStr,
|
||||
body = body
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,5 +70,13 @@ class MessageRepository @Inject constructor(
|
||||
|
||||
suspend fun clearConversation(accountId: Long, conversationJid: String) =
|
||||
messageDao.clearConversation(accountId, conversationJid)
|
||||
|
||||
/** Updates the status of an already-persisted message (e.g. PENDING → SENT/FAILED). */
|
||||
suspend fun updateMessageStatus(id: Long, status: MessageStatus) =
|
||||
messageDao.updateStatus(id, status.name)
|
||||
|
||||
/** Persists an already-sent outgoing message (e.g. encrypted via EncryptionManager). */
|
||||
suspend fun saveOutgoingMessage(message: Message): Long =
|
||||
messageDao.insertMessage(message.toEntity())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,14 @@ 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,
|
||||
otrHandshakeState = uiState.otrHandshakeState,
|
||||
onSelect = viewModel::setEncryption,
|
||||
onDismiss = viewModel::toggleEncryptionPicker
|
||||
)
|
||||
}
|
||||
|
||||
@@ -861,6 +885,11 @@ fun ChatInput(
|
||||
@Composable
|
||||
fun EncryptionPickerDialog(
|
||||
current: EncryptionType,
|
||||
omemoState: EncryptionManager.OmemoState,
|
||||
pgpHasOwn: Boolean,
|
||||
pgpHasCont: Boolean,
|
||||
otrActive: Boolean,
|
||||
otrHandshakeState: EncryptionManager.OtrHandshakeState?,
|
||||
onSelect: (EncryptionType) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
@@ -870,21 +899,63 @@ 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 -> when {
|
||||
otrHandshakeState == EncryptionManager.OtrHandshakeState.ESTABLISHED ->
|
||||
Triple("✓ Session active", MaterialTheme.colorScheme.primary, true)
|
||||
otrHandshakeState == EncryptionManager.OtrHandshakeState.AWAITING_ACK ->
|
||||
Triple("⏳ Waiting for ACK…", MaterialTheme.colorScheme.onSurfaceVariant, true)
|
||||
otrActive ->
|
||||
Triple("⏳ Handshake…", MaterialTheme.colorScheme.onSurfaceVariant, 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.*
|
||||
@@ -10,14 +11,17 @@ import com.manalejandro.alejabber.media.AudioRecorder
|
||||
import com.manalejandro.alejabber.media.HttpUploadManager
|
||||
import com.manalejandro.alejabber.media.RecordingState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
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 +29,13 @@ 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 otrHandshakeState: EncryptionManager.OtrHandshakeState? = null,
|
||||
val pgpHasOwnKey: Boolean = false,
|
||||
val pgpHasContactKey: Boolean = false,
|
||||
val error: String? = null,
|
||||
val info: String? = null
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
@@ -33,7 +43,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 +58,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 +88,45 @@ 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) }
|
||||
}
|
||||
}
|
||||
// Observe OTR handshake state changes
|
||||
viewModelScope.launch {
|
||||
encryptionManager.otrStateChanges.collect { event: EncryptionManager.OtrStateEvent ->
|
||||
val evAccountId = event.accountId
|
||||
val evJid = event.jid
|
||||
val evState = event.state
|
||||
if (evAccountId == accountId && evJid == currentJid) {
|
||||
val established = evState == EncryptionManager.OtrHandshakeState.ESTABLISHED
|
||||
_uiState.update { s ->
|
||||
s.copy(
|
||||
otrHandshakeState = evState,
|
||||
info = if (established)
|
||||
"OTR session established — messages are now encrypted end-to-end." else null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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),
|
||||
otrHandshakeState = encryptionManager.getOtrHandshakeState(accountId, jid)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onInputChange(text: String) = _uiState.update { it.copy(inputText = text) }
|
||||
@@ -90,13 +136,70 @@ class ChatViewModel @Inject constructor(
|
||||
if (text.isBlank()) return
|
||||
_uiState.update { it.copy(inputText = "") }
|
||||
viewModelScope.launch {
|
||||
messageRepository.sendMessage(
|
||||
accountId = currentAccountId,
|
||||
toJid = currentJid,
|
||||
body = text,
|
||||
encryptionType = _uiState.value.encryptionType
|
||||
sendWithEncryption(text)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendWithEncryption(body: String) {
|
||||
val encType = _uiState.value.encryptionType
|
||||
|
||||
if (encType == EncryptionType.NONE) {
|
||||
// Plain text — delegate entirely to MessageRepository (handles PENDING→SENT/FAILED)
|
||||
withContext(Dispatchers.IO) {
|
||||
messageRepository.sendMessage(
|
||||
accountId = currentAccountId,
|
||||
toJid = currentJid,
|
||||
body = body,
|
||||
encryptionType = EncryptionType.NONE
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ── Encrypted path ────────────────────────────────────────────────
|
||||
// 1. Insert PENDING immediately so the message appears in the UI right away.
|
||||
val localId = withContext(Dispatchers.IO) {
|
||||
messageRepository.saveOutgoingMessage(
|
||||
Message(
|
||||
accountId = currentAccountId,
|
||||
conversationJid = currentJid,
|
||||
fromJid = "",
|
||||
toJid = currentJid,
|
||||
body = body,
|
||||
direction = MessageDirection.OUTGOING,
|
||||
status = MessageStatus.PENDING,
|
||||
encryptionType = encType
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Send the encrypted stanza on IO thread (network I/O).
|
||||
val (ok, notice) = withContext(Dispatchers.IO) {
|
||||
encryptionManager.sendMessage(
|
||||
accountId = currentAccountId,
|
||||
toJid = currentJid,
|
||||
body = body,
|
||||
encryptionType = encType
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Update the persisted message status.
|
||||
withContext(Dispatchers.IO) {
|
||||
messageRepository.updateMessageStatus(
|
||||
id = localId,
|
||||
status = if (ok) MessageStatus.SENT else MessageStatus.FAILED
|
||||
)
|
||||
Unit
|
||||
}
|
||||
|
||||
// 4. Notify the user.
|
||||
if (ok) {
|
||||
notice?.let { _uiState.update { s -> s.copy(info = it) } }
|
||||
} else {
|
||||
_uiState.update { it.copy(error = notice ?: "Send failed") }
|
||||
// Put the text back so the user can retry
|
||||
_uiState.update { it.copy(inputText = body) }
|
||||
}
|
||||
}
|
||||
|
||||
fun sendFile(uri: Uri) {
|
||||
@@ -106,9 +209,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 +233,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 +250,50 @@ 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 result: Pair<EncryptionType, String?> = 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 -> {
|
||||
// startOtrSession sends the INIT message and returns a status string.
|
||||
// The session transitions to ESTABLISHED only after the remote ACK arrives.
|
||||
val notice = encryptionManager.startOtrSession(currentAccountId, currentJid)
|
||||
_uiState.update { it.copy(otrActive = true) }
|
||||
type to notice
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
val finalType = result.first
|
||||
val notice = result.second
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
encryptionType = finalType,
|
||||
showEncryptionPicker = false,
|
||||
info = notice
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleEncryptionPicker() = _uiState.update {
|
||||
@@ -160,6 +305,5 @@ class ChatViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun clearError() = _uiState.update { it.copy(error = null) }
|
||||
fun clearInfo() = _uiState.update { it.copy(info = null) }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 ───────────────────────────────────────────
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
Referencia en una nueva incidencia
Block a user