fix encryption
Algunas comprobaciones han fallado
Build & Publish APK Release / build (push) Failing after 7m42s
Algunas comprobaciones han fallado
Build & Publish APK Release / build (push) Failing after 7m42s
Signed-off-by: ale <ale@manalejandro.com>
Este commit está contenido en:
@@ -7,11 +7,14 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.jivesoftware.smack.packet.Message
|
import org.jivesoftware.smack.packet.Message
|
||||||
import org.jivesoftware.smackx.carbons.packet.CarbonExtension
|
import org.jivesoftware.smackx.carbons.packet.CarbonExtension
|
||||||
import org.jivesoftware.smackx.omemo.OmemoManager
|
import org.jivesoftware.smackx.omemo.OmemoManager
|
||||||
@@ -31,13 +34,19 @@ import javax.inject.Singleton
|
|||||||
* Manages OMEMO, OTR and OpenPGP encryption for outgoing messages and
|
* Manages OMEMO, OTR and OpenPGP encryption for outgoing messages and
|
||||||
* decryption of incoming OMEMO-encrypted messages.
|
* decryption of incoming OMEMO-encrypted messages.
|
||||||
*
|
*
|
||||||
* - OMEMO : implemented via smack-omemo-signal (Signal Protocol / XEP-0384).
|
* ## OTR handshake protocol
|
||||||
* Uses TOFU trust model — all new identities are trusted on first use.
|
* OTR requires an ephemeral ECDH key exchange before any message can be encrypted.
|
||||||
* `initializeAsync` is used so the UI thread is never blocked.
|
* The handshake uses two special XMPP message bodies:
|
||||||
* - OTR : implemented from scratch with BouncyCastle ECDH + AES-256-CTR.
|
*
|
||||||
* Session state is kept in memory. Keys are ephemeral per session.
|
* Initiator → Responder: `?OTR-INIT:<base64(pubkey)>`
|
||||||
* - OpenPGP: encrypt with the recipient's public key stored via the Settings screen.
|
* Responder → Initiator: `?OTR-ACK:<base64(pubkey)>`
|
||||||
* Signing is done with the user's own private key (also in Settings).
|
*
|
||||||
|
* 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
|
@Singleton
|
||||||
class EncryptionManager @Inject constructor(
|
class EncryptionManager @Inject constructor(
|
||||||
@@ -47,7 +56,15 @@ class EncryptionManager @Inject constructor(
|
|||||||
private val TAG = "EncryptionManager"
|
private val TAG = "EncryptionManager"
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
// ── OMEMO state per account ───────────────────────────────────────────
|
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 }
|
enum class OmemoState { IDLE, INITIALISING, READY, FAILED }
|
||||||
|
|
||||||
private val _omemoState = MutableStateFlow<Map<Long, OmemoState>>(emptyMap())
|
private val _omemoState = MutableStateFlow<Map<Long, OmemoState>>(emptyMap())
|
||||||
@@ -55,17 +72,36 @@ class EncryptionManager @Inject constructor(
|
|||||||
|
|
||||||
private var omemoServiceSetup = false
|
private var omemoServiceSetup = false
|
||||||
|
|
||||||
// ── OTR sessions: (accountId, bareJid) → OtrSession ──────────────────
|
// ── OTR ──────────────────────────────────────────────────────────────
|
||||||
private val otrSessions = mutableMapOf<Pair<Long, String>, OtrSession>()
|
/**
|
||||||
|
* 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 }
|
||||||
|
|
||||||
// ── OpenPGP manager ───────────────────────────────────────────────────
|
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)
|
private val pgpManager = PgpManager(context)
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
// OMEMO
|
// OMEMO
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Initialise OMEMO asynchronously for [accountId]. */
|
|
||||||
fun initOmemo(accountId: Long) {
|
fun initOmemo(accountId: Long) {
|
||||||
val state = _omemoState.value[accountId]
|
val state = _omemoState.value[accountId]
|
||||||
if (state == OmemoState.READY || state == OmemoState.INITIALISING) return
|
if (state == OmemoState.READY || state == OmemoState.INITIALISING) return
|
||||||
@@ -73,7 +109,8 @@ class EncryptionManager @Inject constructor(
|
|||||||
if (!connection.isAuthenticated) return
|
if (!connection.isAuthenticated) return
|
||||||
|
|
||||||
_omemoState.update { it + (accountId to OmemoState.INITIALISING) }
|
_omemoState.update { it + (accountId to OmemoState.INITIALISING) }
|
||||||
scope.launch {
|
with(scope) {
|
||||||
|
launch {
|
||||||
try {
|
try {
|
||||||
if (!omemoServiceSetup) {
|
if (!omemoServiceSetup) {
|
||||||
SignalOmemoService.acknowledgeLicense()
|
SignalOmemoService.acknowledgeLicense()
|
||||||
@@ -81,20 +118,17 @@ class EncryptionManager @Inject constructor(
|
|||||||
omemoServiceSetup = true
|
omemoServiceSetup = true
|
||||||
}
|
}
|
||||||
val omemoManager = OmemoManager.getInstanceFor(connection)
|
val omemoManager = OmemoManager.getInstanceFor(connection)
|
||||||
// TOFU trust callback — trust every new identity on first encounter
|
|
||||||
omemoManager.setTrustCallback(object : OmemoTrustCallback {
|
omemoManager.setTrustCallback(object : OmemoTrustCallback {
|
||||||
override fun getTrust(
|
override fun getTrust(
|
||||||
device: org.jivesoftware.smackx.omemo.internal.OmemoDevice,
|
device: org.jivesoftware.smackx.omemo.internal.OmemoDevice,
|
||||||
fingerprint: OmemoFingerprint
|
fingerprint: OmemoFingerprint
|
||||||
): TrustState = TrustState.trusted
|
): TrustState = TrustState.trusted
|
||||||
|
|
||||||
override fun setTrust(
|
override fun setTrust(
|
||||||
device: org.jivesoftware.smackx.omemo.internal.OmemoDevice,
|
device: org.jivesoftware.smackx.omemo.internal.OmemoDevice,
|
||||||
fingerprint: OmemoFingerprint,
|
fingerprint: OmemoFingerprint,
|
||||||
state: TrustState
|
state: TrustState
|
||||||
) { /* TOFU: ignore */ }
|
) { /* TOFU */ }
|
||||||
})
|
})
|
||||||
// Register incoming OMEMO message listener
|
|
||||||
omemoManager.addOmemoMessageListener(object : OmemoMessageListener {
|
omemoManager.addOmemoMessageListener(object : OmemoMessageListener {
|
||||||
override fun onOmemoMessageReceived(
|
override fun onOmemoMessageReceived(
|
||||||
stanza: org.jivesoftware.smack.packet.Stanza,
|
stanza: org.jivesoftware.smack.packet.Stanza,
|
||||||
@@ -102,11 +136,9 @@ class EncryptionManager @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
val from = stanza.from?.asBareJid()?.toString() ?: return
|
val from = stanza.from?.asBareJid()?.toString() ?: return
|
||||||
val body = decryptedMessage.body ?: return
|
val body = decryptedMessage.body ?: return
|
||||||
scope.launch {
|
val encScope = scope
|
||||||
xmppManager.dispatchDecryptedOmemoMessage(accountId, from, body)
|
encScope.launch { xmppManager.dispatchDecryptedOmemoMessage(accountId, from, body) }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOmemoCarbonCopyReceived(
|
override fun onOmemoCarbonCopyReceived(
|
||||||
direction: CarbonExtension.Direction,
|
direction: CarbonExtension.Direction,
|
||||||
carbonCopy: Message,
|
carbonCopy: Message,
|
||||||
@@ -115,12 +147,10 @@ class EncryptionManager @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
val from = carbonCopy.from?.asBareJid()?.toString() ?: return
|
val from = carbonCopy.from?.asBareJid()?.toString() ?: return
|
||||||
val body = decryptedCarbonCopy.body ?: return
|
val body = decryptedCarbonCopy.body ?: return
|
||||||
scope.launch {
|
val encScope = scope
|
||||||
xmppManager.dispatchDecryptedOmemoMessage(accountId, from, body)
|
encScope.launch { xmppManager.dispatchDecryptedOmemoMessage(accountId, from, body) }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// Use async initialisation so we never block the IO thread during PubSub
|
|
||||||
omemoManager.initializeAsync(object : OmemoManager.InitializationFinishedCallback {
|
omemoManager.initializeAsync(object : OmemoManager.InitializationFinishedCallback {
|
||||||
override fun initializationFinished(manager: OmemoManager) {
|
override fun initializationFinished(manager: OmemoManager) {
|
||||||
_omemoState.update { it + (accountId to OmemoState.READY) }
|
_omemoState.update { it + (accountId to OmemoState.READY) }
|
||||||
@@ -135,7 +165,8 @@ class EncryptionManager @Inject constructor(
|
|||||||
_omemoState.update { it + (accountId to OmemoState.FAILED) }
|
_omemoState.update { it + (accountId to OmemoState.FAILED) }
|
||||||
Log.e(TAG, "OMEMO setup error for account $accountId", e)
|
Log.e(TAG, "OMEMO setup error for account $accountId", e)
|
||||||
}
|
}
|
||||||
}
|
} // launch
|
||||||
|
} // with(scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isOmemoReady(accountId: Long) = _omemoState.value[accountId] == OmemoState.READY
|
fun isOmemoReady(accountId: Long) = _omemoState.value[accountId] == OmemoState.READY
|
||||||
@@ -152,28 +183,121 @@ class EncryptionManager @Inject constructor(
|
|||||||
return try {
|
return try {
|
||||||
val connection = xmppManager.getConnection(accountId) ?: return null
|
val connection = xmppManager.getConnection(accountId) ?: return null
|
||||||
OmemoManager.getInstanceFor(connection).ownFingerprint.toString()
|
OmemoManager.getInstanceFor(connection).ownFingerprint.toString()
|
||||||
} catch (e: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
// OTR
|
// OTR — public API
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Start or resume an OTR session with [toJid]. */
|
/**
|
||||||
fun startOtrSession(accountId: Long, toJid: String) {
|
* 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
|
val key = accountId to toJid
|
||||||
if (otrSessions.containsKey(key)) return
|
// If already established, no-op
|
||||||
otrSessions[key] = OtrSession()
|
val existing = otrEntries[key]
|
||||||
Log.i(TAG, "OTR session started with $toJid")
|
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) {
|
fun endOtrSession(accountId: Long, toJid: String) {
|
||||||
otrSessions.remove(accountId to toJid)
|
otrEntries.remove(accountId to toJid)
|
||||||
Log.i(TAG, "OTR session ended with $toJid")
|
Log.i(TAG, "OTR session ended with $toJid")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isOtrSessionActive(accountId: Long, toJid: String) =
|
/** True only when the ECDH handshake is complete and messages can be encrypted. */
|
||||||
otrSessions.containsKey(accountId to toJid)
|
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
|
// OpenPGP
|
||||||
@@ -185,12 +309,6 @@ class EncryptionManager @Inject constructor(
|
|||||||
// Unified send
|
// Unified send
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends [body] to [toJid] with the specified [encryptionType].
|
|
||||||
* Returns (success: Boolean, notice: String?) where notice is a
|
|
||||||
* non-null informational message when the call partially succeeded
|
|
||||||
* (e.g. degraded to plain text).
|
|
||||||
*/
|
|
||||||
fun sendMessage(
|
fun sendMessage(
|
||||||
accountId: Long,
|
accountId: Long,
|
||||||
toJid: String,
|
toJid: String,
|
||||||
@@ -227,18 +345,13 @@ class EncryptionManager @Inject constructor(
|
|||||||
val omemoManager = OmemoManager.getInstanceFor(connection)
|
val omemoManager = OmemoManager.getInstanceFor(connection)
|
||||||
val recipientJid = JidCreate.entityBareFrom(toJid)
|
val recipientJid = JidCreate.entityBareFrom(toJid)
|
||||||
val encrypted = omemoManager.encrypt(recipientJid, body)
|
val encrypted = omemoManager.encrypt(recipientJid, body)
|
||||||
// OmemoMessage.Sent — obtain the smack Message via reflection to avoid
|
|
||||||
// depending on the exact method name which differs between smack versions.
|
|
||||||
val stanza = omemoSentToMessage(encrypted, toJid)
|
val stanza = omemoSentToMessage(encrypted, toJid)
|
||||||
connection.sendStanza(stanza)
|
connection.sendStanza(stanza)
|
||||||
true to null
|
true to null
|
||||||
} catch (e: UndecidedOmemoIdentityException) {
|
} catch (e: UndecidedOmemoIdentityException) {
|
||||||
// TOFU: trust all undecided identities then retry — the trust callback
|
|
||||||
// already trusts everything on get(), but the exception might still be
|
|
||||||
// thrown if devices were added mid-flight. Just degrade gracefully.
|
|
||||||
Log.w(TAG, "OMEMO undecided identities — degrading to plain text: ${e.message}")
|
Log.w(TAG, "OMEMO undecided identities — degrading to plain text: ${e.message}")
|
||||||
val sent = xmppManager.sendMessage(accountId, toJid, body)
|
val sent = xmppManager.sendMessage(accountId, toJid, body)
|
||||||
if (sent) true to "OMEMO: undecided devices — sent as plain text. Open Settings to manage trust."
|
if (sent) true to "OMEMO: undecided devices — sent as plain text."
|
||||||
else false to "Send failed"
|
else false to "Send failed"
|
||||||
} catch (e: CryptoFailedException) {
|
} catch (e: CryptoFailedException) {
|
||||||
Log.e(TAG, "OMEMO crypto failed", e)
|
Log.e(TAG, "OMEMO crypto failed", e)
|
||||||
@@ -249,20 +362,11 @@ class EncryptionManager @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts an [OmemoMessage.Sent] to a [Message] stanza ready to send.
|
|
||||||
*
|
|
||||||
* Smack 4.4.x exposes the wrapped Message via one of several method names
|
|
||||||
* depending on the exact patch version. We try known names via reflection
|
|
||||||
* so the code compiles and runs regardless of minor API changes.
|
|
||||||
*/
|
|
||||||
private fun omemoSentToMessage(sent: OmemoMessage.Sent, toJid: String): Message {
|
private fun omemoSentToMessage(sent: OmemoMessage.Sent, toJid: String): Message {
|
||||||
val bareJid = JidCreate.entityBareFrom(toJid)
|
val bareJid = JidCreate.entityBareFrom(toJid)
|
||||||
// Try method names used in different smack 4.4.x versions
|
|
||||||
for (methodName in listOf("asMessage", "buildMessage", "toMessage", "getMessage")) {
|
for (methodName in listOf("asMessage", "buildMessage", "toMessage", "getMessage")) {
|
||||||
try {
|
try {
|
||||||
val m = sent.javaClass.getMethod(methodName,
|
val m = sent.javaClass.getMethod(methodName, org.jxmpp.jid.BareJid::class.java)
|
||||||
org.jxmpp.jid.BareJid::class.java)
|
|
||||||
val result = m.invoke(sent, bareJid)
|
val result = m.invoke(sent, bareJid)
|
||||||
if (result is Message) return result
|
if (result is Message) return result
|
||||||
} catch (_: NoSuchMethodException) {}
|
} catch (_: NoSuchMethodException) {}
|
||||||
@@ -272,7 +376,6 @@ class EncryptionManager @Inject constructor(
|
|||||||
if (result is Message) return result
|
if (result is Message) return result
|
||||||
} catch (_: NoSuchMethodException) {}
|
} catch (_: NoSuchMethodException) {}
|
||||||
}
|
}
|
||||||
// Last resort: look for any method returning Message
|
|
||||||
for (m in sent.javaClass.methods) {
|
for (m in sent.javaClass.methods) {
|
||||||
if (Message::class.java.isAssignableFrom(m.returnType) && m.parameterCount <= 1) {
|
if (Message::class.java.isAssignableFrom(m.returnType) && m.parameterCount <= 1) {
|
||||||
try {
|
try {
|
||||||
@@ -289,9 +392,21 @@ class EncryptionManager @Inject constructor(
|
|||||||
|
|
||||||
private fun sendOtr(accountId: Long, toJid: String, body: String): Pair<Boolean, String?> {
|
private fun sendOtr(accountId: Long, toJid: String, body: String): Pair<Boolean, String?> {
|
||||||
val key = accountId to toJid
|
val key = accountId to toJid
|
||||||
val session = otrSessions.getOrPut(key) { OtrSession() }
|
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 {
|
return try {
|
||||||
val ciphertext = session.encrypt(body)
|
val ciphertext = entry.session.encrypt(body)
|
||||||
val sent = xmppManager.sendMessage(accountId, toJid, ciphertext)
|
val sent = xmppManager.sendMessage(accountId, toJid, ciphertext)
|
||||||
if (sent) true to null
|
if (sent) true to null
|
||||||
else false to "Send failed"
|
else false to "Send failed"
|
||||||
@@ -311,7 +426,6 @@ class EncryptionManager @Inject constructor(
|
|||||||
if (sent) true to null
|
if (sent) true to null
|
||||||
else false to "Send failed"
|
else false to "Send failed"
|
||||||
} else {
|
} else {
|
||||||
// No recipient key — fall back to plain
|
|
||||||
val sent = xmppManager.sendMessage(accountId, toJid, body)
|
val sent = xmppManager.sendMessage(accountId, toJid, body)
|
||||||
if (sent) true to "OpenPGP: no key for $toJid — sent as plain text."
|
if (sent) true to "OpenPGP: no key for $toJid — sent as plain text."
|
||||||
else false to "Send failed"
|
else false to "Send failed"
|
||||||
|
|||||||
@@ -86,6 +86,18 @@ class XmppConnectionManager @Inject constructor() {
|
|||||||
private val _subscriptionRequests = MutableSharedFlow<SubscriptionRequest>(extraBufferCapacity = 32)
|
private val _subscriptionRequests = MutableSharedFlow<SubscriptionRequest>(extraBufferCapacity = 32)
|
||||||
val subscriptionRequests: SharedFlow<SubscriptionRequest> = _subscriptionRequests.asSharedFlow()
|
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) {
|
fun connect(account: Account) {
|
||||||
@@ -334,14 +346,19 @@ class XmppConnectionManager @Inject constructor() {
|
|||||||
val chatManager = ChatManager.getInstanceFor(connection)
|
val chatManager = ChatManager.getInstanceFor(connection)
|
||||||
chatManager.addIncomingListener { from, message, _ ->
|
chatManager.addIncomingListener { from, message, _ ->
|
||||||
val body = message.body ?: return@addIncomingListener
|
val body = message.body ?: return@addIncomingListener
|
||||||
|
val fromStr = from.asBareJid().toString()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
_incomingMessages.emit(
|
// Let interceptors (e.g. OTR handshake) consume the message first
|
||||||
IncomingMessage(
|
val consumed = messageInterceptor?.invoke(accountId, fromStr, body) ?: false
|
||||||
accountId = accountId,
|
if (!consumed) {
|
||||||
from = from.asBareJid().toString(),
|
_incomingMessages.emit(
|
||||||
body = body
|
IncomingMessage(
|
||||||
|
accountId = accountId,
|
||||||
|
from = fromStr,
|
||||||
|
body = body
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ class MessageRepository @Inject constructor(
|
|||||||
suspend fun clearConversation(accountId: Long, conversationJid: String) =
|
suspend fun clearConversation(accountId: Long, conversationJid: String) =
|
||||||
messageDao.clearConversation(accountId, conversationJid)
|
messageDao.clearConversation(accountId, conversationJid)
|
||||||
|
|
||||||
|
/** 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). */
|
/** Persists an already-sent outgoing message (e.g. encrypted via EncryptionManager). */
|
||||||
suspend fun saveOutgoingMessage(message: Message): Long =
|
suspend fun saveOutgoingMessage(message: Message): Long =
|
||||||
messageDao.insertMessage(message.toEntity())
|
messageDao.insertMessage(message.toEntity())
|
||||||
|
|||||||
@@ -289,13 +289,14 @@ fun ChatScreen(
|
|||||||
// ── Encryption picker ─────────────────────────────────────────────────
|
// ── Encryption picker ─────────────────────────────────────────────────
|
||||||
if (uiState.showEncryptionPicker) {
|
if (uiState.showEncryptionPicker) {
|
||||||
EncryptionPickerDialog(
|
EncryptionPickerDialog(
|
||||||
current = uiState.encryptionType,
|
current = uiState.encryptionType,
|
||||||
omemoState = uiState.omemoState,
|
omemoState = uiState.omemoState,
|
||||||
pgpHasOwn = uiState.pgpHasOwnKey,
|
pgpHasOwn = uiState.pgpHasOwnKey,
|
||||||
pgpHasCont = uiState.pgpHasContactKey,
|
pgpHasCont = uiState.pgpHasContactKey,
|
||||||
otrActive = uiState.otrActive,
|
otrActive = uiState.otrActive,
|
||||||
onSelect = viewModel::setEncryption,
|
otrHandshakeState = uiState.otrHandshakeState,
|
||||||
onDismiss = viewModel::toggleEncryptionPicker
|
onSelect = viewModel::setEncryption,
|
||||||
|
onDismiss = viewModel::toggleEncryptionPicker
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -888,6 +889,7 @@ fun EncryptionPickerDialog(
|
|||||||
pgpHasOwn: Boolean,
|
pgpHasOwn: Boolean,
|
||||||
pgpHasCont: Boolean,
|
pgpHasCont: Boolean,
|
||||||
otrActive: Boolean,
|
otrActive: Boolean,
|
||||||
|
otrHandshakeState: EncryptionManager.OtrHandshakeState?,
|
||||||
onSelect: (EncryptionType) -> Unit,
|
onSelect: (EncryptionType) -> Unit,
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -905,10 +907,16 @@ fun EncryptionPickerDialog(
|
|||||||
EncryptionManager.OmemoState.FAILED -> Triple("✗ Failed", MaterialTheme.colorScheme.error, false)
|
EncryptionManager.OmemoState.FAILED -> Triple("✗ Failed", MaterialTheme.colorScheme.error, false)
|
||||||
else -> Triple("⏳ Not started", MaterialTheme.colorScheme.onSurfaceVariant, true)
|
else -> Triple("⏳ Not started", MaterialTheme.colorScheme.onSurfaceVariant, true)
|
||||||
}
|
}
|
||||||
EncryptionType.OTR -> if (otrActive)
|
EncryptionType.OTR -> when {
|
||||||
Triple("✓ Session active", MaterialTheme.colorScheme.primary, true)
|
otrHandshakeState == EncryptionManager.OtrHandshakeState.ESTABLISHED ->
|
||||||
else
|
Triple("✓ Session active", MaterialTheme.colorScheme.primary, true)
|
||||||
Triple("New session", MaterialTheme.colorScheme.onSurfaceVariant, 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 {
|
EncryptionType.OPENPGP -> when {
|
||||||
!pgpHasOwn -> Triple("✗ No own key", MaterialTheme.colorScheme.error, false)
|
!pgpHasOwn -> Triple("✗ No own key", MaterialTheme.colorScheme.error, false)
|
||||||
!pgpHasCont -> Triple("⚠ No contact key", MaterialTheme.colorScheme.tertiary, true)
|
!pgpHasCont -> Triple("⚠ No contact key", MaterialTheme.colorScheme.tertiary, true)
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import com.manalejandro.alejabber.media.AudioRecorder
|
|||||||
import com.manalejandro.alejabber.media.HttpUploadManager
|
import com.manalejandro.alejabber.media.HttpUploadManager
|
||||||
import com.manalejandro.alejabber.media.RecordingState
|
import com.manalejandro.alejabber.media.RecordingState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
data class ChatUiState(
|
data class ChatUiState(
|
||||||
@@ -29,6 +31,7 @@ data class ChatUiState(
|
|||||||
val showEncryptionPicker: Boolean = false,
|
val showEncryptionPicker: Boolean = false,
|
||||||
val omemoState: EncryptionManager.OmemoState = EncryptionManager.OmemoState.IDLE,
|
val omemoState: EncryptionManager.OmemoState = EncryptionManager.OmemoState.IDLE,
|
||||||
val otrActive: Boolean = false,
|
val otrActive: Boolean = false,
|
||||||
|
val otrHandshakeState: EncryptionManager.OtrHandshakeState? = null,
|
||||||
val pgpHasOwnKey: Boolean = false,
|
val pgpHasOwnKey: Boolean = false,
|
||||||
val pgpHasContactKey: Boolean = false,
|
val pgpHasContactKey: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
@@ -92,6 +95,24 @@ class ChatViewModel @Inject constructor(
|
|||||||
_uiState.update { it.copy(omemoState = s) }
|
_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
|
// Initialise OMEMO if needed
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
encryptionManager.initOmemo(accountId)
|
encryptionManager.initOmemo(accountId)
|
||||||
@@ -100,9 +121,10 @@ class ChatViewModel @Inject constructor(
|
|||||||
val pgp = encryptionManager.pgpManager()
|
val pgp = encryptionManager.pgpManager()
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
pgpHasOwnKey = pgp.hasOwnKey(),
|
pgpHasOwnKey = pgp.hasOwnKey(),
|
||||||
pgpHasContactKey = pgp.loadContactPublicKeyArmored(jid) != null,
|
pgpHasContactKey = pgp.loadContactPublicKeyArmored(jid) != null,
|
||||||
otrActive = encryptionManager.isOtrSessionActive(accountId, jid)
|
otrActive = encryptionManager.isOtrSessionActive(accountId, jid),
|
||||||
|
otrHandshakeState = encryptionManager.getOtrHandshakeState(accountId, jid)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,43 +142,62 @@ class ChatViewModel @Inject constructor(
|
|||||||
|
|
||||||
private suspend fun sendWithEncryption(body: String) {
|
private suspend fun sendWithEncryption(body: String) {
|
||||||
val encType = _uiState.value.encryptionType
|
val encType = _uiState.value.encryptionType
|
||||||
|
|
||||||
if (encType == EncryptionType.NONE) {
|
if (encType == EncryptionType.NONE) {
|
||||||
// Fast path — plain text via MessageRepository
|
// Plain text — delegate entirely to MessageRepository (handles PENDING→SENT/FAILED)
|
||||||
messageRepository.sendMessage(
|
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,
|
accountId = currentAccountId,
|
||||||
toJid = currentJid,
|
toJid = currentJid,
|
||||||
body = body,
|
body = body,
|
||||||
encryptionType = EncryptionType.NONE
|
encryptionType = encType
|
||||||
)
|
)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// Encrypted path — EncryptionManager handles sending the stanza;
|
|
||||||
// we still persist the message locally.
|
// 3. Update the persisted message status.
|
||||||
val (ok, errorMsg) = encryptionManager.sendMessage(
|
withContext(Dispatchers.IO) {
|
||||||
accountId = currentAccountId,
|
messageRepository.updateMessageStatus(
|
||||||
toJid = currentJid,
|
id = localId,
|
||||||
body = body,
|
status = if (ok) MessageStatus.SENT else MessageStatus.FAILED
|
||||||
encryptionType = encType
|
|
||||||
)
|
|
||||||
if (ok) {
|
|
||||||
// Persist as sent (EncryptionManager already sent the stanza)
|
|
||||||
val msg = Message(
|
|
||||||
accountId = currentAccountId,
|
|
||||||
conversationJid = currentJid,
|
|
||||||
fromJid = "",
|
|
||||||
toJid = currentJid,
|
|
||||||
body = body,
|
|
||||||
direction = MessageDirection.OUTGOING,
|
|
||||||
status = MessageStatus.SENT,
|
|
||||||
encryptionType = encType
|
|
||||||
)
|
)
|
||||||
messageRepository.saveOutgoingMessage(msg)
|
Unit
|
||||||
errorMsg?.let { notice ->
|
}
|
||||||
_uiState.update { it.copy(info = notice) }
|
|
||||||
}
|
// 4. Notify the user.
|
||||||
|
if (ok) {
|
||||||
|
notice?.let { _uiState.update { s -> s.copy(info = it) } }
|
||||||
} else {
|
} else {
|
||||||
_uiState.update { it.copy(error = errorMsg ?: "Send failed") }
|
_uiState.update { it.copy(error = notice ?: "Send failed") }
|
||||||
// Revert input so user can retry
|
// Put the text back so the user can retry
|
||||||
_uiState.update { it.copy(inputText = body) }
|
_uiState.update { it.copy(inputText = body) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,7 +255,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
val pgpHasOwn = _uiState.value.pgpHasOwnKey
|
val pgpHasOwn = _uiState.value.pgpHasOwnKey
|
||||||
val pgpHasCont = _uiState.value.pgpHasContactKey
|
val pgpHasCont = _uiState.value.pgpHasContactKey
|
||||||
|
|
||||||
val (finalType, notice) = when (type) {
|
val result: Pair<EncryptionType, String?> = when (type) {
|
||||||
EncryptionType.OMEMO -> when (omemoState) {
|
EncryptionType.OMEMO -> when (omemoState) {
|
||||||
EncryptionManager.OmemoState.READY -> type to null
|
EncryptionManager.OmemoState.READY -> type to null
|
||||||
EncryptionManager.OmemoState.INITIALISING ->
|
EncryptionManager.OmemoState.INITIALISING ->
|
||||||
@@ -225,10 +266,11 @@ class ChatViewModel @Inject constructor(
|
|||||||
type to "OMEMO is starting up…"
|
type to "OMEMO is starting up…"
|
||||||
}
|
}
|
||||||
EncryptionType.OTR -> {
|
EncryptionType.OTR -> {
|
||||||
// Start OTR session
|
// startOtrSession sends the INIT message and returns a status string.
|
||||||
encryptionManager.startOtrSession(currentAccountId, currentJid)
|
// The session transitions to ESTABLISHED only after the remote ACK arrives.
|
||||||
|
val notice = encryptionManager.startOtrSession(currentAccountId, currentJid)
|
||||||
_uiState.update { it.copy(otrActive = true) }
|
_uiState.update { it.copy(otrActive = true) }
|
||||||
type to "OTR session started. Messages are encrypted end-to-end."
|
type to notice
|
||||||
}
|
}
|
||||||
EncryptionType.OPENPGP -> when {
|
EncryptionType.OPENPGP -> when {
|
||||||
!pgpHasOwn -> EncryptionType.NONE to "OpenPGP: You don't have a key pair. Generate one in Settings → Encryption."
|
!pgpHasOwn -> EncryptionType.NONE to "OpenPGP: You don't have a key pair. Generate one in Settings → Encryption."
|
||||||
@@ -243,6 +285,8 @@ class ChatViewModel @Inject constructor(
|
|||||||
type to null
|
type to null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val finalType = result.first
|
||||||
|
val notice = result.second
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
encryptionType = finalType,
|
encryptionType = finalType,
|
||||||
|
|||||||
Referencia en una nueva incidencia
Block a user