Detailed changes
@@ -8,6 +8,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.common.collect.ComparisonChain;
+import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import org.json.JSONArray;
@@ -437,6 +438,17 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
return null;
}
+ public Message findReceivedWithRemoteId(final String id) {
+ synchronized (this.messages) {
+ for (final Message message : this.messages) {
+ if (message.getStatus() == Message.STATUS_RECEIVED && id.equals(message.getRemoteMsgId())) {
+ return message;
+ }
+ }
+ }
+ return null;
+ }
+
public Message findMessageWithServerMsgId(String id) {
synchronized (this.messages) {
for (Message message : this.messages) {
@@ -576,20 +588,20 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
}
}
- public List<Message> markRead(String upToUuid) {
- final List<Message> unread = new ArrayList<>();
+ public List<Message> markRead(final String upToUuid) {
+ final ImmutableList.Builder<Message> unread = new ImmutableList.Builder<>();
synchronized (this.messages) {
- for (Message message : this.messages) {
+ for (final Message message : this.messages) {
if (!message.isRead()) {
message.markRead();
unread.add(message);
}
if (message.getUuid().equals(upToUuid)) {
- return unread;
+ return unread.build();
}
}
}
- return unread;
+ return unread.build();
}
public Message getLatestMessage() {
@@ -2,6 +2,15 @@ package eu.siacs.conversations.generator;
import android.util.Base64;
+import eu.siacs.conversations.BuildConfig;
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.R;
+import eu.siacs.conversations.crypto.axolotl.AxolotlService;
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.XmppConnection;
+
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.SimpleDateFormat;
@@ -12,54 +21,42 @@ import java.util.List;
import java.util.Locale;
import java.util.TimeZone;
-import eu.siacs.conversations.BuildConfig;
-import eu.siacs.conversations.Config;
-import eu.siacs.conversations.R;
-import eu.siacs.conversations.crypto.axolotl.AxolotlService;
-import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.services.XmppConnectionService;
-import eu.siacs.conversations.utils.PhoneHelper;
-import eu.siacs.conversations.xml.Namespace;
-import eu.siacs.conversations.xmpp.XmppConnection;
-import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription;
-
public abstract class AbstractGenerator {
- private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US);
+ private static final SimpleDateFormat DATE_FORMAT =
+ new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US);
private final String[] FEATURES = {
- Namespace.JINGLE,
- Namespace.JINGLE_APPS_FILE_TRANSFER,
- Namespace.JINGLE_TRANSPORTS_S5B,
- Namespace.JINGLE_TRANSPORTS_IBB,
- Namespace.JINGLE_ENCRYPTED_TRANSPORT,
- Namespace.JINGLE_ENCRYPTED_TRANSPORT_OMEMO,
- "http://jabber.org/protocol/muc",
- "jabber:x:conference",
- Namespace.OOB,
- "http://jabber.org/protocol/caps",
- "http://jabber.org/protocol/disco#info",
- "urn:xmpp:avatar:metadata+notify",
- Namespace.NICK + "+notify",
- "urn:xmpp:ping",
- "jabber:iq:version",
- "http://jabber.org/protocol/chatstates"
+ Namespace.JINGLE,
+ Namespace.JINGLE_APPS_FILE_TRANSFER,
+ Namespace.JINGLE_TRANSPORTS_S5B,
+ Namespace.JINGLE_TRANSPORTS_IBB,
+ Namespace.JINGLE_ENCRYPTED_TRANSPORT,
+ Namespace.JINGLE_ENCRYPTED_TRANSPORT_OMEMO,
+ "http://jabber.org/protocol/muc",
+ "jabber:x:conference",
+ Namespace.OOB,
+ "http://jabber.org/protocol/caps",
+ "http://jabber.org/protocol/disco#info",
+ "urn:xmpp:avatar:metadata+notify",
+ Namespace.NICK + "+notify",
+ "urn:xmpp:ping",
+ "jabber:iq:version",
+ "http://jabber.org/protocol/chatstates",
+ Namespace.MDS_DISPLAYED + "+notify"
};
private final String[] MESSAGE_CONFIRMATION_FEATURES = {
- "urn:xmpp:chat-markers:0",
- "urn:xmpp:receipts"
- };
- private final String[] MESSAGE_CORRECTION_FEATURES = {
- "urn:xmpp:message-correct:0"
+ "urn:xmpp:chat-markers:0", "urn:xmpp:receipts"
};
+ private final String[] MESSAGE_CORRECTION_FEATURES = {"urn:xmpp:message-correct:0"};
private final String[] PRIVACY_SENSITIVE = {
- "urn:xmpp:time" //XEP-0202: Entity Time leaks time zone
+ "urn:xmpp:time" // XEP-0202: Entity Time leaks time zone
};
private final String[] VOIP_NAMESPACES = {
- Namespace.JINGLE_TRANSPORT_ICE_UDP,
- Namespace.JINGLE_FEATURE_AUDIO,
- Namespace.JINGLE_FEATURE_VIDEO,
- Namespace.JINGLE_APPS_RTP,
- Namespace.JINGLE_APPS_DTLS,
- Namespace.JINGLE_MESSAGE
+ Namespace.JINGLE_TRANSPORT_ICE_UDP,
+ Namespace.JINGLE_FEATURE_AUDIO,
+ Namespace.JINGLE_FEATURE_VIDEO,
+ Namespace.JINGLE_APPS_RTP,
+ Namespace.JINGLE_APPS_DTLS,
+ Namespace.JINGLE_MESSAGE
};
protected XmppConnectionService mXmppConnectionService;
@@ -90,7 +87,11 @@ public abstract class AbstractGenerator {
String getCapHash(final Account account) {
StringBuilder s = new StringBuilder();
- s.append("client/").append(getIdentityType()).append("//").append(getIdentityName()).append('<');
+ s.append("client/")
+ .append(getIdentityType())
+ .append("//")
+ .append(getIdentityName())
+ .append('<');
MessageDigest md;
try {
md = MessageDigest.getInstance("SHA-1");
@@ -129,6 +129,10 @@ public class IqGenerator extends AbstractGenerator {
return retrieve(Namespace.BOOKMARKS2, null);
}
+ public IqPacket retrieveMds() {
+ return retrieve(Namespace.MDS_DISPLAYED, null);
+ }
+
public IqPacket publishNick(String nick) {
final Element item = new Element("item");
item.setAttribute("id", "current");
@@ -264,6 +268,24 @@ public class IqGenerator extends AbstractGenerator {
return conference;
}
+ public Element mdsDisplayed(final String stanzaId, final Conversation conversation) {
+ final Jid by;
+ if (conversation.getMode() == Conversation.MODE_MULTI) {
+ by = conversation.getJid().asBareJid();
+ } else {
+ by = conversation.getAccount().getJid().asBareJid();
+ }
+ return mdsDisplayed(stanzaId, by);
+ }
+
+ private Element mdsDisplayed(final String stanzaId, final Jid by) {
+ final Element displayed = new Element("displayed", Namespace.MDS_DISPLAYED);
+ final Element stanzaIdElement = displayed.addChild("stanza-id", Namespace.STANZA_IDS);
+ stanzaIdElement.setAttribute("id", stanzaId);
+ stanzaIdElement.setAttribute("by", by);
+ return displayed;
+ }
+
public IqPacket publishBundles(final SignedPreKeyRecord signedPreKeyRecord, final IdentityKey identityKey,
final Set<PreKeyRecord> preKeyRecords, final int deviceId, Bundle publishOptions) {
final Element item = new Element("item");
@@ -271,6 +271,9 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
mXmppConnectionService.updateConversationUi();
}
}
+ } else if (Namespace.MDS_DISPLAYED.equals(node) && account.getJid().asBareJid().equals(from)) {
+ final Element item = items.findChild("item");
+ mXmppConnectionService.processMdsItem(account, item);
} else {
Log.d(Config.LOGTAG, account.getJid().asBareJid() + " received pubsub notification for node=" + node);
}
@@ -985,12 +988,18 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
}
}
}
- Element displayed = packet.findChild("displayed", "urn:xmpp:chat-markers:0");
+ final Element displayed = packet.findChild("displayed", "urn:xmpp:chat-markers:0");
if (displayed != null) {
final String id = displayed.getAttribute("id");
final Jid sender = InvalidJid.getNullForInvalid(displayed.getAttributeAsJid("sender"));
if (packet.fromAccount(account) && !selfAddressed) {
- dismissNotification(account, counterpart, query, id);
+ final Conversation c =
+ mXmppConnectionService.find(account, counterpart.asBareJid());
+ final Message message =
+ (c == null || id == null) ? null : c.findReceivedWithRemoteId(id);
+ if (message != null && (query == null || query.isCatchup())) {
+ mXmppConnectionService.markReadUpTo(c, message);
+ }
if (query == null) {
activateGracePeriod(account);
}
@@ -1012,7 +1021,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
final boolean trueJidMatchesAccount = account.getJid().asBareJid().equals(trueJid == null ? null : trueJid.asBareJid());
if (trueJidMatchesAccount || conversation.getMucOptions().isSelf(counterpart)) {
if (!message.isRead() && (query == null || query.isCatchup())) { //checking if message is unread fixes race conditions with reflections
- mXmppConnectionService.markRead(conversation);
+ mXmppConnectionService.markReadUpTo(conversation, message);
}
} else if (!counterpart.isBareJid() && trueJid != null) {
final ReadByMarker readByMarker = ReadByMarker.from(counterpart, trueJid);
@@ -49,6 +49,7 @@ import android.util.Pair;
import androidx.annotation.BoolRes;
import androidx.annotation.IntegerRes;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import androidx.core.app.RemoteInput;
import androidx.core.content.ContextCompat;
import androidx.core.util.Consumer;
@@ -56,6 +57,8 @@ import androidx.core.util.Consumer;
import com.google.common.base.Objects;
import com.google.common.base.Optional;
import com.google.common.base.Strings;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.Iterables;
import org.conscrypt.Conscrypt;
import org.jxmpp.stringprep.libidn.LibIdnXmppStringprep;
@@ -152,6 +155,7 @@ import eu.siacs.conversations.utils.XmppUri;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xml.LocalizedContent;
import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.InvalidJid;
import eu.siacs.conversations.xmpp.Jid;
import eu.siacs.conversations.xmpp.OnBindListener;
import eu.siacs.conversations.xmpp.OnContactStatusChanged;
@@ -368,6 +372,12 @@ public class XmppConnectionService extends Service {
} else if (!account.getXmppConnection().getFeatures().bookmarksConversion()) {
fetchBookmarks(account);
}
+
+ if (connection.getFeatures().mds()) {
+ fetchMessageDisplayedSynchronization(account);
+ } else {
+ Log.d(Config.LOGTAG,account.getJid()+": server has no support for mds");
+ }
final boolean flexible = account.getXmppConnection().getFeatures().flexibleOfflineMessageRetrieval();
final boolean catchup = getMessageArchiveService().inCatchup(account);
final boolean trackOfflineMessageRetrieval;
@@ -392,6 +402,7 @@ public class XmppConnectionService extends Service {
unifiedPushBroker.renewUnifiedPushEndpointsOnBind(account);
}
};
+
private final AtomicLong mLastExpiryRun = new AtomicLong(0);
private final LruCache<Pair<String, String>, ServiceDiscoveryResult> discoCache = new LruCache<>(20);
private final OnStatusChanged statusListener = new OnStatusChanged() {
@@ -1902,18 +1913,88 @@ public class XmppConnectionService extends Service {
public void fetchBookmarks2(final Account account) {
final IqPacket retrieve = mIqGenerator.retrieveBookmarks();
- sendIqPacket(account, retrieve, new OnIqPacketReceived() {
- @Override
- public void onIqPacketReceived(final Account account, final IqPacket response) {
- if (response.getType() == IqPacket.TYPE.RESULT) {
- final Element pubsub = response.findChild("pubsub", Namespace.PUBSUB);
- final Map<Jid, Bookmark> bookmarks = Bookmark.parseFromPubsub(pubsub, account);
- processBookmarksInitial(account, bookmarks, true);
- }
+ sendIqPacket(account, retrieve, (a, response) -> {
+ if (response.getType() == IqPacket.TYPE.RESULT) {
+ final Element pubsub = response.findChild("pubsub", Namespace.PUBSUB);
+ final Map<Jid, Bookmark> bookmarks = Bookmark.parseFromPubsub(pubsub, a);
+ processBookmarksInitial(a, bookmarks, true);
}
});
}
+ private void fetchMessageDisplayedSynchronization(final Account account) {
+ Log.d(Config.LOGTAG, account.getJid() + ": retrieve mds");
+ final var retrieve = mIqGenerator.retrieveMds();
+ sendIqPacket(
+ account,
+ retrieve,
+ (a, response) -> {
+ if (response.getType() != IqPacket.TYPE.RESULT) {
+ return;
+ }
+ final var pubSub = response.findChild("pubsub", Namespace.PUBSUB);
+ final Element items = pubSub == null ? null : pubSub.findChild("items");
+ if (items == null
+ || !Namespace.MDS_DISPLAYED.equals(items.getAttribute("node"))) {
+ return;
+ }
+ for (final Element child : items.getChildren()) {
+ if ("item".equals(child.getName())) {
+ processMdsItem(account, child);
+ }
+ }
+ });
+ }
+
+ public void processMdsItem(final Account account, final Element item) {
+ final Jid jid =
+ item == null ? null : InvalidJid.getNullForInvalid(item.getAttributeAsJid("id"));
+ if (jid == null) {
+ return;
+ }
+ Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": processing mds item for " + jid);
+ final Element displayed = item.findChild("displayed", Namespace.MDS_DISPLAYED);
+ final Element stanzaId =
+ displayed == null ? null : displayed.findChild("stanza-id", Namespace.STANZA_IDS);
+ final String id = stanzaId == null ? null : stanzaId.getAttribute("id");
+ final Conversation conversation = find(account, jid);
+ if (id != null && conversation != null) {
+ markReadUpToStanzaId(conversation, id);
+ }
+ }
+
+ public void markReadUpToStanzaId(final Conversation conversation, final String stanzaId) {
+ final Message message = conversation.findMessageWithServerMsgId(stanzaId);
+ if (message == null) { // do we want to check if isRead?
+ return;
+ }
+ markReadUpTo(conversation, message);
+ }
+
+ public void markReadUpTo(final Conversation conversation, final Message message) {
+ final boolean isDismissNotification = isDismissNotification(message);
+ final var uuid = message.getUuid();
+ Log.d(
+ Config.LOGTAG,
+ conversation.getAccount().getJid().asBareJid()
+ + ": mark "
+ + conversation.getJid().asBareJid()
+ + " as read up to "
+ + uuid);
+ markRead(conversation, uuid, isDismissNotification);
+ }
+
+ private static boolean isDismissNotification(final Message message) {
+ Message next = message.next();
+ while (next != null) {
+ if (message.getStatus() == Message.STATUS_RECEIVED) {
+ return false;
+ }
+ next = next.next();
+ }
+ return true;
+ }
+
public void processBookmarksInitial(Account account, Map<Jid, Bookmark> bookmarks, final boolean pep) {
final Set<Jid> previousBookmarks = account.getBookmarkedJids();
final boolean synchronizeWithBookmarks = synchronizeWithBookmarks();
@@ -2050,7 +2131,7 @@ public class XmppConnectionService extends Service {
}
});
} else {
- Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": error publishing bookmarks (retry=" + retry + ") " + response);
+ Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": error publishing "+node+" (retry=" + retry + ") " + response);
}
});
}
@@ -4534,22 +4615,99 @@ public class XmppConnectionService extends Service {
}
}
- public void sendReadMarker(final Conversation conversation, String upToUuid) {
- final boolean isPrivateAndNonAnonymousMuc = conversation.getMode() == Conversation.MODE_MULTI && conversation.isPrivateAndNonAnonymous();
+ public void sendReadMarker(final Conversation conversation, final String upToUuid) {
+ final boolean isPrivateAndNonAnonymousMuc =
+ conversation.getMode() == Conversation.MODE_MULTI
+ && conversation.isPrivateAndNonAnonymous();
final List<Message> readMessages = this.markRead(conversation, upToUuid, true);
- if (readMessages.size() > 0) {
- updateConversationUi();
+ if (readMessages.isEmpty()) {
+ return;
}
- final Message markable = Conversation.getLatestMarkableMessage(readMessages, isPrivateAndNonAnonymousMuc);
- if (confirmMessages()
- && markable != null
- && (markable.trusted() || isPrivateAndNonAnonymousMuc)
- && markable.getRemoteMsgId() != null) {
- Log.d(Config.LOGTAG, conversation.getAccount().getJid().asBareJid() + ": sending read marker to " + markable.getCounterpart().toString());
- final Account account = conversation.getAccount();
- final MessagePacket packet = mMessageGenerator.confirm(markable);
+ final var account = conversation.getAccount();
+ final var connection = account.getXmppConnection();
+ updateConversationUi();
+ final var last =
+ Iterables.getLast(
+ Collections2.filter(
+ readMessages,
+ m ->
+ !m.isPrivateMessage()
+ && m.getStatus() == Message.STATUS_RECEIVED),
+ null);
+ if (last == null) {
+ return;
+ }
+
+ final boolean sendDisplayedMarker =
+ confirmMessages()
+ && (last.trusted() || isPrivateAndNonAnonymousMuc)
+ && last.getRemoteMsgId() != null
+ && (last.markable || isPrivateAndNonAnonymousMuc);
+ final boolean serverAssist =
+ connection != null && connection.getFeatures().mdsServerAssist();
+
+ final String stanzaId = last.getServerMsgId();
+
+ if (sendDisplayedMarker && serverAssist) {
+ final var mdsDisplayed = mIqGenerator.mdsDisplayed(stanzaId, conversation);
+ final MessagePacket packet = mMessageGenerator.confirm(last);
+ packet.addChild(mdsDisplayed);
+ if (!last.isPrivateMessage()) {
+ packet.setTo(packet.getTo().asBareJid());
+ }
+ Log.d(Config.LOGTAG,account.getJid().asBareJid()+": server assisted "+packet);
this.sendMessagePacket(account, packet);
+ } else {
+ publishMds(last);
+ // read markers will be sent after MDS to flush the CSI stanza queue
+ if (sendDisplayedMarker) {
+ Log.d(
+ Config.LOGTAG,
+ conversation.getAccount().getJid().asBareJid()
+ + ": sending displayed marker to "
+ + last.getCounterpart().toString());
+ final MessagePacket packet = mMessageGenerator.confirm(last);
+ this.sendMessagePacket(account, packet);
+ }
+ }
+ }
+
+ private void publishMds(@Nullable final Message message) {
+ final String stanzaId = message == null ? null : message.getServerMsgId();
+ if (Strings.isNullOrEmpty(stanzaId)) {
+ return;
+ }
+ final Conversation conversation;
+ final var conversational = message.getConversation();
+ if (conversational instanceof Conversation c) {
+ conversation = c;
+ } else {
+ return;
+ }
+ final var account = conversation.getAccount();
+ final var connection = account.getXmppConnection();
+ if (connection == null || !connection.getFeatures().mds()) {
+ return;
}
+ final Jid itemId;
+ if (message.isPrivateMessage()) {
+ itemId = message.getCounterpart();
+ } else {
+ itemId = conversation.getJid().asBareJid();
+ }
+ Log.d(Config.LOGTAG,"publishing mds for "+itemId+"/"+stanzaId);
+ publishMds(account, itemId, stanzaId, conversation);
+ }
+
+ private void publishMds(
+ final Account account, final Jid itemId, final String stanzaId, final Conversation conversation) {
+ final var item = mIqGenerator.mdsDisplayed(stanzaId, conversation);
+ pushNodeAndEnforcePublishOptions(
+ account,
+ Namespace.MDS_DISPLAYED,
+ item,
+ itemId.toEscapedString(),
+ PublishOptions.persistentWhitelistAccessMaxItems());
}
public MemorizingTrustManager getMemorizingTrustManager() {
@@ -24,6 +24,7 @@ public final class Namespace {
public static final String TLS = "urn:ietf:params:xml:ns:xmpp-tls";
public static final String PUBSUB = "http://jabber.org/protocol/pubsub";
public static final String PUBSUB_PUBLISH_OPTIONS = PUBSUB + "#publish-options";
+ public static final String PUBSUB_CONFIG_NODE_MAX = PUBSUB + "#config-node-max";
public static final String PUBSUB_ERROR = PUBSUB + "#errors";
public static final String PUBSUB_OWNER = PUBSUB + "#owner";
public static final String NICK = "http://jabber.org/protocol/nick";
@@ -76,4 +77,6 @@ public final class Namespace {
public static final String REPORTING_REASON_SPAM = "urn:xmpp:reporting:spam";
public static final String SDP_OFFER_ANSWER = "urn:ietf:rfc:3264";
public static final String HASHES = "urn:xmpp:hashes:2";
+ public static final String MDS_DISPLAYED = "urn:xmpp:mds:displayed:0";
+ public static final String MDS_SERVER_ASSIST = "urn:xmpp:mds:server-assist:0";
}
@@ -2968,6 +2968,10 @@ public class XmppConnection implements Runnable {
return hasDiscoFeature(account.getJid().asBareJid(), Namespace.PUBSUB_PUBLISH_OPTIONS);
}
+ public boolean pepConfigNodeMax() {
+ return hasDiscoFeature(account.getJid().asBareJid(), Namespace.PUBSUB_CONFIG_NODE_MAX);
+ }
+
public boolean pepOmemoWhitelisted() {
return hasDiscoFeature(
account.getJid().asBareJid(), AxolotlService.PEP_OMEMO_WHITELISTED);
@@ -3068,5 +3072,13 @@ public class XmppConnection implements Runnable {
public boolean externalServiceDiscovery() {
return hasDiscoFeature(account.getDomain(), Namespace.EXTERNAL_SERVICE_DISCOVERY);
}
+
+ public boolean mds() {
+ return pepPublishOptions() && pepConfigNodeMax();
+ }
+
+ public boolean mdsServerAssist() {
+ return hasDiscoFeature(account.getJid().asBareJid(), Namespace.MDS_DISPLAYED);
+ }
}
}
@@ -31,7 +31,6 @@ public class PublishOptions {
options.putString("pubsub#access_model", "whitelist");
options.putString("pubsub#send_last_published_item", "never");
options.putString("pubsub#max_items", "max");
-
options.putString("pubsub#notify_delete", "true");
options.putString("pubsub#notify_retract", "true"); //one could also set notify=true on the retract