DiscoManager.java

  1package eu.siacs.conversations.xmpp.manager;
  2
  3import android.content.Context;
  4import android.util.Log;
  5import com.google.common.base.Strings;
  6import com.google.common.collect.Collections2;
  7import com.google.common.collect.ImmutableMap;
  8import com.google.common.collect.ImmutableSet;
  9import com.google.common.io.BaseEncoding;
 10import com.google.common.util.concurrent.Futures;
 11import com.google.common.util.concurrent.ListenableFuture;
 12import com.google.common.util.concurrent.MoreExecutors;
 13import eu.siacs.conversations.Config;
 14import eu.siacs.conversations.xml.Namespace;
 15import eu.siacs.conversations.xmpp.Jid;
 16import eu.siacs.conversations.xmpp.XmppConnection;
 17import im.conversations.android.xmpp.Entity;
 18import im.conversations.android.xmpp.EntityCapabilities;
 19import im.conversations.android.xmpp.EntityCapabilities2;
 20import im.conversations.android.xmpp.model.Hash;
 21import im.conversations.android.xmpp.model.disco.info.InfoQuery;
 22import im.conversations.android.xmpp.model.disco.items.Item;
 23import im.conversations.android.xmpp.model.disco.items.ItemsQuery;
 24import im.conversations.android.xmpp.model.stanza.Iq;
 25import java.util.Arrays;
 26import java.util.Collection;
 27import java.util.HashMap;
 28import java.util.List;
 29import java.util.Map;
 30import java.util.Objects;
 31import org.jspecify.annotations.NonNull;
 32import org.jspecify.annotations.Nullable;
 33
 34public class DiscoManager extends AbstractManager {
 35
 36    public static final String CAPABILITY_NODE = "http://conversations.im";
 37
 38    // this is the runtime cache that stores disco information for all entities seen during a
 39    // session
 40
 41    // a caps cache will be build in the database
 42
 43    private final Map<Jid, InfoQuery> entityInformation = new HashMap<>();
 44    private final Map<Jid, ImmutableSet<Jid>> discoItems = new HashMap<>();
 45
 46    public DiscoManager(Context context, XmppConnection connection) {
 47        super(context, connection);
 48    }
 49
 50    public static EntityCapabilities.Hash buildHashFromNode(final String node) {
 51        final var capsPrefix = CAPABILITY_NODE + "#";
 52        final var caps2Prefix = Namespace.ENTITY_CAPABILITIES_2 + "#";
 53        if (node.startsWith(capsPrefix)) {
 54            final String hash = node.substring(capsPrefix.length());
 55            if (Strings.isNullOrEmpty(hash)) {
 56                return null;
 57            }
 58            if (BaseEncoding.base64().canDecode(hash)) {
 59                return EntityCapabilities.EntityCapsHash.of(hash);
 60            }
 61        } else if (node.startsWith(caps2Prefix)) {
 62            final String caps = node.substring(caps2Prefix.length());
 63            if (Strings.isNullOrEmpty(caps)) {
 64                return null;
 65            }
 66            final int separator = caps.lastIndexOf('.');
 67            if (separator < 0) {
 68                return null;
 69            }
 70            final Hash.Algorithm algorithm = Hash.Algorithm.tryParse(caps.substring(0, separator));
 71            final String hash = caps.substring(separator + 1);
 72            if (algorithm == null || Strings.isNullOrEmpty(hash)) {
 73                return null;
 74            }
 75            if (BaseEncoding.base64().canDecode(hash)) {
 76                return EntityCapabilities2.EntityCaps2Hash.of(algorithm, hash);
 77            }
 78        }
 79        return null;
 80    }
 81
 82    public ListenableFuture<Void> infoOrCache(
 83            final Entity entity,
 84            final im.conversations.android.xmpp.model.capabilties.EntityCapabilities.NodeHash
 85                    nodeHash) {
 86        if (nodeHash == null) {
 87            return infoOrCache(entity, null, null);
 88        }
 89        return infoOrCache(entity, nodeHash.node, nodeHash.hash);
 90    }
 91
 92    public ListenableFuture<Void> infoOrCache(
 93            final Entity entity, final String node, final EntityCapabilities.Hash hash) {
 94        final var cached = getDatabase().getInfoQuery(hash);
 95        if (cached != null) {
 96            if (node == null || hash != null) {
 97                this.put(entity.address, cached);
 98            }
 99            return Futures.immediateFuture(null);
100        }
101        return Futures.transform(
102                info(entity, node, hash), f -> null, MoreExecutors.directExecutor());
103    }
104
105    public ListenableFuture<InfoQuery> info(
106            @NonNull final Entity entity, @Nullable final String node) {
107        return info(entity, node, null);
108    }
109
110    public ListenableFuture<InfoQuery> info(
111            final Entity entity, @Nullable final String node, final EntityCapabilities.Hash hash) {
112        final var requestNode = hash != null && node != null ? hash.capabilityNode(node) : node;
113        final var iqRequest = new Iq(Iq.Type.GET);
114        iqRequest.setTo(entity.address);
115        final InfoQuery infoQueryRequest = iqRequest.addExtension(new InfoQuery());
116        if (requestNode != null) {
117            infoQueryRequest.setNode(requestNode);
118        }
119        final var future = connection.sendIqPacket(iqRequest);
120        return Futures.transform(
121                future,
122                iqResult -> {
123                    final var infoQuery = iqResult.getExtension(InfoQuery.class);
124                    if (infoQuery == null) {
125                        throw new IllegalStateException("Response did not have query child");
126                    }
127                    if (!Objects.equals(requestNode, infoQuery.getNode())) {
128                        throw new IllegalStateException(
129                                "Node in response did not match node in request");
130                    }
131
132                    if (node == null
133                            || (hash != null
134                                    && hash.capabilityNode(node).equals(infoQuery.getNode()))) {
135                        // only storing results w/o nodes
136                        this.put(entity.address, infoQuery);
137                    }
138
139                    final var caps = EntityCapabilities.hash(infoQuery);
140                    final var caps2 = EntityCapabilities2.hash(infoQuery);
141                    if (hash instanceof EntityCapabilities.EntityCapsHash) {
142                        checkMatch(
143                                (EntityCapabilities.EntityCapsHash) hash,
144                                caps,
145                                EntityCapabilities.EntityCapsHash.class);
146                    }
147                    if (hash instanceof EntityCapabilities2.EntityCaps2Hash) {
148                        checkMatch(
149                                (EntityCapabilities2.EntityCaps2Hash) hash,
150                                caps2,
151                                EntityCapabilities2.EntityCaps2Hash.class);
152                    }
153                    // we want to avoid caching disco info for entities that put variable data (like
154                    // number of occupants in a MUC) into it
155                    final boolean cache =
156                            Objects.nonNull(hash)
157                                    || infoQuery.hasFeature(Namespace.ENTITY_CAPABILITIES)
158                                    || infoQuery.hasFeature(Namespace.ENTITY_CAPABILITIES_2);
159
160                    if (cache) {
161                        getDatabase().insertCapsCache(caps, caps2, infoQuery);
162                    }
163
164                    return infoQuery;
165                },
166                MoreExecutors.directExecutor());
167    }
168
169    private <H extends EntityCapabilities.Hash> void checkMatch(
170            final H expected, final H was, final Class<H> clazz) {
171        if (Arrays.equals(expected.hash, was.hash)) {
172            return;
173        }
174        throw new CapsHashMismatchException(
175                String.format(
176                        "%s mismatch. Expected %s was %s",
177                        clazz.getSimpleName(),
178                        BaseEncoding.base64().encode(expected.hash),
179                        BaseEncoding.base64().encode(was.hash)));
180    }
181
182    public ListenableFuture<Collection<Item>> items(final Entity.DiscoItem entity) {
183        return items(entity, null);
184    }
185
186    public ListenableFuture<Collection<Item>> items(
187            final Entity.DiscoItem entity, @Nullable final String node) {
188        final var requestNode = Strings.emptyToNull(node);
189        final var iqPacket = new Iq(Iq.Type.GET);
190        iqPacket.setTo(entity.address);
191        final ItemsQuery itemsQueryRequest = iqPacket.addExtension(new ItemsQuery());
192        if (requestNode != null) {
193            itemsQueryRequest.setNode(requestNode);
194        }
195        final var future = connection.sendIqPacket(iqPacket);
196        return Futures.transform(
197                future,
198                iqResult -> {
199                    final var itemsQuery = iqResult.getExtension(ItemsQuery.class);
200                    if (itemsQuery == null) {
201                        throw new IllegalStateException();
202                    }
203                    if (!Objects.equals(requestNode, itemsQuery.getNode())) {
204                        throw new IllegalStateException(
205                                "Node in response did not match node in request");
206                    }
207                    final var items = itemsQuery.getExtensions(Item.class);
208
209                    final var validItems =
210                            Collections2.filter(items, i -> Objects.nonNull(i.getJid()));
211
212                    final var itemsAsAddresses =
213                            ImmutableSet.copyOf(Collections2.transform(validItems, Item::getJid));
214                    if (node == null) {
215                        this.discoItems.put(entity.address, itemsAsAddresses);
216                    }
217                    return validItems;
218                },
219                MoreExecutors.directExecutor());
220    }
221
222    public ListenableFuture<List<InfoQuery>> itemsWithInfo(final Entity.DiscoItem entity) {
223        final var itemsFutures = items(entity);
224        final var filtered =
225                Futures.transform(
226                        itemsFutures,
227                        items ->
228                                Collections2.filter(
229                                        items,
230                                        i ->
231                                                i.getNode() == null
232                                                        && !entity.address.equals(i.getJid())),
233                        MoreExecutors.directExecutor());
234        return Futures.transformAsync(
235                filtered,
236                items -> {
237                    Collection<ListenableFuture<InfoQuery>> infoFutures =
238                            Collections2.transform(
239                                    items, i -> info(Entity.discoItem(i.getJid()), i.getNode()));
240                    return Futures.allAsList(infoFutures);
241                },
242                MoreExecutors.directExecutor());
243    }
244
245    public ListenableFuture<Map<String, Jid>> commands(final Entity.DiscoItem entity) {
246        final var itemsFuture = items(entity, Namespace.COMMANDS);
247        return Futures.transform(
248                itemsFuture,
249                items -> {
250                    final var builder = new ImmutableMap.Builder<String, Jid>();
251                    for (final var item : items) {
252                        final var jid = item.getJid();
253                        final var node = item.getNode();
254                        if (Jid.Invalid.isValid(jid) && node != null) {
255                            builder.put(node, jid);
256                        }
257                    }
258                    return builder.buildKeepingLast();
259                },
260                MoreExecutors.directExecutor());
261    }
262
263    public Map<Jid, InfoQuery> getServerItems() {
264        final var builder = new ImmutableMap.Builder<Jid, InfoQuery>();
265        final var domain = connection.getAccount().getDomain();
266        final var domainInfoQuery = get(domain);
267        if (domainInfoQuery != null) {
268            builder.put(domain, domainInfoQuery);
269        }
270        final var items = this.discoItems.get(domain);
271        if (items == null) {
272            return builder.build();
273        }
274        for (final var item : items) {
275            final var infoQuery = get(item);
276            if (infoQuery == null) {
277                continue;
278            }
279            builder.put(item, infoQuery);
280        }
281        return builder.buildKeepingLast();
282    }
283
284    private void put(final Jid address, final InfoQuery infoQuery) {
285        synchronized (this.entityInformation) {
286            this.entityInformation.put(address, infoQuery);
287        }
288    }
289
290    public InfoQuery get(final Jid address) {
291        synchronized (this.entityInformation) {
292            return this.entityInformation.get(address);
293        }
294    }
295
296    public void clear() {
297        synchronized (this.entityInformation) {
298            this.entityInformation.clear();
299        }
300    }
301
302    public static final class CapsHashMismatchException extends IllegalStateException {
303        public CapsHashMismatchException(final String message) {
304            super(message);
305        }
306    }
307}