rudimentary XEP-0490 implementation

This commit is contained in:
Daniel Gultsch 2024-03-27 10:30:14 +01:00
parent 38e9533be4
commit dd73b01ab1
No known key found for this signature in database
GPG key ID: F43D18AD2A0982C2
8 changed files with 287 additions and 71 deletions

View file

@ -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() {

View file

@ -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,19 +21,9 @@ 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,
@ -41,17 +40,15 @@ public abstract class AbstractGenerator {
Namespace.NICK + "+notify",
"urn:xmpp:ping",
"jabber:iq:version",
"http://jabber.org/protocol/chatstates"
"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,
@ -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");

View file

@ -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");

View file

@ -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);

View file

@ -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) {
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, account);
processBookmarksInitial(account, bookmarks, true);
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,23 +4615,100 @@ 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() {
return this.mMemorizingTrustManager;

View file

@ -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";
}

View file

@ -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);
}
}
}

View file

@ -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