+ * Cela supprime le besoin d'ouvrir manuellement le diagnostic avant
+ * d'utiliser le cadenas dans une conversation.
+ */
+ private void warmUpRuntimeAsync() {
+ Thread worker = new Thread(() -> {
+ for (int attempt = 0; attempt < 30; attempt++) {
+ try {
+ AbstractXMPPConnection connection =
+ SparkManager.getConnection();
+
+ if (connection != null
+ && connection.isAuthenticated()) {
+ installFallbackSuppressor(connection);
+ ensureRuntime();
+ startWireDiagnosticSilently();
+ LOG.info("Runtime OMEMO préinitialisé.");
+ return;
+ }
+
+ Thread.sleep(1000L);
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ return;
+ } catch (Throwable error) {
+ /*
+ * Le préchauffage ne doit jamais bloquer Spark
+ * ni afficher une fenêtre. L'erreur reste
+ * disponible dans le journal, puis le clic sur
+ * le cadenas pourra réessayer.
+ */
+ reportError(
+ "Préinitialisation OMEMO impossible",
+ error);
+ return;
+ }
+ }
+ },
+ "AVHIRAL-OMEMO-Warmup");
+
+ worker.setDaemon(true);
+ worker.start();
+ }
+
+ private void installMenu() {
+ SwingUtilities.invokeLater(new Runnable() {
+ @Override
+ public void run() {
+ avhiralMenu = new JMenu("AVHIRAL");
+ JMenuItem diagnostic = new JMenuItem("Diagnostic OMEMO");
+ diagnostic.addActionListener(
+ new java.awt.event.ActionListener() {
+ @Override
+ public void actionPerformed(
+ java.awt.event.ActionEvent event) {
+ showDiagnostics();
+ }
+ });
+
+ JMenuItem purge = new JMenuItem("Republier mes appareils OMEMO");
+ purge.addActionListener(event -> purgeOwnDevices());
+ JMenuItem startDiagnostic = new JMenuItem("Démarrer diagnostic filaire");
+
+ startDiagnostic.addActionListener(event -> startWireDiagnostic());
+
+ JMenuItem stopDiagnostic = new JMenuItem("Arrêter diagnostic filaire");
+ stopDiagnostic.addActionListener(event -> stopWireDiagnostic());
+
+ JMenuItem openDiagnostic = new JMenuItem("Ouvrir journal filaire");
+ openDiagnostic.addActionListener(event -> openWireDiagnostic());
+
+ JMenuItem clearDiagnostic = new JMenuItem("Effacer journal filaire");
+ clearDiagnostic.addActionListener(event -> clearWireDiagnostic());
+
+ avhiralMenu.add(diagnostic);
+ avhiralMenu.add(purge);
+ avhiralMenu.addSeparator();
+ avhiralMenu.add(startDiagnostic);
+ avhiralMenu.add(stopDiagnostic);
+ avhiralMenu.add(openDiagnostic);
+ avhiralMenu.add(clearDiagnostic);
+
+ SparkManager.getMainWindow().getJMenuBar().add(avhiralMenu);
+ SparkManager.getMainWindow().getJMenuBar().revalidate();
+ SparkManager.getMainWindow().getJMenuBar().repaint();
+ }
+ });
+ }
+
+ @Override
+ public void chatRoomOpened(ChatRoom room) {
+ attachRoom(room);
+ }
+
+ @Override
+ public void chatRoomActivated(ChatRoom room) {
+ attachRoom(room);
+ }
+
+ private synchronized void attachRoom(ChatRoom room) {
+ if (!(room instanceof ChatRoomImpl)) {
+ return;
+ }
+ if (controllers.containsKey(room)) {
+ return;
+ }
+ IntegratedChatController controller = new IntegratedChatController((ChatRoomImpl) room, this);
+ controllers.put(room, controller);
+ }
+
+ @Override
+ public synchronized void chatRoomClosed(ChatRoom room) {
+ detachRoom(room);
+ }
+
+ @Override
+ public synchronized void chatRoomLeft(ChatRoom room) {
+ detachRoom(room);
+ }
+
+ private void detachRoom(ChatRoom room) {
+ IntegratedChatController controller = controllers.remove(room);
+ if (controller != null) {
+ controller.dispose();
+ }
+ }
+
+ @Override
+ public void userHasJoined(ChatRoom room, String user) {
+ // Non utilisé pour les conversations privées.
+ }
+
+ @Override
+ public void userHasLeft(ChatRoom room, String user) {
+ // Non utilisé pour les conversations privées.
+ }
+
+
+ private synchronized void installFallbackSuppressor(AbstractXMPPConnection connection) {
+ if (fallbackSuppressor != null) {
+ return;
+ }
+ fallbackSuppressor = new OmemoFallbackSuppressor(this);
+ /*
+ * Le listener synchrone intervient avant la chaîne asynchrone de Spark
+ * qui ajoute le corps du message au transcript.
+ */
+ connection.addSyncStanzaListener(fallbackSuppressor, StanzaTypeFilter.MESSAGE);
+ LOG.info("Suppresseur synchrone du fallback OMEMO installé.");
+ }
+
+ private synchronized void removeFallbackSuppressor() {
+ AbstractXMPPConnection connection = SparkManager.getConnection();
+ if (connection != null && fallbackSuppressor != null) {
+ connection.removeSyncStanzaListener(fallbackSuppressor);
+ }
+ fallbackSuppressor = null;
+ }
+
+ @Override
+ public void onFallbackSuppressed(String from, String stanzaId, String originalBody) {
+ LOG.info("Fallback OMEMO supprimé : from=" + from + ", stanzaId=" + stanzaId);
+ if (diagnostic != null) {
+ diagnostic.logRuntimeEvent(
+ "FALLBACK SUPPRESSED",
+ "from=" + from + "\n"
+ + "stanzaId=" + stanzaId + "\n"
+ + "body=" + originalBody);
+ }
+ }
+
+ public synchronized void ensureRuntime() throws Exception {
+ if (runtime != null && runtime.isReady()) {
+ return;
+ }
+
+ AbstractXMPPConnection connection =
+ SparkManager.getConnection();
+
+ if (connection == null || !connection.isAuthenticated()) {
+ throw new IllegalStateException(
+ "Spark n'est pas authentifié.");
+ }
+
+ installFallbackSuppressor(connection);
+ File base = new File(
+ System.getenv("APPDATA") != null
+ ? System.getenv("APPDATA")
+ : System.getProperty("user.home"));
+
+ File store = new File(
+ new File(base, "Spark"),
+ "avhiral-omemo");
+
+ runtime = new OmemoRuntime(connection, store);
+ runtime.addListener(this);
+ runtime.initialize();
+ }
+
+ public synchronized OmemoRuntime getRuntime() {
+ return runtime;
+ }
+
+
+ private static boolean containsLegacyOmemo(MessageBuilder message) {
+ return message != null && message.hasExtension(OmemoElement_VAxolotl.NAMESPACE);
+ }
+
+ private static boolean containsLegacyOmemo(Message message) {
+ return message != null && message.hasExtension(OmemoElement_VAxolotl.NAMESPACE);
+ }
+
+ /**
+ * Dernière barrière juste avant l'affichage graphique par
+ * TranscriptWindow.insertMessage().
+ */
+ @Override
+ public boolean isMessageIntercepted(TranscriptWindow window, String userid, Message message) {
+ if (!containsLegacyOmemo(message)) {
+ return false;
+ }
+
+ String body = message.getBody();
+ if (body == null || "[This message is OMEMO encrypted]".equals(body.trim())) {
+ if (diagnostic != null) {
+ diagnostic.logRuntimeEvent(
+ "TRANSCRIPT FALLBACK BLOCKED",
+ "userid=" + userid + "\n"
+ + "stanzaId=" + message.getStanzaId() + "\n"
+ + "body=" + body);
+ }
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public void onSecureMessage(final String from, final String body) {
+ if (diagnostic != null) {
+ diagnostic.logRuntimeEvent("DECRYPTED MESSAGE", "from=" + from + "\n" + "body=" + body);
+ }
+ SwingUtilities.invokeLater(() -> {
+ try {
+ EntityBareJid jid = JidCreate.entityBareFrom(from);
+ ChatRoom existing = null;
+ for (ChatRoom room : controllers.keySet()) {
+ if (room.getBareJid() != null
+ && room.getBareJid().equals(jid)) {
+ existing = room;
+ break;
+ }
+ }
+ if (existing == null) {
+ existing = ChatManager.getInstance().getChatRoom(jid);
+ }
+
+ attachRoom(existing);
+ IntegratedChatController controller = controllers.get(existing);
+
+ if (controller == null) {
+ for (Map.Entry
+ * Le bouton et la touche Entrée sont interceptés uniquement lorsque le
+ * cadenas OMEMO est actif. En mode désactivé, Spark conserve son comportement
+ * natif.
+ */
+public final class IntegratedChatController implements MessageEventListener {
+ private static final String ENTER_ACTION = "avhiral-omemo-send-enter";
+
+ private final ChatRoomImpl room;
+ private final AvhOmemoPlugin plugin;
+ private final ChatInputEditor editor;
+ private final JButton sendButton;
+ private final JToggleButton lockButton;
+
+ private final ActionListener[] originalSendListeners;
+ private final Object originalEnterActionKey;
+ private final Action originalEnterAction;
+
+ private volatile boolean enabled;
+ private volatile boolean operationInProgress;
+
+ public IntegratedChatController(
+ ChatRoomImpl room,
+ AvhOmemoPlugin plugin) {
+
+ this.room = room;
+ this.plugin = plugin;
+ this.editor = room.getChatInputEditor();
+ this.sendButton = room.getSendButton();
+
+ this.originalSendListeners = sendButton.getActionListeners();
+
+ InputMap inputMap = editor.getInputMap(JComponent.WHEN_FOCUSED);
+
+ ActionMap actionMap = editor.getActionMap();
+
+ KeyStroke enter = KeyStroke.getKeyStroke("ENTER");
+
+ this.originalEnterActionKey = inputMap.get(enter);
+ this.originalEnterAction = originalEnterActionKey == null ? null : actionMap.get(originalEnterActionKey);
+
+ this.lockButton = createLockButton();
+
+ installSendInterception();
+ installEnterInterception();
+ room.addMessageEventListener(this);
+
+ // addEditorComponent place le composant dans la barre inférieure
+ // de saisie, au même niveau que les autres outils de Spark.
+ room.addEditorComponent(lockButton);
+ updateVisualState();
+ }
+
+ private JToggleButton createLockButton() {
+ final JToggleButton button = new JToggleButton("🔓 OMEMO");
+ button.setToolTipText("Activer le chiffrement OMEMO pour cette conversation");
+ button.setFocusable(false);
+ button.addActionListener(event -> {
+ if (button.isSelected()) {
+ enableOmemo();
+ } else {
+ enabled = false;
+ updateVisualState();
+ }
+ });
+ return button;
+ }
+
+ private void installSendInterception() {
+ for (ActionListener listener : originalSendListeners) {
+ sendButton.removeActionListener(listener);
+ }
+
+ sendButton.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent event) {
+ if (enabled) {
+ sendEncryptedFromEditor();
+ } else {
+ invokeOriginalSend(event);
+ }
+ }
+ });
+ }
+
+ private void installEnterInterception() {
+ final InputMap inputMap = editor.getInputMap(JComponent.WHEN_FOCUSED);
+ final ActionMap actionMap = editor.getActionMap();
+ final KeyStroke enter = KeyStroke.getKeyStroke("ENTER");
+
+ inputMap.put(enter, ENTER_ACTION);
+
+ actionMap.put(ENTER_ACTION, new AbstractAction() {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void actionPerformed(ActionEvent event) {
+ if (enabled) {
+ sendEncryptedFromEditor();
+ } else if (originalEnterAction != null) {
+ originalEnterAction.actionPerformed(event);
+ } else {
+ invokeOriginalSend(event);
+ }
+ }
+ });
+ }
+
+ private void enableOmemo() {
+ operationInProgress = true;
+ updateVisualState();
+ new SwingWorker
+ * Cette classe ne modifie aucune stanza et ne journalise pas les clés privées.
+ */
+public final class OmemoDiagnosticService implements AutoCloseable {
+ private static final String LEGACY_NAMESPACE = "eu.siacs.conversations.axolotl";
+ private static final String OMEMO2_NAMESPACE = "urn:xmpp:omemo:2";
+
+ private final Object fileLock = new Object();
+ private final AbstractXMPPConnection connection;
+ private final File logFile;
+
+ private StanzaListener incomingListener;
+ private StanzaListener outgoingListener;
+ private volatile boolean running;
+
+ public OmemoDiagnosticService(
+ AbstractXMPPConnection connection,
+ File logFile) {
+
+ this.connection = connection;
+ this.logFile = logFile;
+ }
+
+ public synchronized void start() {
+ if (running) {
+ return;
+ }
+
+ if (connection == null || !connection.isAuthenticated()) {
+ throw new IllegalStateException("Connexion XMPP non authentifiée.");
+ }
+
+ incomingListener = new StanzaListener() {
+ @Override
+ public void processStanza(Stanza stanza) {
+ logMessage("IN", stanza);
+ }
+ };
+
+ outgoingListener = new StanzaListener() {
+ @Override
+ public void processStanza(Stanza stanza) {
+ logMessage("OUT", stanza);
+ }
+ };
+
+ connection.addAsyncStanzaListener(
+ incomingListener,
+ StanzaTypeFilter.MESSAGE);
+
+ connection.addStanzaSendingListener(
+ outgoingListener,
+ StanzaTypeFilter.MESSAGE);
+
+ running = true;
+
+ appendSection(
+ "DIAGNOSTIC START",
+ "localJid=" + safe(connection.getUser()) + "\n"
+ + "connectionClass="
+ + connection.getClass().getName() + "\n"
+ + providerStatus());
+ }
+
+ public synchronized void stop() {
+ if (!running) {
+ return;
+ }
+
+ if (incomingListener != null) {
+ connection.removeAsyncStanzaListener(incomingListener);
+ }
+
+ if (outgoingListener != null) {
+ connection.removeStanzaSendingListener(outgoingListener);
+ }
+
+ incomingListener = null;
+ outgoingListener = null;
+ running = false;
+
+ appendSection("DIAGNOSTIC STOP", "Capture arrêtée.");
+ }
+
+ public boolean isRunning() {
+ return running;
+ }
+
+ public File getLogFile() {
+ return logFile;
+ }
+
+ public void clear() throws Exception {
+ synchronized (fileLock) {
+ if (logFile.exists() && !logFile.delete()) {
+ throw new IllegalStateException("Impossible d'effacer : " + logFile.getAbsolutePath());
+ }
+ }
+ }
+
+ public void openLog() throws Exception {
+ ensureParent();
+
+ if (!logFile.exists()) {
+ appendSection(
+ "DIAGNOSTIC",
+ "Journal créé manuellement.");
+ }
+
+ if (!Desktop.isDesktopSupported()) {
+ throw new IllegalStateException(
+ "Desktop.open n'est pas disponible. Journal : "
+ + logFile.getAbsolutePath());
+ }
+
+ Desktop.getDesktop().open(logFile);
+ }
+
+ public void logRuntimeEvent(String category, String details) {
+ appendSection("RUNTIME " + category, details == null ? "(null)" : details);
+ }
+
+ public void logRuntimeError(String category, Throwable error) {
+ StringBuilder report = new StringBuilder();
+ Throwable current = error;
+ int depth = 0;
+
+ while (current != null && depth < 16) {
+ report.append("cause[").append(depth).append("].class=")
+ .append(current.getClass().getName())
+ .append('\n');
+ report.append("cause[").append(depth).append("].message=")
+ .append(safe(current.getMessage()))
+ .append('\n');
+
+ current = current.getCause();
+ depth++;
+ }
+
+ appendSection(
+ "RUNTIME ERROR " + category,
+ report.toString());
+ }
+
+ private void logMessage(
+ String direction,
+ Stanza stanza) {
+
+ if (!(stanza instanceof Message)) {
+ return;
+ }
+
+ Message message = (Message) stanza;
+ StringBuilder report = new StringBuilder();
+
+ report.append("direction=").append(direction).append('\n');
+ report.append("messageClass=")
+ .append(message.getClass().getName())
+ .append('\n');
+ report.append("from=").append(safe(message.getFrom())).append('\n');
+ report.append("to=").append(safe(message.getTo())).append('\n');
+ report.append("type=").append(safe(message.getType())).append('\n');
+ report.append("stanzaId=")
+ .append(safe(message.getStanzaId()))
+ .append('\n');
+ report.append("body=").append(safe(message.getBody())).append('\n');
+
+ boolean legacyEncrypted = false;
+ boolean omemo2Encrypted = false;
+ boolean carbon = false;
+ int extensionIndex = 0;
+
+ for (var extension : message.getExtensions()) {
+ extensionIndex++;
+
+ String element = safe(extension.getElementName());
+ String namespace = safe(extension.getNamespace());
+
+ report.append("extension[")
+ .append(extensionIndex)
+ .append("].class=")
+ .append(extension.getClass().getName())
+ .append('\n');
+
+ report.append("extension[")
+ .append(extensionIndex)
+ .append("].element=")
+ .append(element)
+ .append('\n');
+
+ report.append("extension[")
+ .append(extensionIndex)
+ .append("].namespace=")
+ .append(namespace)
+ .append('\n');
+
+ if ("encrypted".equals(element)
+ && LEGACY_NAMESPACE.equals(namespace)) {
+ legacyEncrypted = true;
+ }
+
+ if ("encrypted".equals(element)
+ && OMEMO2_NAMESPACE.equals(namespace)) {
+ omemo2Encrypted = true;
+ }
+
+ if ("sent".equals(element)
+ || "received".equals(element)) {
+ if ("urn:xmpp:carbons:2".equals(namespace)) {
+ carbon = true;
+ }
+ }
+ }
+
+ report.append("detected.legacyAxolotl=")
+ .append(legacyEncrypted)
+ .append('\n');
+ report.append("detected.omemo2=")
+ .append(omemo2Encrypted)
+ .append('\n');
+ report.append("detected.carbon=")
+ .append(carbon)
+ .append('\n');
+
+ report.append("provider.legacy.encrypted=")
+ .append(providerClass(
+ "encrypted",
+ LEGACY_NAMESPACE))
+ .append('\n');
+ report.append("provider.legacy.list=")
+ .append(providerClass(
+ "list",
+ LEGACY_NAMESPACE))
+ .append('\n');
+ report.append("provider.legacy.bundle=")
+ .append(providerClass(
+ "bundle",
+ LEGACY_NAMESPACE))
+ .append('\n');
+ report.append("provider.omemo2.encrypted=")
+ .append(providerClass(
+ "encrypted",
+ OMEMO2_NAMESPACE))
+ .append('\n');
+
+ report.append("xml=")
+ .append(safeXml(message))
+ .append('\n');
+
+ appendSection(
+ "XMPP MESSAGE " + direction,
+ report.toString());
+ }
+
+ private String providerStatus() {
+ StringBuilder report = new StringBuilder();
+
+ report.append("provider.legacy.encrypted=")
+ .append(providerClass(
+ "encrypted",
+ LEGACY_NAMESPACE))
+ .append('\n');
+ report.append("provider.legacy.list=")
+ .append(providerClass(
+ "list",
+ LEGACY_NAMESPACE))
+ .append('\n');
+ report.append("provider.legacy.bundle=")
+ .append(providerClass(
+ "bundle",
+ LEGACY_NAMESPACE))
+ .append('\n');
+ report.append("provider.omemo2.encrypted=")
+ .append(providerClass(
+ "encrypted",
+ OMEMO2_NAMESPACE))
+ .append('\n');
+
+ return report.toString();
+ }
+
+ private static String providerClass(
+ String element,
+ String namespace) {
+
+ Object provider =
+ ProviderManager.getExtensionProvider(
+ element,
+ namespace);
+
+ return provider == null
+ ? "(none)"
+ : provider.getClass().getName();
+ }
+
+ private static String safeXml(Stanza stanza) {
+ try {
+ CharSequence xml = stanza.toXML((String) null);
+ return xml == null
+ ? "(null)"
+ : xml.toString();
+ } catch (Throwable error) {
+ return "(XML impossible: "
+ + error.getClass().getName()
+ + ": "
+ + safe(error.getMessage())
+ + ")";
+ }
+ }
+
+ private void appendSection(
+ String title,
+ String content) {
+
+ synchronized (fileLock) {
+ PrintWriter writer = null;
+
+ try {
+ ensureParent();
+
+ writer = new PrintWriter(
+ new FileWriter(logFile, true));
+
+ writer.println(
+ "============================================================");
+ writer.println(
+ new SimpleDateFormat(
+ "yyyy-MM-dd HH:mm:ss.SSS",
+ Locale.ROOT).format(new Date()));
+ writer.println(title);
+ writer.print(content);
+
+ if (!content.endsWith("\n")) {
+ writer.println();
+ }
+
+ writer.flush();
+ } catch (Exception ignored) {
+ // Le diagnostic ne doit jamais bloquer Spark.
+ } finally {
+ if (writer != null) {
+ writer.close();
+ }
+ }
+ }
+ }
+
+ private void ensureParent() {
+ File parent = logFile.getParentFile();
+
+ if (parent != null && !parent.exists()) {
+ parent.mkdirs();
+ }
+ }
+
+ private static String safe(Object value) {
+ return value == null
+ ? "(null)"
+ : String.valueOf(value);
+ }
+
+ @Override
+ public void close() {
+ stop();
+ }
+}
diff --git a/plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/OmemoFallbackSuppressor.java b/plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/OmemoFallbackSuppressor.java
new file mode 100644
index 000000000..48e32e879
--- /dev/null
+++ b/plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/OmemoFallbackSuppressor.java
@@ -0,0 +1,69 @@
+package org.jivesoftware.spark.plugin.omemo;
+
+import java.util.logging.Logger;
+
+import org.jivesoftware.smack.StanzaListener;
+import org.jivesoftware.smack.packet.Message;
+import org.jivesoftware.smack.packet.Stanza;
+import org.jivesoftware.smackx.omemo.element.OmemoElement;
+import org.jivesoftware.smackx.omemo.element.OmemoElement_VAxolotl;
+
+/**
+ * Supprime le corps de compatibilité OMEMO avant que Spark ne l'insère dans le
+ * transcript.
+ *
+ * Le moteur OMEMO de Smack déchiffre ensuite la charge
+ * OmemoElement_VAxolotl et AvhOmemoPlugin.onSecureMessage() affiche le texte
+ * clair.
+ *
+ * Ce listener doit être enregistré avec addSyncStanzaListener(), pas avec un
+ * MessageEventListener Spark : ce dernier intervient trop tard, après
+ * l'affichage du fallback.
+ */
+public final class OmemoFallbackSuppressor implements StanzaListener {
+ private static final Logger LOG =
+ Logger.getLogger(OmemoFallbackSuppressor.class.getName());
+
+ public interface Listener {
+ void onFallbackSuppressed(
+ String from,
+ String stanzaId,
+ String originalBody);
+ }
+
+ private final Listener listener;
+
+ public OmemoFallbackSuppressor(Listener listener) {
+ this.listener = listener;
+ }
+
+ @Override
+ public void processStanza(Stanza stanza) {
+ if (!(stanza instanceof Message)) {
+ return;
+ }
+ Message message = (Message) stanza;
+ if (!containsLegacyOmemo(message)) {
+ return;
+ }
+ String body = message.getBody();
+ if (body == null) {
+ return;
+ }
+ /*
+ * On ne supprime le corps que si une vraie charge OMEMO legacy est
+ * présente. Le texte chiffré reste dans l'extension
+ * Ne pas appeler cette méthode lors de l'activation du cadenas : certains
+ * serveurs Openfire renvoient alors un SimplePayload que Smack 4.4.6 ne
+ * sait pas convertir directement en OmemoDeviceListElement.
+ */
+ public void requestDeviceListUpdate(BareJid recipient) throws Exception {
+ if (!isReady()) {
+ throw new IllegalStateException(
+ "OMEMO non initialisé.");
+ }
+ manager.requestDeviceListUpdateFor(recipient);
+ }
+
+ public void purgeOwnDeviceList() throws Exception {
+ if (!isReady()) {
+ throw new IllegalStateException(
+ "OMEMO non initialisé.");
+ }
+
+ manager.purgeDeviceList();
+ emitStatus(
+ "Liste locale des appareils OMEMO republiée.");
+ }
+
+ private void notifySecureMessage(String from, String body) {
+ for (Listener listener : listeners) {
+ listener.onSecureMessage(from, body == null ? "" : body);
+ }
+ }
+
+ private void emitStatus(String status) {
+ LOG.info(status);
+ for (Listener listener : listeners) {
+ listener.onStatus(status);
+ }
+ }
+
+ @Override
+ public synchronized void close() {
+ if (manager != null && omemoListener != null) {
+ manager.removeOmemoMessageListener(omemoListener);
+ }
+
+ omemoListener = null;
+ manager = null;
+ listeners.clear();
+ }
+}
diff --git a/plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/PersistentTrustCallback.java b/plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/PersistentTrustCallback.java
new file mode 100644
index 000000000..9112f02ec
--- /dev/null
+++ b/plugins/omemo/src/main/java/org/jivesoftware/spark/plugin/omemo/PersistentTrustCallback.java
@@ -0,0 +1,101 @@
+package org.jivesoftware.spark.plugin.omemo;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.Properties;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import org.jivesoftware.smackx.omemo.internal.OmemoDevice;
+import org.jivesoftware.smackx.omemo.trust.OmemoTrustCallback;
+import org.jivesoftware.smackx.omemo.trust.TrustState;
+import org.jivesoftware.smackx.omemo.trust.OmemoFingerprint;
+
+/**
+ * Persistance locale des décisions de confiance OMEMO.
+ *
+ * Politique Alpha 0.11 : TOFU (Trust On First Use).
+ * Une nouvelle empreinte est acceptée lors de sa première observation, puis
+ * mémorisée. Une modification ultérieure produit une nouvelle entrée et doit
+ * être vérifiée via l'interface de gestion des empreintes d'une version future.
+ */
+public final class PersistentTrustCallback implements OmemoTrustCallback {
+ private static final Logger LOG = Logger.getLogger(PersistentTrustCallback.class.getName());
+
+ private final File trustFile;
+ private final Properties states = new Properties();
+
+ public PersistentTrustCallback(File trustFile) throws IOException {
+ this.trustFile = trustFile;
+ File parent = trustFile.getParentFile();
+ if (parent != null && !parent.exists() && !parent.mkdirs()) {
+ throw new IOException("Impossible de créer le dossier de confiance : " + parent);
+ }
+ load();
+ }
+
+ @Override
+ public synchronized TrustState getTrust(OmemoDevice device, OmemoFingerprint fingerprint) {
+ String key = key(device, fingerprint);
+ String stored = states.getProperty(key);
+ if (stored != null) {
+ try {
+ return TrustState.valueOf(stored);
+ } catch (IllegalArgumentException invalidState) {
+ LOG.log(Level.WARNING, "État de confiance invalide pour " + key, invalidState);
+ }
+ }
+
+ // TOFU : première empreinte observée acceptée puis figée localement.
+ states.setProperty(key, TrustState.trusted.name());
+ try {
+ save();
+ } catch (IOException error) {
+ LOG.log(Level.WARNING, "Impossible d'enregistrer la décision TOFU.", error);
+ }
+
+ return TrustState.trusted;
+ }
+
+ @Override
+ public synchronized void setTrust(OmemoDevice device, OmemoFingerprint fingerprint, TrustState state) {
+ if (state == null) {
+ state = TrustState.undecided;
+ }
+ states.setProperty(key(device, fingerprint), state.name());
+ try {
+ save();
+ } catch (IOException error) {
+ LOG.log(Level.WARNING, "Impossible d'enregistrer la décision de confiance.", error);
+ }
+ }
+
+ private static String key(OmemoDevice device, OmemoFingerprint fingerprint) {
+ return device.toString() + "|" + fingerprint.toString().toLowerCase();
+ }
+
+ private void load() throws IOException {
+ if (!trustFile.isFile()) {
+ return;
+ }
+ try (FileInputStream input = new FileInputStream(trustFile)) {
+ states.load(input);
+ }
+ }
+
+ private void save() throws IOException {
+ File temporary = new File(trustFile.getParentFile(), trustFile.getName() + ".tmp");
+ try (FileOutputStream output = new FileOutputStream(temporary)) {
+ states.store(output, "AVHIRAL OMEMO trust decisions - local TOFU store");
+ output.getFD().sync();
+ }
+ if (trustFile.exists() && !trustFile.delete()) {
+ throw new IOException("Impossible de remplacer le fichier de confiance.");
+ }
+ if (!temporary.renameTo(trustFile)) {
+ throw new IOException("Impossible d'activer le nouveau fichier de confiance.");
+ }
+ }
+}
diff --git a/plugins/omemo/src/main/plugin/plugin.xml b/plugins/omemo/src/main/plugin/plugin.xml
new file mode 100644
index 000000000..9ef191ec4
--- /dev/null
+++ b/plugins/omemo/src/main/plugin/plugin.xml
@@ -0,0 +1,11 @@
+
+