diff --git a/build.gradle b/build.gradle index c6e5f252d..e7cec89a4 100644 --- a/build.gradle +++ b/build.gradle @@ -71,6 +71,7 @@ dependencies { implementation 'com.google.guava:guava:32.1.3-android' implementation 'io.michaelrocks:libphonenumber-android:8.13.17' implementation 'im.conversations.webrtc:webrtc-android:119.0.0' + implementation 'org.jitsi:org.otr4j:0.23' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation "androidx.recyclerview:recyclerview:1.2.1" @@ -83,6 +84,7 @@ dependencies { implementation 'com.github.singpolyma:TokenAutoComplete:bfa93780e0' + implementation 'com.github.kizitonwose.colorpreference:core:1.1.0' implementation 'com.github.kizitonwose.colorpreference:support:1.1.0' implementation 'com.caverock:androidsvg-aar:1.4' implementation 'com.github.singpolyma:Better-Link-Movement-Method:4df081e1e4' @@ -102,8 +104,8 @@ android { defaultConfig { minSdkVersion 24 targetSdkVersion 34 - versionCode 42115 - versionName "2.3.1" + versionCode 42116 + versionName "2.3.2" archivesBaseName += "-$versionName" applicationId "eu.siacs.conversations.classic" resValue "string", "applicationId", applicationId diff --git a/src/conversations/java/eu/siacs/conversations/ui/ManageAccountActivity.java b/src/conversations/java/eu/siacs/conversations/ui/ManageAccountActivity.java index aab32220d..67803b387 100644 --- a/src/conversations/java/eu/siacs/conversations/ui/ManageAccountActivity.java +++ b/src/conversations/java/eu/siacs/conversations/ui/ManageAccountActivity.java @@ -36,6 +36,7 @@ import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.services.XmppConnectionService.OnAccountUpdate; import eu.siacs.conversations.ui.adapter.AccountAdapter; import eu.siacs.conversations.ui.util.MenuDoubleTabUtil; +import eu.siacs.conversations.utils.UIHelper; import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.XmppConnection; @@ -44,8 +45,14 @@ import static eu.siacs.conversations.utils.PermissionUtils.writeGranted; import com.google.android.material.bottomnavigation.BottomNavigationView; import com.google.android.material.navigation.NavigationBarView; +import com.kizitonwose.colorpreference.ColorDialog; +import com.kizitonwose.colorpreference.ColorShape; -public class ManageAccountActivity extends XmppActivity implements OnAccountUpdate, KeyChainAliasCallback, XmppConnectionService.OnAccountCreated, AccountAdapter.OnTglAccountState { +public class ManageAccountActivity extends XmppActivity implements OnAccountUpdate, + KeyChainAliasCallback, + XmppConnectionService.OnAccountCreated, + AccountAdapter.OnTglAccountState, + ColorDialog.OnColorSelectedListener { private final String STATE_SELECTED_ACCOUNT = "selected_account"; @@ -61,6 +68,18 @@ public class ManageAccountActivity extends XmppActivity implements OnAccountUpda protected Pair mPostponedActivityResult = null; + private AccountAdapter.ColorSelectorListener colorSelectorListener = new AccountAdapter.ColorSelectorListener() { + @Override + public void onColorPickerRequested(Jid accountJid, int currentColor) { + new ColorDialog.Builder(ManageAccountActivity.this) + .setColorShape(ColorShape.CIRCLE) + .setColorChoices(R.array.themeColorsOverride) + .setSelectedColor(currentColor) + .setTag(accountJid.asBareJid().toEscapedString()) + .show(); + } + }; + @Override public void onAccountUpdate() { refreshUi(); @@ -102,7 +121,7 @@ public class ManageAccountActivity extends XmppActivity implements OnAccountUpda } accountListView = findViewById(R.id.account_list); - this.mAccountAdapter = new AccountAdapter(this, accountList); + this.mAccountAdapter = new AccountAdapter(this, accountList, colorSelectorListener); accountListView.setAdapter(this.mAccountAdapter); accountListView.setOnItemClickListener((arg0, view, position, arg3) -> switchToAccount(accountList.get(position))); registerForContextMenu(accountListView); @@ -158,6 +177,13 @@ public class ManageAccountActivity extends XmppActivity implements OnAccountUpda super.onSaveInstanceState(savedInstanceState); } + @Override + protected void onDestroy() { + super.onDestroy(); + colorSelectorListener = null; + mAccountAdapter.colorSelectorListener = null; + } + @Override public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { super.onCreateContextMenu(menu, v, menuInfo); @@ -349,6 +375,12 @@ public class ManageAccountActivity extends XmppActivity implements OnAccountUpda } } + @Override + public void onColorSelected(int newColor, String tag) { + UIHelper.overrideAccountColor(this, tag, newColor); + refreshUiReal(); + } + private void addAccountFromKey() { try { KeyChain.choosePrivateKeyAlias(this, this, null, null, null, -1, null); diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index d848e4027..8c9a0b9b6 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -299,6 +299,11 @@ + bookmarks = new HashMap<>(); private Presence.Status presenceStatus; private String presenceStatusMessage; @@ -535,6 +544,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable } public void initAccountServices(final XmppConnectionService context) { + this.mOtrService = new OtrService(context, this); this.axolotlService = new AxolotlService(this, context); this.pgpDecryptionService = new PgpDecryptionService(context); if (xmppConnection != null) { @@ -542,6 +552,10 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable } } + public OtrService getOtrService() { + return this.mOtrService; + } + public PgpDecryptionService getPgpDecryptionService() { return this.pgpDecryptionService; } @@ -554,6 +568,27 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable this.xmppConnection = connection; } + public String getOtrFingerprint() { + if (this.otrFingerprint == null) { + try { + if (this.mOtrService == null) { + return null; + } + final PublicKey publicKey = this.mOtrService.getPublicKey(); + if (publicKey == null || !(publicKey instanceof DSAPublicKey)) { + return null; + } + this.otrFingerprint = new OtrCryptoEngineImpl().getFingerprint(publicKey).toLowerCase(Locale.US); + return this.otrFingerprint; + } catch (final OtrCryptoException ignored) { + return null; + } + } else { + return this.otrFingerprint; + } + } + + public String getRosterVersion() { if (this.rosterVersion == null) { return ""; @@ -721,6 +756,10 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable private List getFingerprints() { ArrayList fingerprints = new ArrayList<>(); + final String otr = this.getOtrFingerprint(); + if (otr != null) { + fingerprints.add(new XmppUri.Fingerprint(XmppUri.FingerprintType.OTR, otr)); + } if (axolotlService == null) { return fingerprints; } diff --git a/src/main/java/eu/siacs/conversations/entities/Contact.java b/src/main/java/eu/siacs/conversations/entities/Contact.java index 6b0554240..53f0134bf 100644 --- a/src/main/java/eu/siacs/conversations/entities/Contact.java +++ b/src/main/java/eu/siacs/conversations/entities/Contact.java @@ -348,6 +348,47 @@ public class Contact implements ListItem, Blockable { return groups; } + public ArrayList getOtrFingerprints() { + synchronized (this.keys) { + final ArrayList fingerprints = new ArrayList(); + try { + if (this.keys.has("otr_fingerprints")) { + final JSONArray prints = this.keys.getJSONArray("otr_fingerprints"); + for (int i = 0; i < prints.length(); ++i) { + final String print = prints.isNull(i) ? null : prints.getString(i); + if (print != null && !print.isEmpty()) { + fingerprints.add(prints.getString(i).toLowerCase(Locale.US)); + } + } + } + } catch (final JSONException ignored) { + + } + return fingerprints; + } + } + + public boolean addOtrFingerprint(String print) { + synchronized (this.keys) { + if (getOtrFingerprints().contains(print)) { + return false; + } + try { + JSONArray fingerprints; + if (!this.keys.has("otr_fingerprints")) { + fingerprints = new JSONArray(); + } else { + fingerprints = this.keys.getJSONArray("otr_fingerprints"); + } + fingerprints.put(print); + this.keys.put("otr_fingerprints", fingerprints); + return true; + } catch (final JSONException ignored) { + return false; + } + } + } + public long getPgpKeyId() { synchronized (this.keys) { if (this.keys.has("pgp_keyid")) { diff --git a/src/main/java/eu/siacs/conversations/entities/Conversation.java b/src/main/java/eu/siacs/conversations/entities/Conversation.java index e3c147320..3f9648d92 100644 --- a/src/main/java/eu/siacs/conversations/entities/Conversation.java +++ b/src/main/java/eu/siacs/conversations/entities/Conversation.java @@ -71,6 +71,7 @@ import org.json.JSONException; import org.json.JSONObject; import java.lang.ref.WeakReference; +import java.security.interfaces.DSAPublicKey; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; @@ -81,6 +82,7 @@ import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.ListIterator; +import java.util.Locale; import java.util.Objects; import java.util.Set; import java.util.Timer; @@ -132,6 +134,12 @@ import me.saket.bettermovementmethod.BetterLinkMovementMethod; import static eu.siacs.conversations.entities.Bookmark.printableValue; +import net.java.otr4j.OtrException; +import net.java.otr4j.crypto.OtrCryptoException; +import net.java.otr4j.session.SessionID; +import net.java.otr4j.session.SessionImpl; +import net.java.otr4j.session.SessionStatus; + public class Conversation extends AbstractEntity implements Blockable, Comparable, Conversational, AvatarService.Avatarable { public static final String TABLENAME = "conversations"; @@ -180,10 +188,16 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl private int mode; private JSONObject attributes; private Jid nextCounterpart; + private boolean hasPermanentCounterpart; + private transient SessionImpl otrSession; + private transient String otrFingerprint = null; + private Smp mSmp = new Smp(); private transient MucOptions mucOptions = null; + private byte[] symmetricKey; private boolean messagesLeftOnServer = true; private ChatState mOutgoingChatState = Config.DEFAULT_CHAT_STATE; private ChatState mIncomingChatState = Config.DEFAULT_CHAT_STATE; + private String mLastReceivedOtrMessageId = null; private String mFirstMamReference = null; protected Message replyTo = null; protected int mCurrentTab = -1; @@ -216,6 +230,9 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl this.attributes = new JSONObject(); } this.nextCounterpart = nextCounterpart; + if (nextCounterpart != null) { + hasPermanentCounterpart = true; + } } public String getContactUuid() { @@ -490,6 +507,17 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl } } + public void findUnsentMessagesWithEncryption(int encryptionType, OnMessageFound onMessageFound) { + synchronized (this.messages) { + for (Message message : this.messages) { + if ((message.getStatus() == Message.STATUS_UNSEND || message.getStatus() == Message.STATUS_WAITING) + && (message.getEncryption() == encryptionType)) { + onMessageFound.onMessageFound(message); + } + } + } + } + public void findUnsentTextMessages(OnMessageFound onMessageFound) { final ArrayList results = new ArrayList<>(); synchronized (this.messages) { @@ -662,6 +690,14 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl return getContact().getBlockedJid(); } + public String getLastReceivedOtrMessageId() { + return this.mLastReceivedOtrMessageId; + } + + public void setLastReceivedOtrMessageId(String id) { + this.mLastReceivedOtrMessageId = id; + } + public int countMessages() { synchronized (this.messages) { return this.messages.size(); @@ -905,7 +941,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl values.put(STATUS, status); values.put(MODE, mode); - if (nextCounterpart != null) { + if (nextCounterpart != null && hasPermanentCounterpart) { values.put(NEXT_COUNTERPART, nextCounterpart.toString()); } @@ -923,6 +959,124 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl this.mode = mode; } + public SessionImpl startOtrSession(String presence, boolean sendStart) { + if (this.otrSession != null) { + return this.otrSession; + } else { + final SessionID sessionId = new SessionID(this.getJid().asBareJid().toString(), + presence, + "xmpp"); + this.otrSession = new SessionImpl(sessionId, getAccount().getOtrService()); + try { + if (sendStart) { + this.otrSession.startSession(); + return this.otrSession; + } + return this.otrSession; + } catch (OtrException e) { + return null; + } + } + + } + + public SessionImpl getOtrSession() { + return this.otrSession; + } + + public void resetOtrSession() { + this.otrFingerprint = null; + this.otrSession = null; + this.mSmp.hint = null; + this.mSmp.secret = null; + this.mSmp.status = Smp.STATUS_NONE; + } + + public Smp smp() { + return mSmp; + } + + public boolean startOtrIfNeeded() { + if (this.otrSession != null && this.otrSession.getSessionStatus() != SessionStatus.ENCRYPTED) { + try { + this.otrSession.startSession(); + return true; + } catch (OtrException e) { + this.resetOtrSession(); + return false; + } + } else { + return true; + } + } + + public boolean endOtrIfNeeded() { + if (this.otrSession != null) { + if (this.otrSession.getSessionStatus() == SessionStatus.ENCRYPTED) { + try { + this.otrSession.endSession(); + this.resetOtrSession(); + return true; + } catch (OtrException e) { + this.resetOtrSession(); + return false; + } + } else { + this.resetOtrSession(); + return false; + } + } else { + return false; + } + } + + public boolean hasValidOtrSession() { + return this.otrSession != null; + } + + public synchronized String getOtrFingerprint() { + if (this.otrFingerprint == null) { + try { + if (getOtrSession() == null || getOtrSession().getSessionStatus() != SessionStatus.ENCRYPTED) { + return null; + } + DSAPublicKey remotePubKey = (DSAPublicKey) getOtrSession().getRemotePublicKey(); + this.otrFingerprint = getAccount().getOtrService().getFingerprint(remotePubKey).toLowerCase(Locale.US); + } catch (final OtrCryptoException ignored) { + return null; + } catch (final UnsupportedOperationException ignored) { + return null; + } + } + return this.otrFingerprint; + } + + public boolean verifyOtrFingerprint() { + final String fingerprint = getOtrFingerprint(); + if (fingerprint != null) { + getContact().addOtrFingerprint(fingerprint); + return true; + } else { + return false; + } + } + + public boolean isOtrFingerprintVerified() { + return getContact().getOtrFingerprints().contains(getOtrFingerprint()); + } + + public class Smp { + public static final int STATUS_NONE = 0; + public static final int STATUS_CONTACT_REQUESTED = 1; + public static final int STATUS_WE_REQUESTED = 2; + public static final int STATUS_FAILED = 3; + public static final int STATUS_VERIFIED = 4; + + public String secret = null; + public String hint = null; + public int status = 0; + } + /** * short for is Private and Non-anonymous */ @@ -959,14 +1113,23 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl return this.nextCounterpart; } + public boolean hasPermanentCounterpart() { + return hasPermanentCounterpart; + } + public void setNextCounterpart(Jid jid) { this.nextCounterpart = jid; } public int getNextEncryption() { - if (!Config.supportOmemo() && !Config.supportOpenPgp()) { + if (!Config.supportOmemo() && !Config.supportOpenPgp() && !Config.supportOtr()) { return Message.ENCRYPTION_NONE; } + + if (Config.supportOtr() && nextCounterpart != null && getMode() == MODE_SINGLE && hasPermanentCounterpart) { + return Message.ENCRYPTION_OTR; + } + if (OmemoSetting.isAlways()) { return suitableForOmemoByDefault(this) ? Message.ENCRYPTION_AXOLOTL : Message.ENCRYPTION_NONE; } @@ -977,7 +1140,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl defaultEncryption = Message.ENCRYPTION_NONE; } int encryption = this.getIntAttribute(ATTRIBUTE_NEXT_ENCRYPTION, defaultEncryption); - if (encryption == Message.ENCRYPTION_OTR || encryption < 0) { + if (encryption < 0) { return defaultEncryption; } else { return encryption; @@ -993,6 +1156,10 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl return nextMessage == null ? "" : nextMessage; } + public boolean smpRequested() { + return smp().status == Smp.STATUS_CONTACT_REQUESTED; + } + public @Nullable Draft getDraft() { long timestamp = getLongAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, 0); @@ -1015,6 +1182,14 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl return changed; } + public void setSymmetricKey(byte[] key) { + this.symmetricKey = key; + } + + public byte[] getSymmetricKey() { + return this.symmetricKey; + } + public Bookmark getBookmark() { return this.account.getBookmark(this.contactJid); } @@ -1231,14 +1406,14 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl String res2 = nextCounterpart == null ? null : nextCounterpart.getResource(); if (nextCounterpart == null) { - if (!message.isPrivateMessage()) { + if (!message.isPrivateMessage() && message.encryption != Message.ENCRYPTION_OTR) { synchronized (this.messages) { this.messages.add(message); actualizeReplyMessages(this.messages, List.of(message)); } } } else { - if (message.isPrivateMessage() && Objects.equals(res1, res2)) { + if ((message.isPrivateMessage() || message.encryption == Message.ENCRYPTION_OTR) && Objects.equals(res1, res2)) { synchronized (this.messages) { this.messages.add(message); actualizeReplyMessages(this.messages, List.of(message)); @@ -1260,14 +1435,14 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl } if (nextCounterpart == null) { - if (!message.isPrivateMessage()) { + if (!message.isPrivateMessage() && message.encryption != Message.ENCRYPTION_OTR) { synchronized (this.messages) { properListToAdd.add(Math.min(offset, properListToAdd.size()), message); actualizeReplyMessages(properListToAdd, List.of(message)); } } } else { - if (message.isPrivateMessage() && Objects.equals(res1, res2)) { + if ((message.isPrivateMessage() || message.encryption == Message.ENCRYPTION_OTR) && Objects.equals(res1, res2)) { synchronized (this.messages) { properListToAdd.add(Math.min(offset, properListToAdd.size()), message); actualizeReplyMessages(properListToAdd, List.of(message)); @@ -1291,7 +1466,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl if (nextCounterpart == null) { for(Message m : messages) { - if (!m.isPrivateMessage()) { + if (!m.isPrivateMessage() && m.encryption != Message.ENCRYPTION_OTR) { newM.add(m); } } @@ -1302,7 +1477,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl String res2 = nextCounterpart == null ? null : nextCounterpart.getResource(); - if (m.isPrivateMessage() && Objects.equals(res1, res2)) { + if ((m.isPrivateMessage() || m.encryption == Message.ENCRYPTION_OTR) && Objects.equals(res1, res2)) { newM.add(m); } } diff --git a/src/main/java/eu/siacs/conversations/entities/Message.java b/src/main/java/eu/siacs/conversations/entities/Message.java index ba7dc1bdb..4150d20ac 100644 --- a/src/main/java/eu/siacs/conversations/entities/Message.java +++ b/src/main/java/eu/siacs/conversations/entities/Message.java @@ -352,7 +352,10 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable } public String replyId() { - return conversation.getMode() == Conversation.MODE_MULTI || getRemoteMsgId() == null ? getServerMsgId() : getRemoteMsgId(); + if (conversation.getMode() == Conversation.MODE_MULTI) return getServerMsgId(); + final String remote = getRemoteMsgId(); + if (remote == null && getStatus() > STATUS_RECEIVED) return getUuid(); + return remote; } public Message reply() { diff --git a/src/main/java/eu/siacs/conversations/entities/Transferable.java b/src/main/java/eu/siacs/conversations/entities/Transferable.java index 5c833f603..58297d26a 100644 --- a/src/main/java/eu/siacs/conversations/entities/Transferable.java +++ b/src/main/java/eu/siacs/conversations/entities/Transferable.java @@ -6,7 +6,7 @@ import java.util.List; public interface Transferable { List VALID_IMAGE_EXTENSIONS = Arrays.asList("webp", "jpeg", "jpg", "png", "jpe"); - List VALID_CRYPTO_EXTENSIONS = Arrays.asList("pgp", "gpg"); + List VALID_CRYPTO_EXTENSIONS = Arrays.asList("pgp", "gpg", "otr"); int STATUS_UNKNOWN = 0x200; int STATUS_CHECKING = 0x201; diff --git a/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java b/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java index 706b50043..1088427fb 100644 --- a/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java +++ b/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java @@ -57,6 +57,9 @@ public abstract class AbstractGenerator { private final String[] PRIVACY_SENSITIVE = { "urn:xmpp:time" //XEP-0202: Entity Time leaks time zone }; + private final String[] OTR = { + "urn:xmpp:otr:0" + }; private final String[] VOIP_NAMESPACES = { Namespace.JINGLE_TRANSPORT_ICE_UDP, Namespace.JINGLE_FEATURE_AUDIO, @@ -125,6 +128,9 @@ public abstract class AbstractGenerator { features.addAll(Arrays.asList(PRIVACY_SENSITIVE)); features.addAll(Arrays.asList(VOIP_NAMESPACES)); } + if (Config.supportOtr()) { + features.addAll(Arrays.asList(OTR)); + } if (mXmppConnectionService.broadcastLastActivity()) { features.add(Namespace.IDLE); } diff --git a/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java b/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java index 944e36d15..ddbb6d71c 100644 --- a/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java +++ b/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java @@ -1,5 +1,8 @@ package eu.siacs.conversations.generator; +import net.java.otr4j.OtrException; +import net.java.otr4j.session.Session; + import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; @@ -24,6 +27,7 @@ import eu.siacs.conversations.xmpp.jingle.Media; import eu.siacs.conversations.xmpp.stanzas.MessagePacket; public class MessageGenerator extends AbstractGenerator { + public static final String OTR_FALLBACK_MESSAGE = "I would like to start a private (OTR encrypted) conversation but your client doesn’t seem to support that"; private static final String OMEMO_FALLBACK_MESSAGE = "I sent you an OMEMO encrypted message but your client doesn’t seem to support that. Find more information on https://conversations.im/omemo"; private static final String PGP_FALLBACK_MESSAGE = "I sent you a PGP encrypted message but your client doesn’t seem to support that."; @@ -102,6 +106,36 @@ public class MessageGenerator extends AbstractGenerator { return packet; } + public static void addMessageHints(MessagePacket packet) { + packet.addChild("private", "urn:xmpp:carbons:2"); + packet.addChild("no-copy", "urn:xmpp:hints"); + packet.addChild("no-permanent-store", "urn:xmpp:hints"); + packet.addChild("no-permanent-storage", "urn:xmpp:hints"); //do not copy this. this is wrong. it is *store* + } + + public MessagePacket generateOtrChat(Message message) { + Conversation conversation = (Conversation) message.getConversation(); + Session otrSession = conversation.getOtrSession(); + if (otrSession == null) { + return null; + } + MessagePacket packet = preparePacket(message); + addMessageHints(packet); + try { + String content; + if (message.hasFileOnRemoteHost()) { + content = message.getFileParams().url.toString(); + } else { + content = message.getBody(); + } + packet.setBody(otrSession.transformSending(content)[0]); + packet.addChild("encryption", "urn:xmpp:eme:0").setAttribute("namespace", "urn:xmpp:otr:0"); + return packet; + } catch (OtrException e) { + return null; + } + } + public MessagePacket generateChat(Message message) { MessagePacket packet = preparePacket(message); String content; @@ -233,6 +267,19 @@ public class MessageGenerator extends AbstractGenerator { return packet; } + public MessagePacket generateOtrError(Jid to, String id, String errorText) { + MessagePacket packet = new MessagePacket(); + packet.setType(MessagePacket.TYPE_ERROR); + packet.setAttribute("id", id); + packet.setTo(to); + Element error = packet.addChild("error"); + error.setAttribute("code", "406"); + error.setAttribute("type", "modify"); + error.addChild("not-acceptable", "urn:ietf:params:xml:ns:xmpp-stanzas"); + error.addChild("text").setContent("?OTR Error:" + errorText); + return packet; + } + public MessagePacket sessionProposal(final JingleConnectionManager.RtpSessionProposal proposal) { final MessagePacket packet = new MessagePacket(); packet.setType(MessagePacket.TYPE_CHAT); //we want to carbon copy those diff --git a/src/main/java/eu/siacs/conversations/parser/MessageParser.java b/src/main/java/eu/siacs/conversations/parser/MessageParser.java index 2ec1bacf2..e0ae5fc04 100644 --- a/src/main/java/eu/siacs/conversations/parser/MessageParser.java +++ b/src/main/java/eu/siacs/conversations/parser/MessageParser.java @@ -1,8 +1,15 @@ package eu.siacs.conversations.parser; +import android.os.Build; +import android.text.Html; import android.util.Log; import android.util.Pair; +import com.google.common.base.Strings; + +import net.java.otr4j.session.Session; +import net.java.otr4j.session.SessionStatus; + import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; @@ -16,6 +23,7 @@ import java.util.UUID; import eu.siacs.conversations.Config; import eu.siacs.conversations.R; +import eu.siacs.conversations.crypto.OtrService; import eu.siacs.conversations.crypto.axolotl.AxolotlService; import eu.siacs.conversations.crypto.axolotl.BrokenSessionException; import eu.siacs.conversations.crypto.axolotl.NotEncryptedForThisDeviceException; @@ -28,9 +36,11 @@ import eu.siacs.conversations.entities.Conversation; import eu.siacs.conversations.entities.Conversational; import eu.siacs.conversations.entities.Message; import eu.siacs.conversations.entities.MucOptions; +import eu.siacs.conversations.entities.Presence; import eu.siacs.conversations.entities.ReadByMarker; import eu.siacs.conversations.entities.ReceiptRequest; import eu.siacs.conversations.entities.RtpSessionStatus; +import eu.siacs.conversations.entities.ServiceDiscoveryResult; import eu.siacs.conversations.http.HttpConnectionManager; import eu.siacs.conversations.services.MessageArchiveService; import eu.siacs.conversations.services.QuickConversationsService; @@ -49,6 +59,7 @@ import eu.siacs.conversations.xmpp.pep.Avatar; import eu.siacs.conversations.xmpp.stanzas.MessagePacket; public class MessageParser extends AbstractParser implements OnMessagePacketReceived { + private static final List CLIENTS_SENDING_HTML_IN_OTR = Arrays.asList("Pidgin", "Adium", "Trillian"); private static final SimpleDateFormat TIME_FORMAT = new SimpleDateFormat("HH:mm:ss", Locale.ENGLISH); @@ -95,6 +106,30 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece return result != null ? result : fallback; } + private static boolean clientMightSendHtml(Account account, Jid from) { + String resource = from.getResource(); + if (resource == null) { + return false; + } + Presence presence = account.getRoster().getContact(from).getPresences().getPresencesMap().get(resource); + ServiceDiscoveryResult disco = presence == null ? null : presence.getServiceDiscoveryResult(); + if (disco == null) { + return false; + } + return hasIdentityKnowForSendingHtml(disco.getIdentities()); + } + + private static boolean hasIdentityKnowForSendingHtml(List identities) { + for (ServiceDiscoveryResult.Identity identity : identities) { + if (identity.getName() != null) { + if (CLIENTS_SENDING_HTML_IN_OTR.contains(identity.getName())) { + return true; + } + } + } + return false; + } + private boolean extractChatState(Conversation c, final boolean isTypeGroupChat, final MessagePacket packet) { ChatState state = ChatState.parse(packet); if (state != null && c != null) { @@ -126,6 +161,66 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece return false; } + private Message parseOtrChat(String body, Jid from, String id, Conversation conversation) { + String presence; + if (from.isBareJid()) { + presence = ""; + } else { + presence = from.getResource(); + } + if (body.matches("^\\?OTRv\\d{1,2}\\?.*")) { + conversation.endOtrIfNeeded(); + } + if (!conversation.hasValidOtrSession()) { + conversation.startOtrSession(presence, false); + } else { + String foreignPresence = conversation.getOtrSession().getSessionID().getUserID(); + if (!foreignPresence.equals(presence)) { + conversation.endOtrIfNeeded(); + conversation.startOtrSession(presence, false); + } + } + try { + conversation.setLastReceivedOtrMessageId(id); + Session otrSession = conversation.getOtrSession(); + body = otrSession.transformReceiving(body); + SessionStatus status = otrSession.getSessionStatus(); + if (body == null && status == SessionStatus.ENCRYPTED) { + mXmppConnectionService.onOtrSessionEstablished(conversation); + return null; + } else if (body == null && status == SessionStatus.FINISHED) { + conversation.resetOtrSession(); + mXmppConnectionService.updateConversationUi(); + return null; + } else if (body == null || (body.isEmpty())) { + return null; + } + if (body.startsWith(CryptoHelper.FILETRANSFER)) { + String key = body.substring(CryptoHelper.FILETRANSFER.length()); + conversation.setSymmetricKey(CryptoHelper.hexToBytes(key)); + return null; + } + if (clientMightSendHtml(conversation.getAccount(), from)) { + Log.d(Config.LOGTAG, conversation.getAccount().getJid().asBareJid() + ": received OTR message from bad behaving client. escaping HTML…"); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + body = Html.fromHtml(body, Html.FROM_HTML_MODE_LEGACY).toString(); + } else { + body = Html.fromHtml(body).toString(); + } + } + + final OtrService otrService = conversation.getAccount().getOtrService(); + Message finishedMessage = new Message(conversation, body, Message.ENCRYPTION_OTR, Message.STATUS_RECEIVED); + finishedMessage.setFingerprint(otrService.getFingerprint(otrSession.getRemotePublicKey())); + conversation.setLastReceivedOtrMessageId(null); + + return finishedMessage; + } catch (Exception e) { + conversation.resetOtrSession(); + return null; + } + } + private Message parseAxolotlChat(Element axolotlMessage, Jid from, Conversation conversation, int status, final boolean checkedForDuplicates, boolean postpone) { final AxolotlService service = conversation.getAccount().getAxolotlService(); final XmppAxolotlMessage xmppAxolotlMessage; @@ -327,6 +422,11 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece final Jid from = packet.getFrom(); final String id = packet.getId(); if (from != null && id != null) { + final Message message = mXmppConnectionService.markMessage(account, + from.asBareJid(), + packet.getId(), + Message.STATUS_SEND_FAILED, + extractErrorMessage(packet)); if (id.startsWith(JingleRtpConnection.JINGLE_MESSAGE_PROPOSE_ID_PREFIX)) { final String sessionId = id.substring(JingleRtpConnection.JINGLE_MESSAGE_PROPOSE_ID_PREFIX.length()); mXmppConnectionService.getJingleConnectionManager() @@ -335,8 +435,8 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece } if (id.startsWith(JingleRtpConnection.JINGLE_MESSAGE_PROCEED_ID_PREFIX)) { final String sessionId = id.substring(JingleRtpConnection.JINGLE_MESSAGE_PROCEED_ID_PREFIX.length()); - final String message = extractErrorMessage(packet); - mXmppConnectionService.getJingleConnectionManager().failProceed(account, from, sessionId, message); + final String errorMessage = extractErrorMessage(packet); + mXmppConnectionService.getJingleConnectionManager().failProceed(account, from, sessionId, errorMessage); return true; } mXmppConnectionService.markMessage(account, @@ -355,6 +455,13 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece } } } + + if (message != null) { + if (message.getEncryption() == Message.ENCRYPTION_OTR) { + Conversation conversation = (Conversation) message.getConversation(); + conversation.endOtrIfNeeded(); + } + } } return true; } @@ -368,6 +475,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece } final MessagePacket packet; Long timestamp = null; + final boolean isForwarded; boolean isCarbon = false; String serverMsgId = null; final Element fin = original.findChild("fin", MessageArchiveService.Version.MAM_0.namespace); @@ -385,7 +493,9 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece } timestamp = f.second; packet = f.first; + isForwarded = true; serverMsgId = result.getAttribute("id"); + query.incrementMessageCount(); if (handleErrorMessage(account, packet)) { return; @@ -403,8 +513,10 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece } timestamp = f != null ? f.second : null; isCarbon = f != null; + isForwarded = isCarbon; } else { packet = original; + isForwarded = false; } if (timestamp == null) { @@ -449,6 +561,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece Log.e(Config.LOGTAG, account.getJid().asBareJid() + ": received groupchat (" + from + ") message on regular MAM request. skipping"); return; } + boolean isProperlyAddressed = (to != null) && (!to.isBareJid() || account.countPresences() == 0); boolean isMucStatusMessage = InvalidJid.hasValidFrom(packet) && from.isBareJid() && mucUserElement != null && mucUserElement.hasChild("status"); boolean selfAddressed; if (packet.fromAccount(account)) { @@ -483,7 +596,10 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece final boolean conversationIsProbablyMuc = isTypeGroupChat || mucUserElement != null || account.getXmppConnection().getMucServersWithholdAccount().contains(counterpart.getDomain().toEscapedString()); - if (conversationIsProbablyMuc && !isTypeGroupChat) { + final boolean isOTR = body != null && body.content.startsWith("?OTR") && Config.supportOtr(); + final boolean correctOTR = !isForwarded && !isTypeGroupChat && isProperlyAddressed; + + if ((conversationIsProbablyMuc && !isTypeGroupChat) || (!Strings.isNullOrEmpty(counterpart.getResource()) && isOTR && correctOTR)) { nextCounterpart = counterpart; } @@ -508,10 +624,6 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece } } - if (nextCounterpart != null && mXmppConnectionService.checkIsArchived(account, counterpart.asBareJid(), nextCounterpart)) { - return; - } - if ((body != null || pgpEncrypted != null || (axolotlEncrypted != null && axolotlEncrypted.hasChild("payload")) || oobUrl != null) && !isMucStatusMessage) { final Conversation conversation = mXmppConnectionService.findOrCreateConversation(account, counterpart.asBareJid(), null, conversationIsProbablyMuc, nextCounterpart != null, false, nextCounterpart); final boolean conversationMultiMode = conversation.getMode() == Conversation.MODE_MULTI; @@ -551,7 +663,17 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece } } final Message message; - if (pgpEncrypted != null && Config.supportOpenPgp()) { + if (isOTR) { + if (correctOTR && !conversationMultiMode) { + message = parseOtrChat(body.content, from, remoteMsgId, conversation); + if (message == null) { + return; + } + } else { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": ignoring OTR message from " + from + " isForwarded=" + Boolean.toString(isForwarded) + ", isProperlyAddressed=" + Boolean.valueOf(isProperlyAddressed)); + message = null; + } + } else if (pgpEncrypted != null && Config.supportOpenPgp()) { message = new Message(conversation, pgpEncrypted, Message.ENCRYPTION_PGP, status); } else if (axolotlEncrypted != null && Config.supportOmemo()) { Jid origin; @@ -800,6 +922,13 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece processMessageReceipts(account, packet, remoteMsgId, query); } + if (message.getStatus() == Message.STATUS_RECEIVED + && conversation.getOtrSession() != null + && !conversation.getOtrSession().getSessionID().getUserID() + .equals(message.getCounterpart().getResource())) { + conversation.endOtrIfNeeded(); + } + mXmppConnectionService.databaseBackend.createMessage(message); final HttpConnectionManager manager = this.mXmppConnectionService.getHttpConnectionManager(); if (message.trusted() && message.treatAsDownloadable() && manager.getAutoAcceptFileSize() > 0) { diff --git a/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java b/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java index 41ab36dff..84853193e 100644 --- a/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java +++ b/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java @@ -50,6 +50,7 @@ import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore; import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Contact; import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.Conversational; import eu.siacs.conversations.entities.Message; import eu.siacs.conversations.entities.PresenceTemplate; import eu.siacs.conversations.entities.Roster; @@ -887,30 +888,40 @@ public class DatabaseBackend extends SQLiteOpenHelper { String comparsionOperation = isForward ? ">?" : " markMessage(message1, Message.STATUS_SEND_FAILED)); + } + final boolean inProgressJoin = isJoinInProgress(conversation); @@ -1736,6 +1760,30 @@ public class XmppConnectionService extends Service { packet = mMessageGenerator.generatePgpChat(message); } break; + case Message.ENCRYPTION_OTR: + SessionImpl otrSession = conversation.getOtrSession(); + if (otrSession != null && otrSession.getSessionStatus() == SessionStatus.ENCRYPTED) { + try { + message.setCounterpart(OtrJidHelper.fromSessionID(otrSession.getSessionID())); + } catch (IllegalArgumentException e) { + break; + } + if (message.needsUploading()) { + mJingleConnectionManager.startJingleFileTransfer(message); + } else { + packet = mMessageGenerator.generateOtrChat(message); + } + } else if (otrSession == null) { + if (message.fixCounterpart()) { + conversation.startOtrSession(message.getCounterpart().getResource(), true); + } else { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": could not fix counterpart for OTR message to contact " + message.getCounterpart()); + break; + } + } else { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + " OTR session with " + message.getContact() + " is in wrong state: " + otrSession.getSessionStatus().toString()); + } + break; case Message.ENCRYPTION_AXOLOTL: message.setFingerprint(account.getAxolotlService().getOwnFingerprint()); if (message.needsUploading()) { @@ -1789,6 +1837,12 @@ public class XmppConnectionService extends Service { } } break; + case Message.ENCRYPTION_OTR: + if (!conversation.hasValidOtrSession() && message.getCounterpart() != null) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": create otr session without starting for " + message.getContact().getJid()); + conversation.startOtrSession(message.getCounterpart().getResource(), false); + } + break; case Message.ENCRYPTION_AXOLOTL: message.setFingerprint(account.getAxolotlService().getOwnFingerprint()); break; @@ -2446,7 +2500,9 @@ public class XmppConnectionService extends Service { query.setCallback(callback); callback.informUser(R.string.fetching_history_from_server); } else { - callback.informUser(R.string.not_fetching_history_retention_period); + if (conversation.getMode() != Conversational.MODE_SINGLE || !conversation.hasPermanentCounterpart()) { + callback.informUser(R.string.not_fetching_history_retention_period); + } } } @@ -2496,6 +2552,7 @@ public class XmppConnectionService extends Service { if ((account == null || conversation.getAccount() == account) && (conversation.getJid().asBareJid().equals(jid.asBareJid())) && Objects.equal(conversation.getNextCounterpart(), counterpart) + && conversation.hasPermanentCounterpart() ) { return conversation; } @@ -2504,7 +2561,7 @@ public class XmppConnectionService extends Service { for (final Conversation conversation : haystack) { if ((account == null || conversation.getAccount() == account) && (conversation.getJid().asBareJid().equals(jid.asBareJid())) - && conversation.getNextCounterpart() == null + && (conversation.getNextCounterpart() == null || !conversation.hasPermanentCounterpart()) ) { return conversation; } @@ -2566,6 +2623,7 @@ public class XmppConnectionService extends Service { if (conversation != null) { return conversation; } + conversation = databaseBackend.findConversation(account, jid, counterpart); final boolean loadMessagesFromDb; if (conversation != null) { @@ -3922,6 +3980,12 @@ public class XmppConnectionService extends Service { if (conversation.getAccount() == account) { if (conversation.getMode() == Conversation.MODE_MULTI) { leaveMuc(conversation, true); + } else { + if (conversation.endOtrIfNeeded()) { + Log.d(Config.LOGTAG, account.getJid().asBareJid() + + ": ended otr session with " + + conversation.getJid()); + } } } } @@ -3978,6 +4042,39 @@ public class XmppConnectionService extends Service { pushContactToServer(contact, preAuth); } + public void onOtrSessionEstablished(Conversation conversation) { + final Account account = conversation.getAccount(); + final Session otrSession = conversation.getOtrSession(); + Log.d(Config.LOGTAG, + account.getJid().asBareJid() + " otr session established with " + + conversation.getJid() + "/" + + otrSession.getSessionID().getUserID()); + conversation.findUnsentMessagesWithEncryption(Message.ENCRYPTION_OTR, new Conversation.OnMessageFound() { + + @Override + public void onMessageFound(Message message) { + SessionID id = otrSession.getSessionID(); + try { + message.setCounterpart(Jid.of(id.getAccountID() + "/" + id.getUserID())); + } catch (IllegalArgumentException e) { + return; + } + if (message.needsUploading()) { + mJingleConnectionManager.startJingleFileTransfer(message); + } else { + MessagePacket outPacket = mMessageGenerator.generateOtrChat(message); + if (outPacket != null) { + mMessageGenerator.addDelay(outPacket, message.getTimeSent()); + message.setStatus(Message.STATUS_SEND); + databaseBackend.updateMessage(message, false); + sendMessagePacket(account, outPacket); + } + } + updateConversationUi(); + } + }); + } + public void pushContactToServer(final Contact contact) { pushContactToServer(contact, null); } @@ -4503,6 +4600,7 @@ public class XmppConnectionService extends Service { return false; } else { final Message message = conversation.findSentMessageWithUuid(uuid); + if (message != null) { if (message.getServerMsgId() == null) { message.setServerMsgId(serverMessageId); @@ -4805,6 +4903,11 @@ public class XmppConnectionService extends Service { setMemorizingTrustManager(tm); } + public void syncRosterToDisk(final Account account) { + Runnable runnable = () -> databaseBackend.writeRoster(account.getRoster()); + mDatabaseWriterExecutor.execute(runnable); + } + public LruCache getBitmapCache() { return this.mBitmapCache; } @@ -5272,10 +5375,14 @@ public class XmppConnectionService extends Service { } public boolean verifyFingerprints(Contact contact, List fingerprints) { + boolean needsRosterWrite = false; boolean performedVerification = false; final AxolotlService axolotlService = contact.getAccount().getAxolotlService(); for (XmppUri.Fingerprint fp : fingerprints) { - if (fp.type == XmppUri.FingerprintType.OMEMO) { + if (fp.type == XmppUri.FingerprintType.OTR) { + performedVerification |= contact.addOtrFingerprint(fp.fingerprint); + needsRosterWrite |= performedVerification; + } else if (fp.type == XmppUri.FingerprintType.OMEMO) { String fingerprint = "05" + fp.fingerprint.replaceAll("\\s", ""); FingerprintStatus fingerprintStatus = axolotlService.getFingerprintTrust(fingerprint); if (fingerprintStatus != null) { @@ -5288,6 +5395,11 @@ public class XmppConnectionService extends Service { } } } + + if (needsRosterWrite) { + syncRosterToDisk(contact.getAccount()); + } + return performedVerification; } diff --git a/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java b/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java index cc7773ad2..37dd2e88d 100644 --- a/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java @@ -504,7 +504,7 @@ public class ContactDetailsActivity extends OmemoActivity implements OnAccountUp && contact.getLastseen() > 0 && contact.getPresences().allOrNonSupport(Namespace.IDLE)) { binding.detailsLastseen.setVisibility(View.VISIBLE); - binding.detailsLastseen.setText(UIHelper.lastseen(getApplicationContext(), contact.isActive(), contact.getLastseen())); + binding.detailsLastseen.setText(UIHelper.lastseen(getApplicationContext(), contact.isActive(), contact.getLastseen(), false)); } else { binding.detailsLastseen.setVisibility(View.GONE); } @@ -523,7 +523,7 @@ public class ContactDetailsActivity extends OmemoActivity implements OnAccountUp AvatarWorkerTask.loadAvatar(contact, binding.detailsContactBadge, R.dimen.avatar_on_details_screen_size); binding.detailsContactBadge.setOnClickListener(this::onBadgeClick); - binding.presenceIndicator.setStatus(contact.getShownStatus()); + binding.presenceIndicator.setStatus(contact); binding.detailsContactKeys.removeAllViews(); boolean hasKeys = false; diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index 457ff8807..9e427412e 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -79,6 +79,8 @@ import androidx.viewpager.widget.PagerAdapter; import com.google.common.base.Optional; import com.google.common.collect.ImmutableList; +import net.java.otr4j.session.SessionStatus; + import org.jetbrains.annotations.NotNull; import java.io.File; @@ -223,6 +225,14 @@ public class ConversationFragment extends XmppFragment private ConversationsActivity activity; private Vibrator vibrator; private boolean reInitRequiredOnStart = true; + + protected OnClickListener clickToVerify = new OnClickListener() { + @Override + public void onClick(View v) { + activity.verifyOtrSessionDialog(conversation, v); + } + }; + @ColorInt private int primaryColor = -1; @@ -534,6 +544,20 @@ public class ConversationFragment extends XmppFragment } } }; + + private OnClickListener mAnswerSmpClickListener = new OnClickListener() { + @Override + public void onClick(View view) { + Intent intent = new Intent(activity, VerifyOTRActivity.class); + intent.setAction(VerifyOTRActivity.ACTION_VERIFY_CONTACT); + intent.putExtra(EXTRA_ACCOUNT, conversation.getAccount().getJid().asBareJid().toString()); + intent.putExtra(VerifyOTRActivity.EXTRA_ACCOUNT, conversation.getAccount().getJid().asBareJid().toString()); + intent.putExtra("mode", VerifyOTRActivity.MODE_ANSWER_QUESTION); + startActivity(intent); + activity.overridePendingTransition(R.animator.fade_in, R.animator.fade_out); + } + }; + protected OnClickListener clickToDecryptListener = new OnClickListener() { @@ -590,7 +614,7 @@ public class ConversationFragment extends XmppFragment public void onClick(View v) { stopScrolling(); - if (previousClickedReply != null) { + /*if (previousClickedReply != null) { int lastVisiblePosition = binding.messagesView.getLastVisiblePosition(); Message lastVisibleMessage = messageListAdapter.getItem(lastVisiblePosition); Message jump = previousClickedReply; @@ -602,7 +626,7 @@ public class ConversationFragment extends XmppFragment return; } } - } + }*/ if (conversation.isInHistoryPart()) { conversation.jumpToLatest(); @@ -1064,6 +1088,9 @@ public class ConversationFragment extends XmppFragment message.setUuid(UUID.randomUUID().toString()); } switch (conversation.getNextEncryption()) { + case Message.ENCRYPTION_OTR: + sendOtrMessage(message); + break; case Message.ENCRYPTION_PGP: sendPgpMessage(message); break; @@ -1380,8 +1407,13 @@ public class ConversationFragment extends XmppFragment final MenuItem menuVideoCall = menu.findItem(R.id.action_video_call); final MenuItem menuTogglePinned = menu.findItem(R.id.action_toggle_pinned); final MenuItem deleteCustomBg = menu.findItem(R.id.action_delete_custom_bg); + final MenuItem startSecretChat = menu.findItem(R.id.action_start_secret_chat); + final MenuItem encryption = menu.findItem(R.id.action_security); if (conversation != null) { + boolean considerAsSecretChat = conversation.getMode() == Conversational.MODE_SINGLE && + conversation.getNextCounterpart() != null && conversation.hasPermanentCounterpart(); + if (conversation.getMode() == Conversation.MODE_MULTI) { menuContactDetails.setVisible(false); menuInviteContact.setVisible(conversation.getMucOptions().canInvite() && conversation.getNextCounterpart() == null); @@ -1391,7 +1423,11 @@ public class ConversationFragment extends XmppFragment : R.string.channel_details); menuCall.setVisible(false); menuOngoingCall.setVisible(false); + startSecretChat.setVisible(false); } else { + if (considerAsSecretChat) { + startSecretChat.setVisible(false); + } menuMucParticipants.setVisible(false); final XmppConnectionService service = activity == null ? null : activity.xmppConnectionService; @@ -1432,6 +1468,10 @@ public class ConversationFragment extends XmppFragment menuTogglePinned.setTitle(R.string.add_to_favorites); } + if (considerAsSecretChat) { + encryption.setVisible(false); + } + deleteCustomBg.setVisible(ChatBackgroundHelper.getBgFile(activity, conversation.getUuid()).exists()); } @@ -2022,6 +2062,9 @@ public class ConversationFragment extends XmppFragment case R.id.action_archive: activity.xmppConnectionService.archiveConversation(conversation); break; + case R.id.action_start_secret_chat: + startOtrChat(); + break; case R.id.action_contact_details: activity.switchToContactDetails(conversation.getContact()); break; @@ -3550,6 +3593,14 @@ public class ConversationFragment extends XmppFragment } } else if (account.hasPendingPgpIntent(conversation)) { showSnackbar(R.string.openpgp_messages_found, R.string.decrypt, clickToDecryptListener); + } else if (mode == Conversation.MODE_SINGLE + && conversation.smpRequested()) { + showSnackbar(R.string.smp_requested, R.string.verify, this.mAnswerSmpClickListener); + } else if (mode == Conversation.MODE_SINGLE + && conversation.hasValidOtrSession() + && (conversation.getOtrSession().getSessionStatus() == SessionStatus.ENCRYPTED) + && (!conversation.isOtrFingerprintVerified())) { + showSnackbar(R.string.unknown_otr_fingerprint, R.string.verify, clickToVerify); } else if (connection != null && connection.getFeatures().blocking() && conversation.countMessages() != 0 @@ -3610,7 +3661,7 @@ public class ConversationFragment extends XmppFragment conversation.refreshSessions(); - if (conversation != null && conversation.getMode() == Conversational.MODE_MULTI) { + if (conversation != null && conversation.getMode() == Conversational.MODE_MULTI && conversation.getNextCounterpart() == null) { String subject = conversation.getMucOptions().getSubject(); Boolean hidden = conversation.getMucOptions().subjectHidden(); @@ -3650,6 +3701,11 @@ public class ConversationFragment extends XmppFragment new Handler() .post( () -> { + if (conversation.isInHistoryPart()) { + conversation.jumpToLatest(); + refresh(false); + } + int size = messageList.size(); this.binding.messagesView.setSelection(size - 1); }); @@ -3937,6 +3993,26 @@ public class ConversationFragment extends XmppFragment messageSent(); } + protected void sendOtrMessage(final Message message) { + final ConversationsActivity activity = (ConversationsActivity) getActivity(); + final XmppConnectionService xmppService = activity.xmppConnectionService; + message.setCounterpart(conversation.getNextCounterpart()); + xmppService.sendMessage(message); + messageSent(); + } + + protected void startOtrChat() { + final ConversationsActivity activity = (ConversationsActivity) getActivity(); + activity.selectPresence(conversation, + () -> { + Conversation c = activity.xmppConnectionService.findOrCreateConversation(conversation.getAccount(), conversation.getJid(), null, false, false, false, conversation.getNextCounterpart()); + conversation.setNextCounterpart(null); + if (c != conversation) { + activity.switchToConversation(c); + } + }); + } + protected void sendPgpMessage(final Message message) { final XmppConnectionService xmppService = activity.xmppConnectionService; final Contact contact = message.getConversation().getContact(); diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java index b225d8404..51f7f5891 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java @@ -42,10 +42,14 @@ import android.app.FragmentTransaction; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.net.Uri; import android.os.Build; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.preference.PreferenceManager; import android.provider.Settings; import android.util.Log; import android.view.KeyEvent; @@ -59,9 +63,12 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.PopupMenu; import androidx.core.app.ActivityCompat; import androidx.databinding.DataBindingUtil; +import net.java.otr4j.session.SessionStatus; + import com.google.android.material.bottomnavigation.BottomNavigationView; import com.google.android.material.navigation.NavigationBarView; @@ -95,10 +102,13 @@ import eu.siacs.conversations.ui.util.PendingItem; import eu.siacs.conversations.utils.ExceptionHelper; import eu.siacs.conversations.utils.PhoneNumberUtilWrapper; import eu.siacs.conversations.utils.SignupUtils; +import eu.siacs.conversations.utils.UIHelper; import eu.siacs.conversations.utils.XmppUri; +import eu.siacs.conversations.xml.Namespace; import eu.siacs.conversations.xmpp.Jid; import eu.siacs.conversations.xmpp.OnUpdateBlocklist; import io.michaelrocks.libphonenumber.android.NumberParseException; +import me.drakeet.support.toast.ToastCompat; public class ConversationsActivity extends XmppActivity implements OnConversationSelected, OnConversationArchived, OnConversationsListItemUpdated, OnConversationRead, XmppConnectionService.OnAccountUpdate, XmppConnectionService.OnConversationUpdate, XmppConnectionService.OnRosterUpdate, OnUpdateBlocklist, XmppConnectionService.OnShowErrorToast, XmppConnectionService.OnAffiliationChanged { @@ -135,6 +145,10 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio private boolean mActivityPaused = true; private final AtomicBoolean mRedirectInProcess = new AtomicBoolean(false); + private final Handler handler = new Handler(Looper.getMainLooper()); + private final Runnable refreshTitleRunnable = this::invalidateActionBarTitle; + private boolean showLastSeen = false; + private static boolean isViewOrShareIntent(Intent i) { Log.d(Config.LOGTAG, "action: " + (i == null ? null : i.getAction())); return i != null && VIEW_AND_SHARE_ACTIONS.contains(i.getAction()) && i.hasExtra(EXTRA_CONVERSATION); @@ -660,6 +674,8 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio this.mSkipBackgroundBinding = false; } mRedirectInProcess.set(false); + final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + this.showLastSeen = preferences.getBoolean("last_activity", false); BottomNavigationView bottomNavigationView = findViewById(R.id.bottom_navigation); bottomNavigationView.setSelectedItemId(R.id.chats); @@ -735,13 +751,18 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio if (actionBar == null) { return; } + final FragmentManager fragmentManager = getFragmentManager(); final Fragment mainFragment = fragmentManager.findFragmentById(R.id.main_fragment); if (mainFragment instanceof ConversationFragment) { final Conversation conversation = ((ConversationFragment) mainFragment).getConversation(); if (conversation != null) { - if (conversation.getNextCounterpart() != null) { - actionBar.setTitle(getString(R.string.muc_private_conversation_title, conversation.getNextCounterpart().getResource(), conversation.getName())); + if (conversation.getNextCounterpart() != null && conversation.hasPermanentCounterpart()) { + if (conversation.getMode() == Conversational.MODE_MULTI) { + actionBar.setTitle(getString(R.string.muc_private_conversation_title, conversation.getNextCounterpart().getResource(), conversation.getName())); + } else { + actionBar.setTitle(getString(R.string.secret_chat_title_no_resource, conversation.getName())); + } } else { actionBar.setTitle(conversation.getName()); } @@ -750,10 +771,40 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio binding.toolbar, (v) -> openConversationDetails(conversation) ); + + if (conversation.getMode() == Conversation.MODE_MULTI && conversation.getNextCounterpart() == null) { + int usersCount = conversation.getMucOptions().getUserCount(); + if (usersCount > 0) { + actionBar.setSubtitle(getResources().getQuantityString(R.plurals.x_participants, conversation.getMucOptions().getUserCount(), conversation.getMucOptions().getUserCount())); + } else { + actionBar.setSubtitle(""); + } + + handler.postDelayed(refreshTitleRunnable, 5000L); + } else if (conversation.getMode() == Conversation.MODE_SINGLE) { + Contact contact = conversation.getContact(); + List statuses = contact.getPresences().getStatusMessages(); + if (conversation.getNextCounterpart() != null && conversation.hasPermanentCounterpart()) { + actionBar.setSubtitle(conversation.getNextCounterpart().getResource()); + } else if (!statuses.isEmpty() && !statuses.get(0).isBlank()) { + actionBar.setSubtitle(statuses.get(0)); + handler.postDelayed(refreshTitleRunnable, 5000L); + } else { + actionBar.setSubtitle(""); + handler.removeCallbacks(refreshTitleRunnable); + } + } else { + actionBar.setSubtitle(""); + handler.removeCallbacks(refreshTitleRunnable); + } + return; } } + + handler.removeCallbacks(refreshTitleRunnable); actionBar.setTitle(R.string.app_name); + actionBar.setSubtitle(""); actionBar.setDisplayHomeAsUpEnabled(false); ActionBarUtil.resetActionBarOnClickListeners(binding.toolbar); } @@ -771,6 +822,41 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio } } + public void verifyOtrSessionDialog(final Conversation conversation, View view) { + if (!conversation.hasValidOtrSession() || conversation.getOtrSession().getSessionStatus() != SessionStatus.ENCRYPTED) { + ToastCompat.makeText(this, R.string.otr_session_not_started, Toast.LENGTH_LONG).show(); + return; + } + if (view == null) { + return; + } + PopupMenu popup = new PopupMenu(this, view); + popup.inflate(R.menu.verification_choices); + popup.setOnMenuItemClickListener(menuItem -> { + if (menuItem.getItemId() == R.id.blind_trust) { + conversation.verifyOtrFingerprint(); + xmppConnectionService.syncRosterToDisk(conversation.getAccount()); + refreshUiReal(); + return true; + } + + Intent intent = new Intent(ConversationsActivity.this, VerifyOTRActivity.class); + intent.setAction(VerifyOTRActivity.ACTION_VERIFY_CONTACT); + intent.putExtra("contact", conversation.getContact().getJid().asBareJid().toString()); + intent.putExtra("counterpart", conversation.getNextCounterpart().toString()); + intent.putExtra(EXTRA_ACCOUNT, conversation.getAccount().getJid().asBareJid().toString()); + switch (menuItem.getItemId()) { + case R.id.ask_question: + intent.putExtra("mode", VerifyOTRActivity.MODE_ASK_QUESTION); + break; + } + startActivity(intent); + overridePendingTransition(R.animator.fade_in, R.animator.fade_out); + return true; + }); + popup.show(); + } + @Override public void onConversationArchived(Conversation conversation) { if (performRedirectIfNecessary(conversation, false)) { diff --git a/src/main/java/eu/siacs/conversations/ui/SendLogActivity.java b/src/main/java/eu/siacs/conversations/ui/SendLogActivity.java index 8dabc77cd..ec537d703 100644 --- a/src/main/java/eu/siacs/conversations/ui/SendLogActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/SendLogActivity.java @@ -265,7 +265,6 @@ public class SendLogActivity extends ActionBarActivity { log.insert(0, mAdditonalInfo); } - android.util.Log.e("35fd", log.toString()); writer.write(log.toString()); } catch (IOException e){ diff --git a/src/main/java/eu/siacs/conversations/ui/VerifyOTRActivity.java b/src/main/java/eu/siacs/conversations/ui/VerifyOTRActivity.java new file mode 100644 index 000000000..dd9ec29df --- /dev/null +++ b/src/main/java/eu/siacs/conversations/ui/VerifyOTRActivity.java @@ -0,0 +1,450 @@ +package eu.siacs.conversations.ui; + +import android.content.DialogInterface; +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; +import android.view.Menu; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; + +import net.java.otr4j.OtrException; +import net.java.otr4j.session.Session; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Contact; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.utils.CryptoHelper; +import eu.siacs.conversations.utils.XmppUri; +import eu.siacs.conversations.xmpp.Jid; +import me.drakeet.support.toast.ToastCompat; + +public class VerifyOTRActivity extends XmppActivity implements XmppConnectionService.OnConversationUpdate { + + public static final String ACTION_VERIFY_CONTACT = "verify_contact"; + public static final int MODE_SCAN_FINGERPRINT = -0x0502; + public static final int MODE_ASK_QUESTION = 0x0503; + public static final int MODE_ANSWER_QUESTION = 0x0504; + public static final int MODE_MANUAL_VERIFICATION = 0x0505; + + private LinearLayout mManualVerificationArea; + private LinearLayout mSmpVerificationArea; + private TextView mRemoteFingerprint; + private TextView mYourFingerprint; + private TextView mVerificationExplain; + private TextView mStatusMessage; + private TextView mSharedSecretHint; + private EditText mSharedSecretHintEditable; + private EditText mSharedSecretSecret; + private Button mLeftButton; + private Button mRightButton; + private Account mAccount; + private Conversation mConversation; + private int mode = MODE_MANUAL_VERIFICATION; + private XmppUri mPendingUri = null; + + private DialogInterface.OnClickListener mVerifyFingerprintListener = new DialogInterface.OnClickListener() { + + @Override + public void onClick(DialogInterface dialogInterface, int click) { + mConversation.verifyOtrFingerprint(); + xmppConnectionService.syncRosterToDisk(mConversation.getAccount()); + ToastCompat.makeText(VerifyOTRActivity.this, R.string.verified, Toast.LENGTH_SHORT).show(); + finish(); + } + }; + + private View.OnClickListener mCreateSharedSecretListener = new View.OnClickListener() { + @Override + public void onClick(final View view) { + if (isAccountOnline()) { + final String question = mSharedSecretHintEditable.getText().toString(); + final String secret = mSharedSecretSecret.getText().toString(); + if (question.trim().isEmpty()) { + mSharedSecretHintEditable.requestFocus(); + mSharedSecretHintEditable.setError(getString(R.string.shared_secret_hint_should_not_be_empty)); + } else if (secret.trim().isEmpty()) { + mSharedSecretSecret.requestFocus(); + mSharedSecretSecret.setError(getString(R.string.shared_secret_can_not_be_empty)); + } else { + mSharedSecretSecret.setError(null); + mSharedSecretHintEditable.setError(null); + initSmp(question, secret); + updateView(); + } + } + } + }; + private View.OnClickListener mCancelSharedSecretListener = new View.OnClickListener() { + @Override + public void onClick(View view) { + if (isAccountOnline()) { + abortSmp(); + updateView(); + } + } + }; + private View.OnClickListener mRespondSharedSecretListener = new View.OnClickListener() { + + @Override + public void onClick(View view) { + if (isAccountOnline()) { + final String question = mSharedSecretHintEditable.getText().toString(); + final String secret = mSharedSecretSecret.getText().toString(); + respondSmp(question, secret); + updateView(); + } + } + }; + private View.OnClickListener mRetrySharedSecretListener = new View.OnClickListener() { + @Override + public void onClick(View view) { + mConversation.smp().status = Conversation.Smp.STATUS_NONE; + mConversation.smp().hint = null; + mConversation.smp().secret = null; + updateView(); + } + }; + private View.OnClickListener mFinishListener = new View.OnClickListener() { + @Override + public void onClick(View view) { + mConversation.smp().status = Conversation.Smp.STATUS_NONE; + finish(); + } + }; + + protected boolean initSmp(final String question, final String secret) { + final Session session = mConversation.getOtrSession(); + if (session != null) { + try { + session.initSmp(question, secret); + mConversation.smp().status = Conversation.Smp.STATUS_WE_REQUESTED; + mConversation.smp().secret = secret; + mConversation.smp().hint = question; + return true; + } catch (OtrException e) { + return false; + } + } else { + return false; + } + } + + protected boolean abortSmp() { + final Session session = mConversation.getOtrSession(); + if (session != null) { + try { + session.abortSmp(); + mConversation.smp().status = Conversation.Smp.STATUS_NONE; + mConversation.smp().hint = null; + mConversation.smp().secret = null; + return true; + } catch (OtrException e) { + return false; + } + } else { + return false; + } + } + + protected boolean respondSmp(final String question, final String secret) { + final Session session = mConversation.getOtrSession(); + if (session != null) { + try { + session.respondSmp(question, secret); + return true; + } catch (OtrException e) { + return false; + } + } else { + return false; + } + } + + protected boolean verifyWithUri(XmppUri uri) { + Contact contact = mConversation.getContact(); + if (this.mConversation.getContact().getJid().equals(uri.getJid()) && uri.hasFingerprints()) { + xmppConnectionService.verifyFingerprints(contact, uri.getFingerprints()); + ToastCompat.makeText(this, R.string.verified, Toast.LENGTH_SHORT).show(); + updateView(); + return true; + } else { + ToastCompat.makeText(this, R.string.could_not_verify_fingerprint, Toast.LENGTH_SHORT).show(); + return false; + } + } + + protected boolean isAccountOnline() { + if (this.mAccount.getStatus() != Account.State.ONLINE) { + ToastCompat.makeText(this, R.string.not_connected_try_again, Toast.LENGTH_SHORT).show(); + return false; + } else { + return true; + } + } + + protected boolean handleIntent(Intent intent) { + if (intent != null && intent.getAction().equals(ACTION_VERIFY_CONTACT)) { + this.mAccount = extractAccount(intent); + if (this.mAccount == null) { + return false; + } + try { + this.mConversation = this.xmppConnectionService.find(this.mAccount, Jid.of(intent.getExtras().getString("contact")), Jid.of(intent.getExtras().getString("counterpart"))); + if (this.mConversation == null) { + return false; + } + } catch (final IllegalArgumentException ignored) { + ignored.printStackTrace(); + return false; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + this.mode = intent.getIntExtra("mode", MODE_MANUAL_VERIFICATION); + // todo scan OTR fingerprint + if (this.mode == MODE_SCAN_FINGERPRINT) { + Log.d(Config.LOGTAG, "Scan OTR fingerprint is not implemented in this version"); + //new IntentIntegrator(this).initiateScan(); + return false; + } + return true; + } else { + return false; + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent intent) { + // todo onActivityResult for OTR scan + Log.d(Config.LOGTAG, "Scan OTR fingerprint result is not implemented in this version"); + /*if ((requestCode & 0xFFFF) == IntentIntegrator.REQUEST_CODE) { + IntentResult scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, intent); + if (scanResult != null && scanResult.getFormatName() != null) { + String data = scanResult.getContents(); + XmppUri uri = new XmppUri(data); + if (xmppConnectionServiceBound) { + verifyWithUri(uri); + finish(); + } else { + this.mPendingUri = uri; + } + } else { + finish(); + } + }*/ + super.onActivityResult(requestCode, requestCode, intent); + } + + @Override + protected void onBackendConnected() { + if (handleIntent(getIntent())) { + updateView(); + } else if (mPendingUri != null) { + verifyWithUri(mPendingUri); + finish(); + mPendingUri = null; + } + setIntent(null); + } + + protected void updateView() { + if (this.mConversation != null && this.mConversation.hasValidOtrSession()) { + final ActionBar actionBar = getSupportActionBar(); + this.mVerificationExplain.setText(R.string.no_otr_session_found); + invalidateOptionsMenu(); + switch (this.mode) { + case MODE_ASK_QUESTION: + if (actionBar != null) { + actionBar.setTitle(R.string.ask_question); + } + this.updateViewAskQuestion(); + break; + case MODE_ANSWER_QUESTION: + if (actionBar != null) { + actionBar.setTitle(R.string.smp_requested); + } + this.updateViewAnswerQuestion(); + break; + case MODE_MANUAL_VERIFICATION: + default: + if (actionBar != null) { + actionBar.setTitle(R.string.manually_verify); + } + this.updateViewManualVerification(); + break; + } + } else { + this.mManualVerificationArea.setVisibility(View.GONE); + this.mSmpVerificationArea.setVisibility(View.GONE); + } + } + + protected void updateViewManualVerification() { + this.mVerificationExplain.setText(R.string.manual_verification_explanation); + this.mManualVerificationArea.setVisibility(View.VISIBLE); + this.mSmpVerificationArea.setVisibility(View.GONE); + this.mYourFingerprint.setText(CryptoHelper.prettifyFingerprint(this.mAccount.getOtrFingerprint())); + this.mRemoteFingerprint.setText(CryptoHelper.prettifyFingerprint(this.mConversation.getOtrFingerprint())); + if (this.mConversation.isOtrFingerprintVerified()) { + deactivateButton(this.mRightButton, R.string.verified); + activateButton(this.mLeftButton, R.string.cancel, this.mFinishListener); + } else { + activateButton(this.mLeftButton, R.string.cancel, this.mFinishListener); + activateButton(this.mRightButton, R.string.verify, new View.OnClickListener() { + @Override + public void onClick(View view) { + showManuallyVerifyDialog(); + } + }); + } + } + + protected void updateViewAskQuestion() { + this.mManualVerificationArea.setVisibility(View.GONE); + this.mSmpVerificationArea.setVisibility(View.VISIBLE); + this.mVerificationExplain.setText(R.string.smp_explain_question); + final int smpStatus = this.mConversation.smp().status; + switch (smpStatus) { + case Conversation.Smp.STATUS_WE_REQUESTED: + this.mStatusMessage.setVisibility(View.GONE); + this.mSharedSecretHintEditable.setVisibility(View.VISIBLE); + this.mSharedSecretSecret.setVisibility(View.VISIBLE); + this.mSharedSecretHintEditable.setText(this.mConversation.smp().hint); + this.mSharedSecretSecret.setText(this.mConversation.smp().secret); + this.activateButton(this.mLeftButton, R.string.cancel, this.mCancelSharedSecretListener); + this.deactivateButton(this.mRightButton, R.string.in_progress); + break; + case Conversation.Smp.STATUS_FAILED: + this.mStatusMessage.setVisibility(View.GONE); + this.mSharedSecretHintEditable.setVisibility(View.VISIBLE); + this.mSharedSecretSecret.setVisibility(View.VISIBLE); + this.mSharedSecretSecret.requestFocus(); + this.mSharedSecretSecret.setError(getString(R.string.secrets_do_not_match)); + this.deactivateButton(this.mLeftButton, R.string.cancel); + this.activateButton(this.mRightButton, R.string.try_again, this.mRetrySharedSecretListener); + break; + case Conversation.Smp.STATUS_VERIFIED: + this.mSharedSecretHintEditable.setText(""); + this.mSharedSecretHintEditable.setVisibility(View.GONE); + this.mSharedSecretSecret.setText(""); + this.mSharedSecretSecret.setVisibility(View.GONE); + this.mStatusMessage.setVisibility(View.VISIBLE); + this.deactivateButton(this.mLeftButton, R.string.cancel); + this.activateButton(this.mRightButton, R.string.finish, this.mFinishListener); + break; + default: + this.mStatusMessage.setVisibility(View.GONE); + this.mSharedSecretHintEditable.setVisibility(View.VISIBLE); + this.mSharedSecretSecret.setVisibility(View.VISIBLE); + this.activateButton(this.mLeftButton, R.string.cancel, this.mFinishListener); + this.activateButton(this.mRightButton, R.string.ask_question, this.mCreateSharedSecretListener); + break; + } + } + + protected void updateViewAnswerQuestion() { + this.mManualVerificationArea.setVisibility(View.GONE); + this.mSmpVerificationArea.setVisibility(View.VISIBLE); + this.mVerificationExplain.setText(R.string.smp_explain_answer); + this.mSharedSecretHintEditable.setVisibility(View.GONE); + this.mSharedSecretHint.setVisibility(View.VISIBLE); + this.deactivateButton(this.mLeftButton, R.string.cancel); + final int smpStatus = this.mConversation.smp().status; + switch (smpStatus) { + case Conversation.Smp.STATUS_CONTACT_REQUESTED: + this.mStatusMessage.setVisibility(View.GONE); + this.mSharedSecretHint.setText(this.mConversation.smp().hint); + this.activateButton(this.mRightButton, R.string.respond, this.mRespondSharedSecretListener); + break; + case Conversation.Smp.STATUS_VERIFIED: + this.mSharedSecretHintEditable.setText(""); + this.mSharedSecretHintEditable.setVisibility(View.GONE); + this.mSharedSecretHint.setVisibility(View.GONE); + this.mSharedSecretSecret.setText(""); + this.mSharedSecretSecret.setVisibility(View.GONE); + this.mStatusMessage.setVisibility(View.VISIBLE); + this.activateButton(this.mRightButton, R.string.finish, this.mFinishListener); + break; + case Conversation.Smp.STATUS_FAILED: + default: + this.mSharedSecretSecret.requestFocus(); + this.mSharedSecretSecret.setError(getString(R.string.secrets_do_not_match)); + this.activateButton(this.mRightButton, R.string.finish, this.mFinishListener); + break; + } + } + + protected void activateButton(Button button, int text, View.OnClickListener listener) { + button.setEnabled(true); + button.setText(text); + button.setOnClickListener(listener); + } + + protected void deactivateButton(Button button, int text) { + button.setEnabled(false); + button.setText(text); + button.setOnClickListener(null); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_verify_otr); + this.mRemoteFingerprint = findViewById(R.id.remote_fingerprint); + this.mYourFingerprint = findViewById(R.id.your_fingerprint); + this.mLeftButton = findViewById(R.id.left_button); + this.mRightButton = findViewById(R.id.right_button); + this.mVerificationExplain = findViewById(R.id.verification_explanation); + this.mStatusMessage = findViewById(R.id.status_message); + this.mSharedSecretSecret = findViewById(R.id.shared_secret_secret); + this.mSharedSecretHintEditable = findViewById(R.id.shared_secret_hint_editable); + this.mSharedSecretHint = findViewById(R.id.shared_secret_hint); + this.mManualVerificationArea = findViewById(R.id.manual_verification_area); + this.mSmpVerificationArea = findViewById(R.id.smp_verification_area); + } + + @Override + public boolean onCreateOptionsMenu(final Menu menu) { + super.onCreateOptionsMenu(menu); + getMenuInflater().inflate(R.menu.verify_otr, menu); + return true; + } + + private void showManuallyVerifyDialog() { + final AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.manually_verify); + builder.setMessage(R.string.are_you_sure_verify_fingerprint); + builder.setNegativeButton(R.string.cancel, null); + builder.setPositiveButton(R.string.verify, mVerifyFingerprintListener); + builder.create().show(); + } + + @Override + protected String getShareableUri() { + if (mAccount != null) { + return mAccount.getShareableUri(); + } else { + return ""; + } + } + + public void onConversationUpdate() { + refreshUi(); + } + + @Override + protected void refreshUiReal() { + updateView(); + } +} diff --git a/src/main/java/eu/siacs/conversations/ui/XmppActivity.java b/src/main/java/eu/siacs/conversations/ui/XmppActivity.java index 47ce3be01..3e1f878a9 100644 --- a/src/main/java/eu/siacs/conversations/ui/XmppActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/XmppActivity.java @@ -56,6 +56,8 @@ import androidx.databinding.DataBindingUtil; import com.google.common.base.Strings; +import net.java.otr4j.session.SessionID; + import java.io.IOException; import java.lang.ref.WeakReference; import java.util.ArrayList; @@ -446,7 +448,18 @@ public abstract class XmppActivity extends ActionBarActivity { public void selectPresence(final Conversation conversation, final PresenceSelector.OnPresenceSelected listener) { final Contact contact = conversation.getContact(); - if (contact.showInRoster() || contact.isSelf()) { + + if (conversation.hasValidOtrSession()) { + SessionID id = conversation.getOtrSession().getSessionID(); + Jid jid; + try { + jid = Jid.of(id.getAccountID() + "/" + id.getUserID()); + } catch (IllegalArgumentException e) { + jid = null; + } + conversation.setNextCounterpart(jid); + listener.onPresenceSelected(); + } else if (contact.showInRoster() || contact.isSelf()) { final Presences presences = contact.getPresences(); if (presences.size() == 0) { if (contact.isSelf()) { diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java index a074fd6c8..b4109bfdb 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java @@ -9,6 +9,11 @@ import android.widget.ArrayAdapter; import androidx.annotation.NonNull; import androidx.databinding.DataBindingUtil; +import com.kizitonwose.colorpreference.ColorDialog; +import com.kizitonwose.colorpreference.ColorPreference; +import com.kizitonwose.colorpreference.ColorShape; +import com.kizitonwose.colorpreference.ColorUtils; + import java.util.List; import eu.siacs.conversations.Config; @@ -19,22 +24,29 @@ import eu.siacs.conversations.ui.XmppActivity; import eu.siacs.conversations.ui.util.AvatarWorkerTask; import eu.siacs.conversations.ui.util.StyledAttributes; import eu.siacs.conversations.utils.UIHelper; +import eu.siacs.conversations.xmpp.Jid; public class AccountAdapter extends ArrayAdapter { private final XmppActivity activity; private final boolean showStateButton; + private final boolean showColorSelector; + + public ColorSelectorListener colorSelectorListener = null; public AccountAdapter(XmppActivity activity, List objects, boolean showStateButton) { super(activity, 0, objects); this.activity = activity; this.showStateButton = showStateButton; + this.showColorSelector = false; } - public AccountAdapter(XmppActivity activity, List objects) { + public AccountAdapter(XmppActivity activity, List objects, ColorSelectorListener listener) { super(activity, 0, objects); this.activity = activity; this.showStateButton = true; + this.showColorSelector = true; + colorSelectorListener = listener; } @Override @@ -77,18 +89,28 @@ public class AccountAdapter extends ArrayAdapter { } else { viewHolder.binding.tglAccountStatus.setVisibility(View.GONE); } + viewHolder.binding.tglAccountStatus.setOnCheckedChangeListener((compoundButton, b) -> { if (b == isDisabled && activity instanceof OnTglAccountState) { ((OnTglAccountState) activity).onClickTglAccountState(account, b); } }); - if (activity.xmppConnectionService.getAccounts().size() > 1) { - viewHolder.binding.accountIndicator.setBackgroundColor(UIHelper.getColorForName(account.getJid().asBareJid().getEscapedLocal())); + if (this.showColorSelector && activity.xmppConnectionService.getAccounts().size() > 1 && + activity.xmppConnectionService.getPreferences().getBoolean("show_account_indicator", activity.getResources().getBoolean(R.bool.show_account_indicator))) { + int color = UIHelper.getAccountColor(activity, account.getJid()); + viewHolder.binding.colorView.setVisibility(View.VISIBLE); + ColorUtils.setColorViewValue(viewHolder.binding.colorView, color, false, ColorShape.CIRCLE); + viewHolder.binding.colorView.setOnClickListener(v -> { + if (colorSelectorListener != null) { + colorSelectorListener.onColorPickerRequested(account.getJid(), color); + } + }); } else { - viewHolder.binding.accountIndicator.setBackgroundColor(Color.TRANSPARENT); + viewHolder.binding.colorView.setVisibility(View.GONE); } + return view; } @@ -106,4 +128,7 @@ public class AccountAdapter extends ArrayAdapter { void onClickTglAccountState(Account account, boolean state); } + public interface ColorSelectorListener { + void onColorPickerRequested(Jid accountJid, int currentColor); + } } diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/ConversationAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/ConversationAdapter.java index 69d4c7df4..0a8f328ab 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/ConversationAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/ConversationAdapter.java @@ -338,8 +338,12 @@ public class ConversationAdapter } CharSequence name = conversation.getName(); - if (conversation.getNextCounterpart() != null) { - name = viewHolder.binding.getRoot().getResources().getString(R.string.muc_private_conversation_title, conversation.getNextCounterpart().getResource(), conversation.getName()); + if (conversation.getNextCounterpart() != null && conversation.hasPermanentCounterpart()) { + if (conversation.getMode() == Conversational.MODE_MULTI) { + name = viewHolder.binding.getRoot().getResources().getString(R.string.muc_private_conversation_title, conversation.getNextCounterpart().getResource(), conversation.getName()); + } else { + name = viewHolder.binding.getRoot().getResources().getString(R.string.secret_chat_title, conversation.getName(), conversation.getNextCounterpart().getResource()); + } } if (conversation.withSelf()) { @@ -386,6 +390,10 @@ public class ConversationAdapter int drId = activity.getThemeResource(R.attr.ic_group_16, R.drawable.ic_group_selected_black_16); Drawable dr = AppCompatResources.getDrawable(activity, drId); viewHolder.binding.conversationName.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, dr, null); + } else if (conversation.getMode() == Conversation.MODE_SINGLE && conversation.hasPermanentCounterpart()) { + int drId = activity.getThemeResource(R.attr.ic_secret_chat_16, R.drawable.ic_secret_chat_16dp_black); + Drawable dr = AppCompatResources.getDrawable(activity, drId); + viewHolder.binding.conversationName.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, dr, null); } else { viewHolder.binding.conversationName.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, null, null); } @@ -393,7 +401,7 @@ public class ConversationAdapter Contact contact = conversation.getContact(); if (contact != null) { - viewHolder.binding.presenceIndicator.setStatus(contact.getShownStatus()); + viewHolder.binding.presenceIndicator.setStatus(contact); } else { viewHolder.binding.presenceIndicator.setStatus(null); } @@ -401,7 +409,7 @@ public class ConversationAdapter Account account = conversation.getAccount(); if (account != null && activity.xmppConnectionService.getAccounts().size() > 1) { - viewHolder.binding.accountIndicator.setBackgroundColor(UIHelper.getColorForName(account.getJid().asBareJid().getEscapedLocal())); + viewHolder.binding.accountIndicator.setBackgroundColor(UIHelper.getAccountColor(activity, account.getJid())); } else { viewHolder.binding.accountIndicator.setBackgroundColor(Color.TRANSPARENT); } diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/ListItemAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/ListItemAdapter.java index b342ccf0d..f9444a06c 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/ListItemAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/ListItemAdapter.java @@ -94,7 +94,7 @@ public class ListItemAdapter extends ArrayAdapter { AvatarWorkerTask.loadAvatar(item, viewHolder.avatar, R.dimen.avatar); if (item instanceof Contact) { - viewHolder.presenceIndicator.setStatus(((Contact) item).getShownStatus()); + viewHolder.presenceIndicator.setStatus(((Contact) item)); } else { viewHolder.presenceIndicator.setStatus(null); } @@ -108,7 +108,7 @@ public class ListItemAdapter extends ArrayAdapter { } if (account != null && activity.xmppConnectionService.getAccounts().size() > 1) { - viewHolder.accountIndicator.setBackgroundColor(UIHelper.getColorForName(account.getJid().asBareJid().getEscapedLocal())); + viewHolder.accountIndicator.setBackgroundColor(UIHelper.getAccountColor(activity, account.getJid())); } else { viewHolder.accountIndicator.setBackgroundColor(Color.TRANSPARENT); } diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/UserAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/UserAdapter.java index 525fec6f6..e1fbfa283 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/UserAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/UserAdapter.java @@ -91,7 +91,7 @@ public class UserAdapter extends ListAdapter(context.getString(R.string.file_deleted), true); } else if (message.getEncryption() == Message.ENCRYPTION_PGP) { return new Pair<>(context.getString(R.string.pgp_message), true); + } else if (message.getEncryption() == Message.ENCRYPTION_OTR) { + return new Pair<>(context.getString(R.string.otr_message), true); } else if (message.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED) { return new Pair<>(context.getString(R.string.decryption_failed), true); } else if (message.getEncryption() == Message.ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE) { @@ -557,6 +611,8 @@ public class UIHelper { } else { return context.getString(R.string.send_message_to_x, conversation.getName()); } + case Message.ENCRYPTION_OTR: + return context.getString(R.string.send_otr_message); case Message.ENCRYPTION_AXOLOTL: AxolotlService axolotlService = conversation.getAccount().getAxolotlService(); if (axolotlService != null && axolotlService.trustedSessionVerified(conversation)) { diff --git a/src/main/java/eu/siacs/conversations/utils/XmppUri.java b/src/main/java/eu/siacs/conversations/utils/XmppUri.java index 6c3075be9..6f7b8481c 100644 --- a/src/main/java/eu/siacs/conversations/utils/XmppUri.java +++ b/src/main/java/eu/siacs/conversations/utils/XmppUri.java @@ -29,6 +29,7 @@ public class XmppUri { public static final String PARAMETER_PRE_AUTH = "preauth"; public static final String PARAMETER_IBR = "ibr"; private static final String OMEMO_URI_PARAM = "omemo-sid-"; + private static final String OTR_URI_PARAM = "otr-fingerprint"; protected Uri uri; protected String jid; private List fingerprints = new ArrayList<>(); @@ -111,6 +112,8 @@ public class XmppUri { if (type == XmppUri.FingerprintType.OMEMO) { builder.append(XmppUri.OMEMO_URI_PARAM); builder.append(fingerprints.get(i).deviceId); + } else if (type == XmppUri.FingerprintType.OTR) { + builder.append(XmppUri.OTR_URI_PARAM); } builder.append('='); builder.append(fingerprints.get(i).fingerprint); @@ -241,7 +244,8 @@ public class XmppUri { } public enum FingerprintType { - OMEMO + OMEMO, + OTR } public static class Fingerprint { @@ -249,6 +253,10 @@ public class XmppUri { public final String fingerprint; final int deviceId; + public Fingerprint(FingerprintType type, String fingerprint) { + this(type, fingerprint, 0); + } + public Fingerprint(FingerprintType type, String fingerprint, int deviceId) { this.type = type; this.fingerprint = fingerprint; diff --git a/src/main/java/eu/siacs/conversations/xmpp/jid/OtrJidHelper.java b/src/main/java/eu/siacs/conversations/xmpp/jid/OtrJidHelper.java new file mode 100644 index 000000000..09c4dec53 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/xmpp/jid/OtrJidHelper.java @@ -0,0 +1,17 @@ + +package eu.siacs.conversations.xmpp.jid; + +import net.java.otr4j.session.SessionID; + +import eu.siacs.conversations.xmpp.Jid; + +public final class OtrJidHelper { + + public static Jid fromSessionID(final SessionID id) throws IllegalArgumentException { + if (id.getUserID().isEmpty()) { + return Jid.of(id.getAccountID()); + } else { + return Jid.of(id.getAccountID() + "/" + id.getUserID()); + } + } +} \ No newline at end of file diff --git a/src/main/res/drawable/ic_secret_chat_16dp_black.xml b/src/main/res/drawable/ic_secret_chat_16dp_black.xml new file mode 100644 index 000000000..44fa7de58 --- /dev/null +++ b/src/main/res/drawable/ic_secret_chat_16dp_black.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/main/res/drawable/ic_secret_chat_16dp_white.xml b/src/main/res/drawable/ic_secret_chat_16dp_white.xml new file mode 100644 index 000000000..0f704750e --- /dev/null +++ b/src/main/res/drawable/ic_secret_chat_16dp_white.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/main/res/drawable/ic_subject_black.xml b/src/main/res/drawable/ic_subject_black.xml new file mode 100644 index 000000000..6edbae1ae --- /dev/null +++ b/src/main/res/drawable/ic_subject_black.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/main/res/drawable/ic_subject_white.xml b/src/main/res/drawable/ic_subject_white.xml new file mode 100644 index 000000000..8c6262226 --- /dev/null +++ b/src/main/res/drawable/ic_subject_white.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/main/res/layout/account_row.xml b/src/main/res/layout/account_row.xml index ee3f7c138..93d6bd8d7 100644 --- a/src/main/res/layout/account_row.xml +++ b/src/main/res/layout/account_row.xml @@ -6,17 +6,10 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="?selectableItemBackground" - android:clipToPadding="false" android:paddingLeft="8dp" android:paddingBottom="8dp" android:paddingTop="8dp"> - - + android:layout_toLeftOf="@+id/controls" + android:layout_toStartOf="@+id/controls"> - + android:layout_alignParentRight="true" + android:layout_centerVertical="true"> + + + + + + \ No newline at end of file diff --git a/src/main/res/layout/activity_verify_otr.xml b/src/main/res/layout/activity_verify_otr.xml new file mode 100644 index 000000000..5c15dd3e6 --- /dev/null +++ b/src/main/res/layout/activity_verify_otr.xml @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +