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