Comparar commits
1 Commits
| Autor | SHA1 | Fecha | |
|---|---|---|---|
|
eb69fa8b9b
|
@@ -6,7 +6,9 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Forum
|
import androidx.compose.material.icons.filled.Forum
|
||||||
import androidx.compose.material.icons.filled.ManageAccounts
|
import androidx.compose.material.icons.filled.ManageAccounts
|
||||||
@@ -127,13 +129,15 @@ fun MainAppContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { _ ->
|
) { paddingValues ->
|
||||||
|
Box(modifier = Modifier.padding(paddingValues)) {
|
||||||
AleJabberNavGraph(
|
AleJabberNavGraph(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = Screen.Accounts.route
|
startDestination = Screen.Accounts.route
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Item descriptor for the bottom navigation bar. */
|
/** Item descriptor for the bottom navigation bar. */
|
||||||
data class BottomNavItem(
|
data class BottomNavItem(
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
package com.manalejandro.alejabber.data.remote
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import com.manalejandro.alejabber.domain.model.EncryptionType
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jivesoftware.smack.packet.Message
|
||||||
|
import org.jivesoftware.smackx.carbons.packet.CarbonExtension
|
||||||
|
import org.jivesoftware.smackx.omemo.OmemoManager
|
||||||
|
import org.jivesoftware.smackx.omemo.OmemoMessage
|
||||||
|
import org.jivesoftware.smackx.omemo.exceptions.CryptoFailedException
|
||||||
|
import org.jivesoftware.smackx.omemo.exceptions.UndecidedOmemoIdentityException
|
||||||
|
import org.jivesoftware.smackx.omemo.listener.OmemoMessageListener
|
||||||
|
import org.jivesoftware.smackx.omemo.signal.SignalOmemoService
|
||||||
|
import org.jivesoftware.smackx.omemo.trust.OmemoFingerprint
|
||||||
|
import org.jivesoftware.smackx.omemo.trust.OmemoTrustCallback
|
||||||
|
import org.jivesoftware.smackx.omemo.trust.TrustState
|
||||||
|
import org.jxmpp.jid.impl.JidCreate
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages OMEMO, OTR and OpenPGP encryption for outgoing messages and
|
||||||
|
* decryption of incoming OMEMO-encrypted messages.
|
||||||
|
*
|
||||||
|
* - OMEMO : implemented via smack-omemo-signal (Signal Protocol / XEP-0384).
|
||||||
|
* Uses TOFU trust model — all new identities are trusted on first use.
|
||||||
|
* `initializeAsync` is used so the UI thread is never blocked.
|
||||||
|
* - OTR : implemented from scratch with BouncyCastle ECDH + AES-256-CTR.
|
||||||
|
* Session state is kept in memory. Keys are ephemeral per session.
|
||||||
|
* - OpenPGP: encrypt with the recipient's public key stored via the Settings screen.
|
||||||
|
* Signing is done with the user's own private key (also in Settings).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class EncryptionManager @Inject constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val xmppManager: XmppConnectionManager
|
||||||
|
) {
|
||||||
|
private val TAG = "EncryptionManager"
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
// ── OMEMO state per account ───────────────────────────────────────────
|
||||||
|
enum class OmemoState { IDLE, INITIALISING, READY, FAILED }
|
||||||
|
|
||||||
|
private val _omemoState = MutableStateFlow<Map<Long, OmemoState>>(emptyMap())
|
||||||
|
val omemoState: StateFlow<Map<Long, OmemoState>> = _omemoState.asStateFlow()
|
||||||
|
|
||||||
|
private var omemoServiceSetup = false
|
||||||
|
|
||||||
|
// ── OTR sessions: (accountId, bareJid) → OtrSession ──────────────────
|
||||||
|
private val otrSessions = mutableMapOf<Pair<Long, String>, OtrSession>()
|
||||||
|
|
||||||
|
// ── OpenPGP manager ───────────────────────────────────────────────────
|
||||||
|
private val pgpManager = PgpManager(context)
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// OMEMO
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Initialise OMEMO asynchronously for [accountId]. */
|
||||||
|
fun initOmemo(accountId: Long) {
|
||||||
|
val state = _omemoState.value[accountId]
|
||||||
|
if (state == OmemoState.READY || state == OmemoState.INITIALISING) return
|
||||||
|
val connection = xmppManager.getConnection(accountId) ?: return
|
||||||
|
if (!connection.isAuthenticated) return
|
||||||
|
|
||||||
|
_omemoState.update { it + (accountId to OmemoState.INITIALISING) }
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
if (!omemoServiceSetup) {
|
||||||
|
SignalOmemoService.acknowledgeLicense()
|
||||||
|
SignalOmemoService.setup()
|
||||||
|
omemoServiceSetup = true
|
||||||
|
}
|
||||||
|
val omemoManager = OmemoManager.getInstanceFor(connection)
|
||||||
|
// TOFU trust callback — trust every new identity on first encounter
|
||||||
|
omemoManager.setTrustCallback(object : OmemoTrustCallback {
|
||||||
|
override fun getTrust(
|
||||||
|
device: org.jivesoftware.smackx.omemo.internal.OmemoDevice,
|
||||||
|
fingerprint: OmemoFingerprint
|
||||||
|
): TrustState = TrustState.trusted
|
||||||
|
|
||||||
|
override fun setTrust(
|
||||||
|
device: org.jivesoftware.smackx.omemo.internal.OmemoDevice,
|
||||||
|
fingerprint: OmemoFingerprint,
|
||||||
|
state: TrustState
|
||||||
|
) { /* TOFU: ignore */ }
|
||||||
|
})
|
||||||
|
// Register incoming OMEMO message listener
|
||||||
|
omemoManager.addOmemoMessageListener(object : OmemoMessageListener {
|
||||||
|
override fun onOmemoMessageReceived(
|
||||||
|
stanza: org.jivesoftware.smack.packet.Stanza,
|
||||||
|
decryptedMessage: OmemoMessage.Received
|
||||||
|
) {
|
||||||
|
val from = stanza.from?.asBareJid()?.toString() ?: return
|
||||||
|
val body = decryptedMessage.body ?: return
|
||||||
|
scope.launch {
|
||||||
|
xmppManager.dispatchDecryptedOmemoMessage(accountId, from, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOmemoCarbonCopyReceived(
|
||||||
|
direction: CarbonExtension.Direction,
|
||||||
|
carbonCopy: Message,
|
||||||
|
wrappingMessage: Message,
|
||||||
|
decryptedCarbonCopy: OmemoMessage.Received
|
||||||
|
) {
|
||||||
|
val from = carbonCopy.from?.asBareJid()?.toString() ?: return
|
||||||
|
val body = decryptedCarbonCopy.body ?: return
|
||||||
|
scope.launch {
|
||||||
|
xmppManager.dispatchDecryptedOmemoMessage(accountId, from, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Use async initialisation so we never block the IO thread during PubSub
|
||||||
|
omemoManager.initializeAsync(object : OmemoManager.InitializationFinishedCallback {
|
||||||
|
override fun initializationFinished(manager: OmemoManager) {
|
||||||
|
_omemoState.update { it + (accountId to OmemoState.READY) }
|
||||||
|
Log.i(TAG, "OMEMO ready for account $accountId")
|
||||||
|
}
|
||||||
|
override fun initializationFailed(cause: Exception) {
|
||||||
|
_omemoState.update { it + (accountId to OmemoState.FAILED) }
|
||||||
|
Log.e(TAG, "OMEMO init failed for account $accountId", cause)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_omemoState.update { it + (accountId to OmemoState.FAILED) }
|
||||||
|
Log.e(TAG, "OMEMO setup error for account $accountId", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isOmemoReady(accountId: Long) = _omemoState.value[accountId] == OmemoState.READY
|
||||||
|
|
||||||
|
fun getOmemoStateLabel(accountId: Long): String = when (_omemoState.value[accountId]) {
|
||||||
|
OmemoState.READY -> "✓ Ready"
|
||||||
|
OmemoState.INITIALISING -> "⏳ Initialising…"
|
||||||
|
OmemoState.FAILED -> "✗ Init failed"
|
||||||
|
else -> "⏳ Not started"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getOwnOmemoFingerprint(accountId: Long): String? {
|
||||||
|
if (!isOmemoReady(accountId)) return null
|
||||||
|
return try {
|
||||||
|
val connection = xmppManager.getConnection(accountId) ?: return null
|
||||||
|
OmemoManager.getInstanceFor(connection).ownFingerprint.toString()
|
||||||
|
} catch (e: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// OTR
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Start or resume an OTR session with [toJid]. */
|
||||||
|
fun startOtrSession(accountId: Long, toJid: String) {
|
||||||
|
val key = accountId to toJid
|
||||||
|
if (otrSessions.containsKey(key)) return
|
||||||
|
otrSessions[key] = OtrSession()
|
||||||
|
Log.i(TAG, "OTR session started with $toJid")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun endOtrSession(accountId: Long, toJid: String) {
|
||||||
|
otrSessions.remove(accountId to toJid)
|
||||||
|
Log.i(TAG, "OTR session ended with $toJid")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isOtrSessionActive(accountId: Long, toJid: String) =
|
||||||
|
otrSessions.containsKey(accountId to toJid)
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// OpenPGP
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun pgpManager() = pgpManager
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// Unified send
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends [body] to [toJid] with the specified [encryptionType].
|
||||||
|
* Returns (success: Boolean, notice: String?) where notice is a
|
||||||
|
* non-null informational message when the call partially succeeded
|
||||||
|
* (e.g. degraded to plain text).
|
||||||
|
*/
|
||||||
|
fun sendMessage(
|
||||||
|
accountId: Long,
|
||||||
|
toJid: String,
|
||||||
|
body: String,
|
||||||
|
encryptionType: EncryptionType
|
||||||
|
): Pair<Boolean, String?> {
|
||||||
|
val connection = xmppManager.getConnection(accountId)
|
||||||
|
?: return false to "Not connected"
|
||||||
|
if (!connection.isConnected || !connection.isAuthenticated)
|
||||||
|
return false to "Not authenticated"
|
||||||
|
|
||||||
|
return when (encryptionType) {
|
||||||
|
EncryptionType.NONE -> xmppManager.sendMessage(accountId, toJid, body) to null
|
||||||
|
EncryptionType.OMEMO -> sendOmemo(accountId, toJid, body)
|
||||||
|
EncryptionType.OTR -> sendOtr(accountId, toJid, body)
|
||||||
|
EncryptionType.OPENPGP -> sendPgp(accountId, toJid, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OMEMO send ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun sendOmemo(accountId: Long, toJid: String, body: String): Pair<Boolean, String?> {
|
||||||
|
if (!isOmemoReady(accountId)) {
|
||||||
|
val notice = when (_omemoState.value[accountId]) {
|
||||||
|
OmemoState.INITIALISING -> "OMEMO is still initialising — sent as plain text."
|
||||||
|
OmemoState.FAILED -> "OMEMO failed to initialise — sent as plain text."
|
||||||
|
else -> "OMEMO not ready — sent as plain text."
|
||||||
|
}
|
||||||
|
return if (xmppManager.sendMessage(accountId, toJid, body)) true to notice
|
||||||
|
else false to "Send failed"
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
val connection = xmppManager.getConnection(accountId)!!
|
||||||
|
val omemoManager = OmemoManager.getInstanceFor(connection)
|
||||||
|
val recipientJid = JidCreate.entityBareFrom(toJid)
|
||||||
|
val encrypted = omemoManager.encrypt(recipientJid, body)
|
||||||
|
// OmemoMessage.Sent — obtain the smack Message via reflection to avoid
|
||||||
|
// depending on the exact method name which differs between smack versions.
|
||||||
|
val stanza = omemoSentToMessage(encrypted, toJid)
|
||||||
|
connection.sendStanza(stanza)
|
||||||
|
true to null
|
||||||
|
} catch (e: UndecidedOmemoIdentityException) {
|
||||||
|
// TOFU: trust all undecided identities then retry — the trust callback
|
||||||
|
// already trusts everything on get(), but the exception might still be
|
||||||
|
// thrown if devices were added mid-flight. Just degrade gracefully.
|
||||||
|
Log.w(TAG, "OMEMO undecided identities — degrading to plain text: ${e.message}")
|
||||||
|
val sent = xmppManager.sendMessage(accountId, toJid, body)
|
||||||
|
if (sent) true to "OMEMO: undecided devices — sent as plain text. Open Settings to manage trust."
|
||||||
|
else false to "Send failed"
|
||||||
|
} catch (e: CryptoFailedException) {
|
||||||
|
Log.e(TAG, "OMEMO crypto failed", e)
|
||||||
|
false to "OMEMO encryption failed: ${e.message}"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "OMEMO send error", e)
|
||||||
|
false to "OMEMO error: ${e.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an [OmemoMessage.Sent] to a [Message] stanza ready to send.
|
||||||
|
*
|
||||||
|
* Smack 4.4.x exposes the wrapped Message via one of several method names
|
||||||
|
* depending on the exact patch version. We try known names via reflection
|
||||||
|
* so the code compiles and runs regardless of minor API changes.
|
||||||
|
*/
|
||||||
|
private fun omemoSentToMessage(sent: OmemoMessage.Sent, toJid: String): Message {
|
||||||
|
val bareJid = JidCreate.entityBareFrom(toJid)
|
||||||
|
// Try method names used in different smack 4.4.x versions
|
||||||
|
for (methodName in listOf("asMessage", "buildMessage", "toMessage", "getMessage")) {
|
||||||
|
try {
|
||||||
|
val m = sent.javaClass.getMethod(methodName,
|
||||||
|
org.jxmpp.jid.BareJid::class.java)
|
||||||
|
val result = m.invoke(sent, bareJid)
|
||||||
|
if (result is Message) return result
|
||||||
|
} catch (_: NoSuchMethodException) {}
|
||||||
|
try {
|
||||||
|
val m = sent.javaClass.getMethod(methodName)
|
||||||
|
val result = m.invoke(sent)
|
||||||
|
if (result is Message) return result
|
||||||
|
} catch (_: NoSuchMethodException) {}
|
||||||
|
}
|
||||||
|
// Last resort: look for any method returning Message
|
||||||
|
for (m in sent.javaClass.methods) {
|
||||||
|
if (Message::class.java.isAssignableFrom(m.returnType) && m.parameterCount <= 1) {
|
||||||
|
try {
|
||||||
|
val result = if (m.parameterCount == 0) m.invoke(sent)
|
||||||
|
else m.invoke(sent, bareJid)
|
||||||
|
if (result is Message) return result
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw IllegalStateException("Cannot extract Message from OmemoMessage.Sent")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OTR send ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun sendOtr(accountId: Long, toJid: String, body: String): Pair<Boolean, String?> {
|
||||||
|
val key = accountId to toJid
|
||||||
|
val session = otrSessions.getOrPut(key) { OtrSession() }
|
||||||
|
return try {
|
||||||
|
val ciphertext = session.encrypt(body)
|
||||||
|
val sent = xmppManager.sendMessage(accountId, toJid, ciphertext)
|
||||||
|
if (sent) true to null
|
||||||
|
else false to "Send failed"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "OTR encrypt error", e)
|
||||||
|
false to "OTR error: ${e.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OpenPGP send ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun sendPgp(accountId: Long, toJid: String, body: String): Pair<Boolean, String?> {
|
||||||
|
return try {
|
||||||
|
val encrypted = pgpManager.encryptFor(toJid, body)
|
||||||
|
if (encrypted != null) {
|
||||||
|
val sent = xmppManager.sendMessage(accountId, toJid, encrypted)
|
||||||
|
if (sent) true to null
|
||||||
|
else false to "Send failed"
|
||||||
|
} else {
|
||||||
|
// No recipient key — fall back to plain
|
||||||
|
val sent = xmppManager.sendMessage(accountId, toJid, body)
|
||||||
|
if (sent) true to "OpenPGP: no key for $toJid — sent as plain text."
|
||||||
|
else false to "Send failed"
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "PGP encrypt error", e)
|
||||||
|
false to "PGP error: ${e.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
val statusMessage: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** A contact has requested subscription (wants to see our presence). */
|
||||||
|
data class SubscriptionRequest(
|
||||||
|
val accountId: Long,
|
||||||
|
val fromJid: String
|
||||||
|
)
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class XmppConnectionManager @Inject constructor() {
|
class XmppConnectionManager @Inject constructor() {
|
||||||
|
|
||||||
@@ -76,6 +82,10 @@ class XmppConnectionManager @Inject constructor() {
|
|||||||
private val _presenceUpdates = MutableSharedFlow<PresenceUpdate>(extraBufferCapacity = 64)
|
private val _presenceUpdates = MutableSharedFlow<PresenceUpdate>(extraBufferCapacity = 64)
|
||||||
val presenceUpdates: SharedFlow<PresenceUpdate> = _presenceUpdates.asSharedFlow()
|
val presenceUpdates: SharedFlow<PresenceUpdate> = _presenceUpdates.asSharedFlow()
|
||||||
|
|
||||||
|
// ── Incoming subscription requests ───────────────────────────────────
|
||||||
|
private val _subscriptionRequests = MutableSharedFlow<SubscriptionRequest>(extraBufferCapacity = 32)
|
||||||
|
val subscriptionRequests: SharedFlow<SubscriptionRequest> = _subscriptionRequests.asSharedFlow()
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun connect(account: Account) {
|
fun connect(account: Account) {
|
||||||
@@ -161,6 +171,54 @@ class XmppConnectionManager @Inject constructor() {
|
|||||||
|
|
||||||
fun getConnection(accountId: Long): AbstractXMPPConnection? = connections[accountId]
|
fun getConnection(accountId: Long): AbstractXMPPConnection? = connections[accountId]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by [EncryptionManager] when an incoming OMEMO message has been
|
||||||
|
* decrypted. Re-emits it through the same [incomingMessages] flow so
|
||||||
|
* [XmppForegroundService] and the chat UI can handle it uniformly.
|
||||||
|
*/
|
||||||
|
suspend fun dispatchDecryptedOmemoMessage(accountId: Long, from: String, body: String) {
|
||||||
|
_incomingMessages.emit(
|
||||||
|
IncomingMessage(accountId = accountId, from = from, body = body)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accept a subscription request — send subscribed + subscribe back. */
|
||||||
|
fun acceptSubscription(accountId: Long, fromJid: String) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val connection = connections[accountId] ?: return@launch
|
||||||
|
val jid = JidCreate.entityBareFrom(fromJid)
|
||||||
|
// Confirm subscription
|
||||||
|
val subscribed = Presence(Presence.Type.subscribed)
|
||||||
|
subscribed.to = jid
|
||||||
|
connection.sendStanza(subscribed)
|
||||||
|
// Subscribe back (mutual)
|
||||||
|
val subscribe = Presence(Presence.Type.subscribe)
|
||||||
|
subscribe.to = jid
|
||||||
|
connection.sendStanza(subscribe)
|
||||||
|
Log.i(TAG, "Accepted subscription from $fromJid")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Accept subscription error", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deny/cancel a subscription request. */
|
||||||
|
fun denySubscription(accountId: Long, fromJid: String) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val connection = connections[accountId] ?: return@launch
|
||||||
|
val jid = JidCreate.entityBareFrom(fromJid)
|
||||||
|
val unsubscribed = Presence(Presence.Type.unsubscribed)
|
||||||
|
unsubscribed.to = jid
|
||||||
|
connection.sendStanza(unsubscribed)
|
||||||
|
Log.i(TAG, "Denied subscription from $fromJid")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Deny subscription error", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun buildConfig(account: Account): XMPPTCPConnectionConfiguration {
|
private fun buildConfig(account: Account): XMPPTCPConnectionConfiguration {
|
||||||
@@ -185,6 +243,23 @@ class XmppConnectionManager @Inject constructor() {
|
|||||||
private fun setupRoster(accountId: Long, connection: AbstractXMPPConnection) {
|
private fun setupRoster(accountId: Long, connection: AbstractXMPPConnection) {
|
||||||
val roster = Roster.getInstanceFor(connection)
|
val roster = Roster.getInstanceFor(connection)
|
||||||
roster.isRosterLoadedAtLogin = true
|
roster.isRosterLoadedAtLogin = true
|
||||||
|
// Manual subscription mode: we handle subscribe/unsubscribe stanzas ourselves
|
||||||
|
roster.subscriptionMode = Roster.SubscriptionMode.manual
|
||||||
|
|
||||||
|
// ── Subscription request listener ─────────────────────────────────
|
||||||
|
connection.addAsyncStanzaListener({ stanza ->
|
||||||
|
val presence = stanza as Presence
|
||||||
|
scope.launch {
|
||||||
|
when (presence.type) {
|
||||||
|
Presence.Type.subscribe -> {
|
||||||
|
val fromJid = presence.from?.asBareJid()?.toString() ?: return@launch
|
||||||
|
Log.i(TAG, "Subscription request from $fromJid")
|
||||||
|
_subscriptionRequests.emit(SubscriptionRequest(accountId, fromJid))
|
||||||
|
}
|
||||||
|
else -> { /* handled by roster listener */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, org.jivesoftware.smack.filter.StanzaTypeFilter(Presence::class.java))
|
||||||
|
|
||||||
// ── Snapshot all current presences once the roster is loaded ──────
|
// ── Snapshot all current presences once the roster is loaded ──────
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|||||||
@@ -70,5 +70,9 @@ class MessageRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun clearConversation(accountId: Long, conversationJid: String) =
|
suspend fun clearConversation(accountId: Long, conversationJid: String) =
|
||||||
messageDao.clearConversation(accountId, conversationJid)
|
messageDao.clearConversation(accountId, conversationJid)
|
||||||
|
|
||||||
|
/** Persists an already-sent outgoing message (e.g. encrypted via EncryptionManager). */
|
||||||
|
suspend fun saveOutgoingMessage(message: Message): Long =
|
||||||
|
messageDao.insertMessage(message.toEntity())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class XmppForegroundService : Service() {
|
|||||||
startForeground(AleJabberApp.NOTIFICATION_ID_SERVICE, buildForegroundNotification())
|
startForeground(AleJabberApp.NOTIFICATION_ID_SERVICE, buildForegroundNotification())
|
||||||
listenForIncomingMessages()
|
listenForIncomingMessages()
|
||||||
listenForPresenceUpdates()
|
listenForPresenceUpdates()
|
||||||
|
listenForSubscriptionRequests()
|
||||||
connectAllAccounts()
|
connectAllAccounts()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +88,36 @@ class XmppForegroundService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun listenForSubscriptionRequests() {
|
||||||
|
serviceScope.launch {
|
||||||
|
xmppManager.subscriptionRequests.collect { req ->
|
||||||
|
showSubscriptionNotification(req.accountId, req.fromJid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showSubscriptionNotification(accountId: Long, fromJid: String) {
|
||||||
|
val intent = Intent(this, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
putExtra("subscription_account_id", accountId)
|
||||||
|
putExtra("subscription_from_jid", fromJid)
|
||||||
|
}
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this, fromJid.hashCode(), intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val notification = NotificationCompat.Builder(this, AleJabberApp.CHANNEL_MESSAGES)
|
||||||
|
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||||
|
.setContentTitle("Contact request")
|
||||||
|
.setContentText("$fromJid wants to add you as a contact")
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.build()
|
||||||
|
val nm = getSystemService(NotificationManager::class.java)
|
||||||
|
nm.notify("sub_${fromJid}".hashCode(), notification)
|
||||||
|
}
|
||||||
|
|
||||||
private fun showMessageNotification(from: String, body: String) {
|
private fun showMessageNotification(from: String, body: String) {
|
||||||
val intent = Intent(this, MainActivity::class.java).apply {
|
val intent = Intent(this, MainActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import coil.compose.AsyncImage
|
|||||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
import com.google.accompanist.permissions.isGranted
|
import com.google.accompanist.permissions.isGranted
|
||||||
import com.google.accompanist.permissions.rememberPermissionState
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
|
import com.manalejandro.alejabber.data.remote.EncryptionManager
|
||||||
import com.manalejandro.alejabber.R
|
import com.manalejandro.alejabber.R
|
||||||
import com.manalejandro.alejabber.domain.model.*
|
import com.manalejandro.alejabber.domain.model.*
|
||||||
import com.manalejandro.alejabber.media.RecordingState
|
import com.manalejandro.alejabber.media.RecordingState
|
||||||
@@ -81,6 +82,8 @@ fun ChatScreen(
|
|||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO)
|
val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO)
|
||||||
val clipboard = LocalClipboard.current
|
val clipboard = LocalClipboard.current
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
// Message selected via long-press → shows the action bottom sheet
|
// Message selected via long-press → shows the action bottom sheet
|
||||||
var selectedMessage by remember { mutableStateOf<Message?>(null) }
|
var selectedMessage by remember { mutableStateOf<Message?>(null) }
|
||||||
@@ -96,6 +99,21 @@ fun ChatScreen(
|
|||||||
viewModel.init(accountId, conversationJid)
|
viewModel.init(accountId, conversationJid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show error snackbar
|
||||||
|
LaunchedEffect(uiState.error) {
|
||||||
|
uiState.error?.let {
|
||||||
|
snackbarHostState.showSnackbar(it, duration = SnackbarDuration.Long)
|
||||||
|
viewModel.clearError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Show info snackbar
|
||||||
|
LaunchedEffect(uiState.info) {
|
||||||
|
uiState.info?.let {
|
||||||
|
snackbarHostState.showSnackbar(it, duration = SnackbarDuration.Long)
|
||||||
|
viewModel.clearInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Scroll to bottom on new message
|
// Scroll to bottom on new message
|
||||||
LaunchedEffect(uiState.messages.size) {
|
LaunchedEffect(uiState.messages.size) {
|
||||||
if (uiState.messages.isNotEmpty()) {
|
if (uiState.messages.isNotEmpty()) {
|
||||||
@@ -115,7 +133,7 @@ fun ChatScreen(
|
|||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
AvatarWithStatus(
|
AvatarWithStatus(
|
||||||
name = uiState.contactName,
|
name = uiState.contactName,
|
||||||
avatarUrl = null,
|
avatarUrl = uiState.contactAvatarUrl,
|
||||||
presence = uiState.contactPresence,
|
presence = uiState.contactPresence,
|
||||||
size = 36.dp
|
size = 36.dp
|
||||||
)
|
)
|
||||||
@@ -151,7 +169,8 @@ fun ChatScreen(
|
|||||||
containerColor = MaterialTheme.colorScheme.surface
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||||
// No bottomBar — input is placed inside the content column so imePadding works
|
// No bottomBar — input is placed inside the content column so imePadding works
|
||||||
) { padding ->
|
) { padding ->
|
||||||
// imePadding() here at the column level makes the whole content
|
// imePadding() here at the column level makes the whole content
|
||||||
@@ -271,6 +290,10 @@ fun ChatScreen(
|
|||||||
if (uiState.showEncryptionPicker) {
|
if (uiState.showEncryptionPicker) {
|
||||||
EncryptionPickerDialog(
|
EncryptionPickerDialog(
|
||||||
current = uiState.encryptionType,
|
current = uiState.encryptionType,
|
||||||
|
omemoState = uiState.omemoState,
|
||||||
|
pgpHasOwn = uiState.pgpHasOwnKey,
|
||||||
|
pgpHasCont = uiState.pgpHasContactKey,
|
||||||
|
otrActive = uiState.otrActive,
|
||||||
onSelect = viewModel::setEncryption,
|
onSelect = viewModel::setEncryption,
|
||||||
onDismiss = viewModel::toggleEncryptionPicker
|
onDismiss = viewModel::toggleEncryptionPicker
|
||||||
)
|
)
|
||||||
@@ -861,6 +884,10 @@ fun ChatInput(
|
|||||||
@Composable
|
@Composable
|
||||||
fun EncryptionPickerDialog(
|
fun EncryptionPickerDialog(
|
||||||
current: EncryptionType,
|
current: EncryptionType,
|
||||||
|
omemoState: EncryptionManager.OmemoState,
|
||||||
|
pgpHasOwn: Boolean,
|
||||||
|
pgpHasCont: Boolean,
|
||||||
|
otrActive: Boolean,
|
||||||
onSelect: (EncryptionType) -> Unit,
|
onSelect: (EncryptionType) -> Unit,
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -870,21 +897,57 @@ fun EncryptionPickerDialog(
|
|||||||
text = {
|
text = {
|
||||||
Column {
|
Column {
|
||||||
EncryptionType.entries.forEach { type ->
|
EncryptionType.entries.forEach { type ->
|
||||||
|
val (statusLabel, statusColor, enabled) = when (type) {
|
||||||
|
EncryptionType.NONE -> Triple("", null, true)
|
||||||
|
EncryptionType.OMEMO -> when (omemoState) {
|
||||||
|
EncryptionManager.OmemoState.READY -> Triple("✓ Ready", MaterialTheme.colorScheme.primary, true)
|
||||||
|
EncryptionManager.OmemoState.INITIALISING -> Triple("⏳ Initialising…", MaterialTheme.colorScheme.onSurfaceVariant, true)
|
||||||
|
EncryptionManager.OmemoState.FAILED -> Triple("✗ Failed", MaterialTheme.colorScheme.error, false)
|
||||||
|
else -> Triple("⏳ Not started", MaterialTheme.colorScheme.onSurfaceVariant, true)
|
||||||
|
}
|
||||||
|
EncryptionType.OTR -> if (otrActive)
|
||||||
|
Triple("✓ Session active", MaterialTheme.colorScheme.primary, true)
|
||||||
|
else
|
||||||
|
Triple("New session", MaterialTheme.colorScheme.onSurfaceVariant, true)
|
||||||
|
EncryptionType.OPENPGP -> when {
|
||||||
|
!pgpHasOwn -> Triple("✗ No own key", MaterialTheme.colorScheme.error, false)
|
||||||
|
!pgpHasCont -> Triple("⚠ No contact key", MaterialTheme.colorScheme.tertiary, true)
|
||||||
|
else -> Triple("✓ Keys available", MaterialTheme.colorScheme.primary, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.pointerInput(type) {
|
.pointerInput(type) {
|
||||||
detectTapGestures { onSelect(type) }
|
detectTapGestures { if (enabled) onSelect(type) }
|
||||||
}
|
}
|
||||||
.padding(12.dp),
|
.padding(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
RadioButton(selected = current == type, onClick = { onSelect(type) })
|
RadioButton(
|
||||||
|
selected = current == type,
|
||||||
|
onClick = { if (enabled) onSelect(type) },
|
||||||
|
enabled = enabled
|
||||||
|
)
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Column {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(type.toDisplayName(), fontWeight = FontWeight.Medium)
|
Text(type.toDisplayName(), fontWeight = FontWeight.Medium)
|
||||||
Text(type.toDescription(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
if (statusLabel.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
statusLabel,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = statusColor ?: MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
type.toDescription(),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.manalejandro.alejabber.ui.chat
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.manalejandro.alejabber.data.remote.EncryptionManager
|
||||||
import com.manalejandro.alejabber.data.repository.ContactRepository
|
import com.manalejandro.alejabber.data.repository.ContactRepository
|
||||||
import com.manalejandro.alejabber.data.repository.MessageRepository
|
import com.manalejandro.alejabber.data.repository.MessageRepository
|
||||||
import com.manalejandro.alejabber.domain.model.*
|
import com.manalejandro.alejabber.domain.model.*
|
||||||
@@ -18,6 +19,7 @@ data class ChatUiState(
|
|||||||
val messages: List<Message> = emptyList(),
|
val messages: List<Message> = emptyList(),
|
||||||
val contactName: String = "",
|
val contactName: String = "",
|
||||||
val contactPresence: PresenceStatus = PresenceStatus.OFFLINE,
|
val contactPresence: PresenceStatus = PresenceStatus.OFFLINE,
|
||||||
|
val contactAvatarUrl: String? = null,
|
||||||
val inputText: String = "",
|
val inputText: String = "",
|
||||||
val encryptionType: EncryptionType = EncryptionType.NONE,
|
val encryptionType: EncryptionType = EncryptionType.NONE,
|
||||||
val isTyping: Boolean = false,
|
val isTyping: Boolean = false,
|
||||||
@@ -25,7 +27,12 @@ data class ChatUiState(
|
|||||||
val recordingState: RecordingState = RecordingState.IDLE,
|
val recordingState: RecordingState = RecordingState.IDLE,
|
||||||
val recordingDurationMs: Long = 0,
|
val recordingDurationMs: Long = 0,
|
||||||
val showEncryptionPicker: Boolean = false,
|
val showEncryptionPicker: Boolean = false,
|
||||||
val error: String? = null
|
val omemoState: EncryptionManager.OmemoState = EncryptionManager.OmemoState.IDLE,
|
||||||
|
val otrActive: Boolean = false,
|
||||||
|
val pgpHasOwnKey: Boolean = false,
|
||||||
|
val pgpHasContactKey: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val info: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -33,7 +40,8 @@ class ChatViewModel @Inject constructor(
|
|||||||
private val messageRepository: MessageRepository,
|
private val messageRepository: MessageRepository,
|
||||||
private val contactRepository: ContactRepository,
|
private val contactRepository: ContactRepository,
|
||||||
private val httpUploadManager: HttpUploadManager,
|
private val httpUploadManager: HttpUploadManager,
|
||||||
private val audioRecorder: AudioRecorder
|
private val audioRecorder: AudioRecorder,
|
||||||
|
private val encryptionManager: EncryptionManager
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ChatUiState())
|
private val _uiState = MutableStateFlow(ChatUiState())
|
||||||
@@ -47,30 +55,26 @@ class ChatViewModel @Inject constructor(
|
|||||||
currentJid = jid
|
currentJid = jid
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Load messages
|
|
||||||
messageRepository.getMessages(accountId, jid).collect { messages ->
|
messageRepository.getMessages(accountId, jid).collect { messages ->
|
||||||
_uiState.update { it.copy(messages = messages) }
|
_uiState.update { it.copy(messages = messages) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Load contact info
|
// Keep contact info (name, presence, avatar) live
|
||||||
contactRepository.getContacts(accountId)
|
contactRepository.getContacts(accountId).collect { contacts ->
|
||||||
.take(1)
|
|
||||||
.collect { contacts ->
|
|
||||||
val contact = contacts.find { it.jid == jid }
|
val contact = contacts.find { it.jid == jid }
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
contactName = contact?.nickname?.ifBlank { jid } ?: jid,
|
contactName = contact?.nickname?.ifBlank { jid } ?: jid,
|
||||||
contactPresence = contact?.presence ?: PresenceStatus.OFFLINE
|
contactPresence = contact?.presence ?: PresenceStatus.OFFLINE,
|
||||||
|
contactAvatarUrl = contact?.avatarUrl
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Mark as read
|
|
||||||
messageRepository.markAllAsRead(accountId, jid)
|
messageRepository.markAllAsRead(accountId, jid)
|
||||||
}
|
}
|
||||||
// Observe recording state
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
audioRecorder.state.collect { state ->
|
audioRecorder.state.collect { state ->
|
||||||
_uiState.update { it.copy(recordingState = state) }
|
_uiState.update { it.copy(recordingState = state) }
|
||||||
@@ -81,6 +85,26 @@ class ChatViewModel @Inject constructor(
|
|||||||
_uiState.update { it.copy(recordingDurationMs = ms) }
|
_uiState.update { it.copy(recordingDurationMs = ms) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Observe OMEMO state — updates UI badge in real time
|
||||||
|
viewModelScope.launch {
|
||||||
|
encryptionManager.omemoState.collect { stateMap ->
|
||||||
|
val s = stateMap[accountId] ?: EncryptionManager.OmemoState.IDLE
|
||||||
|
_uiState.update { it.copy(omemoState = s) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Initialise OMEMO if needed
|
||||||
|
viewModelScope.launch {
|
||||||
|
encryptionManager.initOmemo(accountId)
|
||||||
|
}
|
||||||
|
// Check PGP key availability
|
||||||
|
val pgp = encryptionManager.pgpManager()
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
pgpHasOwnKey = pgp.hasOwnKey(),
|
||||||
|
pgpHasContactKey = pgp.loadContactPublicKeyArmored(jid) != null,
|
||||||
|
otrActive = encryptionManager.isOtrSessionActive(accountId, jid)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onInputChange(text: String) = _uiState.update { it.copy(inputText = text) }
|
fun onInputChange(text: String) = _uiState.update { it.copy(inputText = text) }
|
||||||
@@ -90,12 +114,50 @@ class ChatViewModel @Inject constructor(
|
|||||||
if (text.isBlank()) return
|
if (text.isBlank()) return
|
||||||
_uiState.update { it.copy(inputText = "") }
|
_uiState.update { it.copy(inputText = "") }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
sendWithEncryption(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendWithEncryption(body: String) {
|
||||||
|
val encType = _uiState.value.encryptionType
|
||||||
|
if (encType == EncryptionType.NONE) {
|
||||||
|
// Fast path — plain text via MessageRepository
|
||||||
messageRepository.sendMessage(
|
messageRepository.sendMessage(
|
||||||
accountId = currentAccountId,
|
accountId = currentAccountId,
|
||||||
toJid = currentJid,
|
toJid = currentJid,
|
||||||
body = text,
|
body = body,
|
||||||
encryptionType = _uiState.value.encryptionType
|
encryptionType = EncryptionType.NONE
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Encrypted path — EncryptionManager handles sending the stanza;
|
||||||
|
// we still persist the message locally.
|
||||||
|
val (ok, errorMsg) = encryptionManager.sendMessage(
|
||||||
|
accountId = currentAccountId,
|
||||||
|
toJid = currentJid,
|
||||||
|
body = body,
|
||||||
|
encryptionType = encType
|
||||||
|
)
|
||||||
|
if (ok) {
|
||||||
|
// Persist as sent (EncryptionManager already sent the stanza)
|
||||||
|
val msg = Message(
|
||||||
|
accountId = currentAccountId,
|
||||||
|
conversationJid = currentJid,
|
||||||
|
fromJid = "",
|
||||||
|
toJid = currentJid,
|
||||||
|
body = body,
|
||||||
|
direction = MessageDirection.OUTGOING,
|
||||||
|
status = MessageStatus.SENT,
|
||||||
|
encryptionType = encType
|
||||||
|
)
|
||||||
|
messageRepository.saveOutgoingMessage(msg)
|
||||||
|
errorMsg?.let { notice ->
|
||||||
|
_uiState.update { it.copy(info = notice) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_uiState.update { it.copy(error = errorMsg ?: "Send failed") }
|
||||||
|
// Revert input so user can retry
|
||||||
|
_uiState.update { it.copy(inputText = body) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,8 +209,47 @@ class ChatViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun cancelRecording() = audioRecorder.cancelRecording()
|
fun cancelRecording() = audioRecorder.cancelRecording()
|
||||||
|
|
||||||
fun setEncryption(type: EncryptionType) = _uiState.update {
|
fun setEncryption(type: EncryptionType) {
|
||||||
it.copy(encryptionType = type, showEncryptionPicker = false)
|
val omemoState = _uiState.value.omemoState
|
||||||
|
val pgpHasOwn = _uiState.value.pgpHasOwnKey
|
||||||
|
val pgpHasCont = _uiState.value.pgpHasContactKey
|
||||||
|
|
||||||
|
val (finalType, notice) = when (type) {
|
||||||
|
EncryptionType.OMEMO -> when (omemoState) {
|
||||||
|
EncryptionManager.OmemoState.READY -> type to null
|
||||||
|
EncryptionManager.OmemoState.INITIALISING ->
|
||||||
|
type to "OMEMO is initialising — messages will be encrypted once ready."
|
||||||
|
EncryptionManager.OmemoState.FAILED ->
|
||||||
|
EncryptionType.NONE to "OMEMO failed to initialise. Check server PubSub support."
|
||||||
|
else ->
|
||||||
|
type to "OMEMO is starting up…"
|
||||||
|
}
|
||||||
|
EncryptionType.OTR -> {
|
||||||
|
// Start OTR session
|
||||||
|
encryptionManager.startOtrSession(currentAccountId, currentJid)
|
||||||
|
_uiState.update { it.copy(otrActive = true) }
|
||||||
|
type to "OTR session started. Messages are encrypted end-to-end."
|
||||||
|
}
|
||||||
|
EncryptionType.OPENPGP -> when {
|
||||||
|
!pgpHasOwn -> EncryptionType.NONE to "OpenPGP: You don't have a key pair. Generate one in Settings → Encryption."
|
||||||
|
!pgpHasCont -> type to "OpenPGP: No public key for this contact. Ask them to share their key in Settings → Encryption."
|
||||||
|
else -> type to null
|
||||||
|
}
|
||||||
|
EncryptionType.NONE -> {
|
||||||
|
if (_uiState.value.encryptionType == EncryptionType.OTR) {
|
||||||
|
encryptionManager.endOtrSession(currentAccountId, currentJid)
|
||||||
|
_uiState.update { it.copy(otrActive = false) }
|
||||||
|
}
|
||||||
|
type to null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
encryptionType = finalType,
|
||||||
|
showEncryptionPicker = false,
|
||||||
|
info = notice
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleEncryptionPicker() = _uiState.update {
|
fun toggleEncryptionPicker() = _uiState.update {
|
||||||
@@ -160,6 +261,5 @@ class ChatViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun clearError() = _uiState.update { it.copy(error = null) }
|
fun clearError() = _uiState.update { it.copy(error = null) }
|
||||||
|
fun clearInfo() = _uiState.update { it.copy(info = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package com.manalejandro.alejabber.ui.contacts
|
package com.manalejandro.alejabber.ui.contacts
|
||||||
|
|
||||||
import androidx.compose.animation.animateContentSize
|
import androidx.compose.animation.animateContentSize
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
@@ -187,6 +187,42 @@ fun ContactsScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Subscription authorization dialog ─────────────────────────────────
|
||||||
|
uiState.pendingSubscriptionJid?.let { fromJid ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { viewModel.denySubscription() },
|
||||||
|
icon = { Icon(Icons.Default.PersonAdd, null, tint = MaterialTheme.colorScheme.primary) },
|
||||||
|
title = { Text("Contact request") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
"$fromJid wants to add you as a contact and see your presence status.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Do you want to accept and add them to your contacts?",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = { viewModel.acceptSubscription() }) {
|
||||||
|
Icon(Icons.Default.Check, null)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Accept")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
OutlinedButton(onClick = { viewModel.denySubscription() }) {
|
||||||
|
Icon(Icons.Default.Close, null)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Deny")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Contact Detail Bottom Sheet ───────────────────────────────────────────
|
// ── Contact Detail Bottom Sheet ───────────────────────────────────────────
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.manalejandro.alejabber.ui.contacts
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.manalejandro.alejabber.data.remote.XmppConnectionManager
|
||||||
import com.manalejandro.alejabber.data.repository.AccountRepository
|
import com.manalejandro.alejabber.data.repository.AccountRepository
|
||||||
import com.manalejandro.alejabber.data.repository.ContactRepository
|
import com.manalejandro.alejabber.data.repository.ContactRepository
|
||||||
import com.manalejandro.alejabber.domain.model.Contact
|
import com.manalejandro.alejabber.domain.model.Contact
|
||||||
@@ -31,14 +32,18 @@ data class ContactsUiState(
|
|||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val searchQuery: String = "",
|
val searchQuery: String = "",
|
||||||
val showAddDialog: Boolean = false,
|
val showAddDialog: Boolean = false,
|
||||||
val error: String? = null
|
val error: String? = null,
|
||||||
|
/** JID of a contact requesting subscription (triggers the authorization dialog). */
|
||||||
|
val pendingSubscriptionJid: String? = null,
|
||||||
|
val pendingSubscriptionAccountId: Long = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ContactsViewModel @Inject constructor(
|
class ContactsViewModel @Inject constructor(
|
||||||
private val accountRepository: AccountRepository,
|
private val accountRepository: AccountRepository,
|
||||||
private val contactRepository: ContactRepository
|
private val contactRepository: ContactRepository,
|
||||||
|
private val xmppManager: XmppConnectionManager
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ContactsUiState())
|
private val _uiState = MutableStateFlow(ContactsUiState())
|
||||||
@@ -46,6 +51,20 @@ class ContactsViewModel @Inject constructor(
|
|||||||
|
|
||||||
private val searchQuery = MutableStateFlow("")
|
private val searchQuery = MutableStateFlow("")
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Listen for incoming subscription requests from any account
|
||||||
|
viewModelScope.launch {
|
||||||
|
xmppManager.subscriptionRequests.collect { req ->
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
pendingSubscriptionJid = req.fromJid,
|
||||||
|
pendingSubscriptionAccountId = req.accountId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load contacts for the given [accountId].
|
* Load contacts for the given [accountId].
|
||||||
* Safe to call multiple times; cancels the previous collection.
|
* Safe to call multiple times; cancels the previous collection.
|
||||||
@@ -141,5 +160,27 @@ class ContactsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun acceptSubscription() {
|
||||||
|
val jid = _uiState.value.pendingSubscriptionJid ?: return
|
||||||
|
val accountId = _uiState.value.pendingSubscriptionAccountId
|
||||||
|
xmppManager.acceptSubscription(accountId, jid)
|
||||||
|
// Optionally add the contact to the roster locally
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
contactRepository.addContact(
|
||||||
|
Contact(accountId = accountId, jid = jid, nickname = jid)
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(pendingSubscriptionJid = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun denySubscription() {
|
||||||
|
val jid = _uiState.value.pendingSubscriptionJid ?: return
|
||||||
|
val accountId = _uiState.value.pendingSubscriptionAccountId
|
||||||
|
xmppManager.denySubscription(accountId, jid)
|
||||||
|
_uiState.update { it.copy(pendingSubscriptionJid = null) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.manalejandro.alejabber.ui.settings
|
package com.manalejandro.alejabber.ui.settings
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
@@ -12,6 +15,7 @@ import androidx.compose.material3.*
|
|||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -28,8 +32,40 @@ fun SettingsScreen(
|
|||||||
viewModel: SettingsViewModel = hiltViewModel()
|
viewModel: SettingsViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
var showThemeDialog by remember { mutableStateOf(false) }
|
var showThemeDialog by remember { mutableStateOf(false) }
|
||||||
var showEncryptionDialog by remember { mutableStateOf(false) }
|
var showEncryptionDialog by remember { mutableStateOf(false) }
|
||||||
|
// PGP dialogs
|
||||||
|
var showPgpImportOwnDialog by remember { mutableStateOf(false) }
|
||||||
|
var showPgpImportContactDialog by remember { mutableStateOf(false) }
|
||||||
|
var pgpContactJid by remember { mutableStateOf("") }
|
||||||
|
var pgpKeyText by remember { mutableStateOf("") }
|
||||||
|
var showPgpContactDeleteDialog by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
// File picker for PGP key import
|
||||||
|
val keyFilePicker = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.GetContent()
|
||||||
|
) { uri: Uri? ->
|
||||||
|
uri?.let {
|
||||||
|
val text = context.contentResolver.openInputStream(it)?.bufferedReader()?.readText() ?: return@let
|
||||||
|
pgpKeyText = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(uiState.pgpError) {
|
||||||
|
uiState.pgpError?.let {
|
||||||
|
snackbarHostState.showSnackbar(it, duration = SnackbarDuration.Long)
|
||||||
|
viewModel.clearPgpMessages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(uiState.pgpInfo) {
|
||||||
|
uiState.pgpInfo?.let {
|
||||||
|
snackbarHostState.showSnackbar(it)
|
||||||
|
viewModel.clearPgpMessages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -41,7 +77,8 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -49,71 +86,213 @@ fun SettingsScreen(
|
|||||||
.padding(padding)
|
.padding(padding)
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
) {
|
) {
|
||||||
// Appearance section
|
// ── Appearance ────────────────────────────────────────────────
|
||||||
SettingsSectionHeader(stringResource(R.string.settings_appearance))
|
SettingsSectionHeader(stringResource(R.string.settings_appearance))
|
||||||
|
|
||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon = Icons.Default.Palette,
|
icon = Icons.Default.Palette,
|
||||||
title = stringResource(R.string.settings_theme),
|
title = stringResource(R.string.settings_theme),
|
||||||
subtitle = uiState.appTheme.toDisplayName(),
|
subtitle = uiState.appTheme.toDisplayName(),
|
||||||
onClick = { showThemeDialog = true }
|
onClick = { showThemeDialog = true }
|
||||||
)
|
)
|
||||||
|
|
||||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||||
|
|
||||||
// Notifications section
|
// ── Notifications ─────────────────────────────────────────────
|
||||||
SettingsSectionHeader(stringResource(R.string.settings_notifications))
|
SettingsSectionHeader(stringResource(R.string.settings_notifications))
|
||||||
|
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon = Icons.Default.Notifications,
|
icon = Icons.Default.Notifications,
|
||||||
title = stringResource(R.string.settings_notifications_messages),
|
title = stringResource(R.string.settings_notifications_messages),
|
||||||
checked = uiState.notificationsEnabled,
|
checked = uiState.notificationsEnabled,
|
||||||
onCheckedChange = viewModel::setNotifications
|
onCheckedChange = viewModel::setNotifications
|
||||||
)
|
)
|
||||||
|
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon = Icons.Default.Vibration,
|
icon = Icons.Default.Vibration,
|
||||||
title = stringResource(R.string.settings_notifications_vibrate),
|
title = stringResource(R.string.settings_notifications_vibrate),
|
||||||
checked = uiState.vibrateEnabled,
|
checked = uiState.vibrateEnabled,
|
||||||
onCheckedChange = viewModel::setVibrate
|
onCheckedChange = viewModel::setVibrate
|
||||||
)
|
)
|
||||||
|
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon = Icons.AutoMirrored.Filled.VolumeUp,
|
icon = Icons.AutoMirrored.Filled.VolumeUp,
|
||||||
title = stringResource(R.string.settings_notifications_sound),
|
title = stringResource(R.string.settings_notifications_sound),
|
||||||
checked = uiState.soundEnabled,
|
checked = uiState.soundEnabled,
|
||||||
onCheckedChange = viewModel::setSound
|
onCheckedChange = viewModel::setSound
|
||||||
)
|
)
|
||||||
|
|
||||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||||
|
|
||||||
// Encryption section
|
// ── Encryption — default ──────────────────────────────────────
|
||||||
SettingsSectionHeader(stringResource(R.string.settings_encryption))
|
SettingsSectionHeader(stringResource(R.string.settings_encryption))
|
||||||
|
|
||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon = Icons.Default.Lock,
|
icon = Icons.Default.Lock,
|
||||||
title = stringResource(R.string.settings_default_encryption),
|
title = stringResource(R.string.settings_default_encryption),
|
||||||
subtitle = uiState.defaultEncryption.name,
|
subtitle = uiState.defaultEncryption.name,
|
||||||
onClick = { showEncryptionDialog = true }
|
onClick = { showEncryptionDialog = true }
|
||||||
)
|
)
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||||
|
|
||||||
|
// ── OMEMO ─────────────────────────────────────────────────────
|
||||||
|
SettingsSectionHeader("OMEMO (XEP-0384)")
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("OMEMO Device Fingerprint") },
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
uiState.omemoFingerprint ?: "Not available — open a chat to initialise",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = { Icon(Icons.Default.Fingerprint, null) }
|
||||||
|
)
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||||
|
|
||||||
|
// ── OTR ───────────────────────────────────────────────────────
|
||||||
|
SettingsSectionHeader("OTR (Off-The-Record)")
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("OTR Sessions") },
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
"OTR sessions are established per conversation.\n" +
|
||||||
|
"Select OTR encryption in any chat to start a session.\n" +
|
||||||
|
"Sessions use ephemeral ECDH keys — perfect forward secrecy.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = { Icon(Icons.Default.SwapHoriz, null) }
|
||||||
|
)
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||||
|
|
||||||
|
// ── OpenPGP ───────────────────────────────────────────────────
|
||||||
|
SettingsSectionHeader("OpenPGP")
|
||||||
|
|
||||||
|
// Own key status
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("My PGP Key Pair") },
|
||||||
|
supportingContent = {
|
||||||
|
if (uiState.pgpHasOwnKey) {
|
||||||
|
Column {
|
||||||
|
Text("Fingerprint:", style = MaterialTheme.typography.labelSmall)
|
||||||
|
Text(
|
||||||
|
uiState.pgpOwnKeyFingerprint ?: "—",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
"No key pair. Import an armored secret key (.asc).",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
leadingContent = { Icon(Icons.Default.Key, null) },
|
||||||
|
trailingContent = {
|
||||||
|
if (uiState.pgpHasOwnKey) {
|
||||||
|
IconButton(onClick = { viewModel.deleteOwnPgpKey() }) {
|
||||||
|
Icon(Icons.Default.Delete, "Delete own key",
|
||||||
|
tint = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TextButton(onClick = { showPgpImportOwnDialog = true }) {
|
||||||
|
Text("Import")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Import own key button (also visible via trailing if no key)
|
||||||
|
if (!uiState.pgpHasOwnKey) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { showPgpImportOwnDialog = true },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Edit, null, Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Paste key")
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { keyFilePicker.launch("*/*") },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.FolderOpen, null, Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("From file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contact public keys
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Contact Public Keys") },
|
||||||
|
supportingContent = {
|
||||||
|
if (uiState.pgpContactKeys.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"No contact keys stored.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Column {
|
||||||
|
uiState.pgpContactKeys.forEach { jid ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
jid,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = { showPgpContactDeleteDialog = jid },
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete, "Remove",
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
leadingContent = { Icon(Icons.Default.People, null) }
|
||||||
|
)
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { showPgpImportContactDialog = true },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.PersonAdd, null, Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Add contact public key")
|
||||||
|
}
|
||||||
|
|
||||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||||
|
|
||||||
// About section
|
// ── About ─────────────────────────────────────────────────────
|
||||||
SettingsSectionHeader(stringResource(R.string.settings_about))
|
SettingsSectionHeader(stringResource(R.string.settings_about))
|
||||||
|
|
||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon = Icons.Default.Info,
|
icon = Icons.Default.Info,
|
||||||
title = stringResource(R.string.settings_version),
|
title = stringResource(R.string.settings_version),
|
||||||
subtitle = "1.0.0",
|
subtitle = "1.0.0",
|
||||||
onClick = {}
|
onClick = {}
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Theme dialog
|
// ── Theme dialog ──────────────────────────────────────────────────────
|
||||||
if (showThemeDialog) {
|
if (showThemeDialog) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showThemeDialog = false },
|
onDismissRequest = { showThemeDialog = false },
|
||||||
@@ -124,17 +303,14 @@ fun SettingsScreen(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable {
|
.clickable { viewModel.setTheme(theme); showThemeDialog = false }
|
||||||
viewModel.setTheme(theme)
|
|
||||||
showThemeDialog = false
|
|
||||||
}
|
|
||||||
.padding(12.dp),
|
.padding(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
RadioButton(selected = uiState.appTheme == theme, onClick = {
|
RadioButton(
|
||||||
viewModel.setTheme(theme)
|
selected = uiState.appTheme == theme,
|
||||||
showThemeDialog = false
|
onClick = { viewModel.setTheme(theme); showThemeDialog = false }
|
||||||
})
|
)
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Text(theme.toDisplayName())
|
Text(theme.toDisplayName())
|
||||||
}
|
}
|
||||||
@@ -147,7 +323,7 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encryption default dialog
|
// ── Default encryption dialog ─────────────────────────────────────────
|
||||||
if (showEncryptionDialog) {
|
if (showEncryptionDialog) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showEncryptionDialog = false },
|
onDismissRequest = { showEncryptionDialog = false },
|
||||||
@@ -158,19 +334,18 @@ fun SettingsScreen(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable {
|
.clickable { viewModel.setDefaultEncryption(type); showEncryptionDialog = false }
|
||||||
viewModel.setDefaultEncryption(type)
|
|
||||||
showEncryptionDialog = false
|
|
||||||
}
|
|
||||||
.padding(12.dp),
|
.padding(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
RadioButton(selected = uiState.defaultEncryption == type, onClick = {
|
RadioButton(
|
||||||
viewModel.setDefaultEncryption(type)
|
selected = uiState.defaultEncryption == type,
|
||||||
showEncryptionDialog = false
|
onClick = { viewModel.setDefaultEncryption(type); showEncryptionDialog = false }
|
||||||
})
|
)
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Text(type.name)
|
Column {
|
||||||
|
Text(type.name, fontWeight = FontWeight.Medium)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,6 +355,118 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── PGP import own key ────────────────────────────────────────────────
|
||||||
|
if (showPgpImportOwnDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showPgpImportOwnDialog = false; pgpKeyText = "" },
|
||||||
|
title = { Text("Import My PGP Secret Key") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
"Paste your armored PGP secret key below (-----BEGIN PGP PRIVATE KEY BLOCK-----…).",
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = pgpKeyText,
|
||||||
|
onValueChange = { pgpKeyText = it },
|
||||||
|
label = { Text("Armored PGP key") },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(160.dp),
|
||||||
|
maxLines = 8
|
||||||
|
)
|
||||||
|
TextButton(onClick = { keyFilePicker.launch("*/*") }) {
|
||||||
|
Icon(Icons.Default.FolderOpen, null)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text("Pick from file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = {
|
||||||
|
viewModel.importOwnPgpKey(pgpKeyText)
|
||||||
|
showPgpImportOwnDialog = false
|
||||||
|
pgpKeyText = ""
|
||||||
|
}, enabled = pgpKeyText.isNotBlank()) {
|
||||||
|
Text("Import")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showPgpImportOwnDialog = false; pgpKeyText = "" }) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PGP import contact key ────────────────────────────────────────────
|
||||||
|
if (showPgpImportContactDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showPgpImportContactDialog = false; pgpKeyText = ""; pgpContactJid = "" },
|
||||||
|
title = { Text("Add Contact Public Key") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = pgpContactJid,
|
||||||
|
onValueChange = { pgpContactJid = it },
|
||||||
|
label = { Text("Contact JID") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = pgpKeyText,
|
||||||
|
onValueChange = { pgpKeyText = it },
|
||||||
|
label = { Text("Armored public key") },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(140.dp),
|
||||||
|
maxLines = 8
|
||||||
|
)
|
||||||
|
TextButton(onClick = { keyFilePicker.launch("*/*") }) {
|
||||||
|
Icon(Icons.Default.FolderOpen, null)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text("Pick from file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.importContactPgpKey(pgpContactJid.trim(), pgpKeyText)
|
||||||
|
showPgpImportContactDialog = false
|
||||||
|
pgpKeyText = ""; pgpContactJid = ""
|
||||||
|
},
|
||||||
|
enabled = pgpContactJid.isNotBlank() && pgpKeyText.isNotBlank()
|
||||||
|
) { Text("Save") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showPgpImportContactDialog = false; pgpKeyText = ""; pgpContactJid = "" }) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PGP delete contact key confirmation ───────────────────────────────
|
||||||
|
showPgpContactDeleteDialog?.let { jid ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showPgpContactDeleteDialog = null },
|
||||||
|
title = { Text("Remove key?") },
|
||||||
|
text = { Text("Remove the PGP public key for $jid?") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
viewModel.deleteContactPgpKey(jid)
|
||||||
|
showPgpContactDeleteDialog = null
|
||||||
|
}) { Text("Remove", color = MaterialTheme.colorScheme.error) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showPgpContactDeleteDialog = null }) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -205,9 +492,7 @@ fun SettingsItem(
|
|||||||
supportingContent = if (subtitle.isNotBlank()) {
|
supportingContent = if (subtitle.isNotBlank()) {
|
||||||
{ Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
{ Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
} else null,
|
} else null,
|
||||||
leadingContent = {
|
leadingContent = { Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) },
|
||||||
Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
},
|
|
||||||
modifier = Modifier.clickable(onClick = onClick)
|
modifier = Modifier.clickable(onClick = onClick)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -221,12 +506,8 @@ fun SettingsSwitchItem(
|
|||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(title) },
|
headlineContent = { Text(title) },
|
||||||
leadingContent = {
|
leadingContent = { Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) },
|
||||||
Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
trailingContent = { Switch(checked = checked, onCheckedChange = onCheckedChange) }
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +516,3 @@ fun AppTheme.toDisplayName(): String = when (this) {
|
|||||||
AppTheme.LIGHT -> "Light"
|
AppTheme.LIGHT -> "Light"
|
||||||
AppTheme.DARK -> "Dark"
|
AppTheme.DARK -> "Dark"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import androidx.datastore.preferences.core.edit
|
|||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.manalejandro.alejabber.data.remote.EncryptionManager
|
||||||
|
import com.manalejandro.alejabber.data.remote.PgpManager
|
||||||
import com.manalejandro.alejabber.domain.model.EncryptionType
|
import com.manalejandro.alejabber.domain.model.EncryptionType
|
||||||
import com.manalejandro.alejabber.ui.theme.AppTheme
|
import com.manalejandro.alejabber.ui.theme.AppTheme
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -19,12 +21,22 @@ data class SettingsUiState(
|
|||||||
val notificationsEnabled: Boolean = true,
|
val notificationsEnabled: Boolean = true,
|
||||||
val vibrateEnabled: Boolean = true,
|
val vibrateEnabled: Boolean = true,
|
||||||
val soundEnabled: Boolean = true,
|
val soundEnabled: Boolean = true,
|
||||||
val defaultEncryption: EncryptionType = EncryptionType.OMEMO
|
val defaultEncryption: EncryptionType = EncryptionType.OMEMO,
|
||||||
|
// OMEMO
|
||||||
|
val omemoFingerprint: String? = null,
|
||||||
|
// OTR (informational only — sessions are per-chat)
|
||||||
|
// OpenPGP
|
||||||
|
val pgpHasOwnKey: Boolean = false,
|
||||||
|
val pgpOwnKeyFingerprint: String? = null,
|
||||||
|
val pgpContactKeys: List<String> = emptyList(), // JIDs with stored pub key
|
||||||
|
val pgpError: String? = null,
|
||||||
|
val pgpInfo: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel @Inject constructor(
|
||||||
private val dataStore: DataStore<Preferences>
|
private val dataStore: DataStore<Preferences>,
|
||||||
|
private val encryptionManager: EncryptionManager
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -43,45 +55,95 @@ class SettingsViewModel @Inject constructor(
|
|||||||
dataStore.data.collect { prefs ->
|
dataStore.data.collect { prefs ->
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
state.copy(
|
state.copy(
|
||||||
appTheme = try { AppTheme.valueOf(prefs[KEY_THEME] ?: "SYSTEM") } catch (e: Exception) { AppTheme.SYSTEM },
|
appTheme = try { AppTheme.valueOf(prefs[KEY_THEME] ?: "SYSTEM") } catch (_: Exception) { AppTheme.SYSTEM },
|
||||||
notificationsEnabled = prefs[KEY_NOTIFICATIONS] ?: true,
|
notificationsEnabled = prefs[KEY_NOTIFICATIONS] ?: true,
|
||||||
vibrateEnabled = prefs[KEY_VIBRATE] ?: true,
|
vibrateEnabled = prefs[KEY_VIBRATE] ?: true,
|
||||||
soundEnabled = prefs[KEY_SOUND] ?: true,
|
soundEnabled = prefs[KEY_SOUND] ?: true,
|
||||||
defaultEncryption = try { EncryptionType.valueOf(prefs[KEY_DEFAULT_ENCRYPTION] ?: "OMEMO") } catch (e: Exception) { EncryptionType.OMEMO }
|
defaultEncryption = try { EncryptionType.valueOf(prefs[KEY_DEFAULT_ENCRYPTION] ?: "OMEMO") } catch (_: Exception) { EncryptionType.OMEMO }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
refreshPgpState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun refreshPgpState() {
|
||||||
|
val pgp = encryptionManager.pgpManager()
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
pgpHasOwnKey = pgp.hasOwnKey(),
|
||||||
|
pgpOwnKeyFingerprint = pgp.getOwnKeyFingerprint(),
|
||||||
|
pgpContactKeys = pgp.listContactsWithKeys()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Preferences ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun setTheme(theme: AppTheme) {
|
fun setTheme(theme: AppTheme) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch { dataStore.edit { it[KEY_THEME] = theme.name } }
|
||||||
dataStore.edit { it[KEY_THEME] = theme.name }
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun setNotifications(enabled: Boolean) {
|
fun setNotifications(enabled: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch { dataStore.edit { it[KEY_NOTIFICATIONS] = enabled } }
|
||||||
dataStore.edit { it[KEY_NOTIFICATIONS] = enabled }
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun setVibrate(enabled: Boolean) {
|
fun setVibrate(enabled: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch { dataStore.edit { it[KEY_VIBRATE] = enabled } }
|
||||||
dataStore.edit { it[KEY_VIBRATE] = enabled }
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun setSound(enabled: Boolean) {
|
fun setSound(enabled: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch { dataStore.edit { it[KEY_SOUND] = enabled } }
|
||||||
dataStore.edit { it[KEY_SOUND] = enabled }
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun setDefaultEncryption(type: EncryptionType) {
|
fun setDefaultEncryption(type: EncryptionType) {
|
||||||
|
viewModelScope.launch { dataStore.edit { it[KEY_DEFAULT_ENCRYPTION] = type.name } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OMEMO ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun refreshOmemoFingerprint(accountId: Long) {
|
||||||
|
val fp = encryptionManager.getOwnOmemoFingerprint(accountId)
|
||||||
|
_uiState.update { it.copy(omemoFingerprint = fp) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OpenPGP ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Import the user's own armored secret key (from file or paste). */
|
||||||
|
fun importOwnPgpKey(armoredKey: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
dataStore.edit { it[KEY_DEFAULT_ENCRYPTION] = type.name }
|
try {
|
||||||
|
encryptionManager.pgpManager().saveOwnSecretKeyArmored(armoredKey)
|
||||||
|
refreshPgpState()
|
||||||
|
_uiState.update { it.copy(pgpInfo = "Own PGP key imported successfully.") }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.update { it.copy(pgpError = "Key import failed: ${e.message}") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete the user's own key pair. */
|
||||||
|
fun deleteOwnPgpKey() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
encryptionManager.pgpManager().saveOwnSecretKeyArmored("") // overwrite with empty
|
||||||
|
refreshPgpState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import a contact's public key. */
|
||||||
|
fun importContactPgpKey(jid: String, armoredKey: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
encryptionManager.pgpManager().saveContactPublicKey(jid, armoredKey)
|
||||||
|
refreshPgpState()
|
||||||
|
_uiState.update { it.copy(pgpInfo = "Public key for $jid saved.") }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.update { it.copy(pgpError = "Key import failed: ${e.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a contact's public key. */
|
||||||
|
fun deleteContactPgpKey(jid: String) {
|
||||||
|
encryptionManager.pgpManager().deleteContactPublicKey(jid)
|
||||||
|
refreshPgpState()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearPgpMessages() = _uiState.update { it.copy(pgpError = null, pgpInfo = null) }
|
||||||
|
}
|
||||||
|
|||||||
Referencia en una nueva incidencia
Block a user