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.ImmutableList;
  8import com.google.common.collect.ImmutableMap;
  9import com.google.common.collect.ImmutableSet;
 10import com.google.common.collect.Iterables;
 11import com.google.common.collect.Maps;
 12import com.google.common.io.BaseEncoding;
 13import com.google.common.util.concurrent.FutureCallback;
 14import com.google.common.util.concurrent.Futures;
 15import com.google.common.util.concurrent.ListenableFuture;
 16import com.google.common.util.concurrent.MoreExecutors;
 17import eu.siacs.conversations.AppSettings;
 18import eu.siacs.conversations.BuildConfig;
 19import eu.siacs.conversations.Config;
 20import eu.siacs.conversations.R;
 21import eu.siacs.conversations.crypto.axolotl.AxolotlService;
 22import eu.siacs.conversations.xml.Namespace;
 23import eu.siacs.conversations.xmpp.Jid;
 24import eu.siacs.conversations.xmpp.XmppConnection;
 25import im.conversations.android.xmpp.Entity;
 26import im.conversations.android.xmpp.EntityCapabilities;
 27import im.conversations.android.xmpp.EntityCapabilities2;
 28import im.conversations.android.xmpp.ServiceDescription;
 29import im.conversations.android.xmpp.model.Hash;
 30import im.conversations.android.xmpp.model.disco.info.InfoQuery;
 31import im.conversations.android.xmpp.model.disco.items.Item;
 32import im.conversations.android.xmpp.model.disco.items.ItemsQuery;
 33import im.conversations.android.xmpp.model.error.Condition;
 34import im.conversations.android.xmpp.model.error.Error;
 35import im.conversations.android.xmpp.model.stanza.Iq;
 36import im.conversations.android.xmpp.model.version.Version;
 37import java.util.Arrays;
 38import java.util.Collection;
 39import java.util.Collections;
 40import java.util.HashMap;
 41import java.util.List;
 42import java.util.Map;
 43import java.util.Objects;
 44import org.jspecify.annotations.NonNull;
 45import org.jspecify.annotations.Nullable;
 46
 47public class DiscoManager extends AbstractManager {
 48
 49    public static final String CAPABILITY_NODE = "http://conversations.im";
 50
 51    private final List<String> STATIC_FEATURES =
 52            Arrays.asList(
 53                    Namespace.JINGLE,
 54                    Namespace.JINGLE_APPS_FILE_TRANSFER,
 55                    Namespace.JINGLE_TRANSPORTS_S5B,
 56                    Namespace.JINGLE_TRANSPORTS_IBB,
 57                    Namespace.JINGLE_ENCRYPTED_TRANSPORT,
 58                    Namespace.JINGLE_ENCRYPTED_TRANSPORT_OMEMO,
 59                    "http://jabber.org/protocol/muc",
 60                    "jabber:x:conference",
 61                    Namespace.OOB,
 62                    Namespace.ENTITY_CAPABILITIES,
 63                    Namespace.ENTITY_CAPABILITIES_2,
 64                    Namespace.DISCO_INFO,
 65                    "urn:xmpp:avatar:metadata+notify",
 66                    Namespace.NICK + "+notify",
 67                    Namespace.PING,
 68                    Namespace.VERSION,
 69                    Namespace.CHAT_STATES,
 70                    Namespace.REACTIONS);
 71    private final List<String> MESSAGE_CONFIRMATION_FEATURES =
 72            Arrays.asList(Namespace.CHAT_MARKERS, Namespace.DELIVERY_RECEIPTS);
 73    private final List<String> MESSAGE_CORRECTION_FEATURES =
 74            Collections.singletonList(Namespace.LAST_MESSAGE_CORRECTION);
 75    private final List<String> PRIVACY_SENSITIVE =
 76            Collections.singletonList(
 77                    Namespace.TIME // XEP-0202: Entity Time leaks time zone
 78                    );
 79    private final List<String> VOIP_NAMESPACES =
 80            Arrays.asList(
 81                    Namespace.JINGLE_TRANSPORT_ICE_UDP,
 82                    Namespace.JINGLE_FEATURE_AUDIO,
 83                    Namespace.JINGLE_FEATURE_VIDEO,
 84                    Namespace.JINGLE_APPS_RTP,
 85                    Namespace.JINGLE_APPS_DTLS,
 86                    Namespace.JINGLE_MESSAGE);
 87
 88    // this is the runtime cache that stores disco information for all entities seen during a
 89    // session
 90
 91    // a caps cache will be build in the database
 92
 93    private final Map<Jid, InfoQuery> entityInformation = new HashMap<>();
 94    private final Map<Jid, ImmutableSet<Jid>> discoItems = new HashMap<>();
 95    private final Map<String, Jid> commands = new HashMap<>();
 96
 97    public DiscoManager(Context context, XmppConnection connection) {
 98        super(context, connection);
 99    }
100
101    public static EntityCapabilities.Hash buildHashFromNode(final String node) {
102        final var capsPrefix = CAPABILITY_NODE + "#";
103        final var caps2Prefix = Namespace.ENTITY_CAPABILITIES_2 + "#";
104        if (node.startsWith(capsPrefix)) {
105            final String hash = node.substring(capsPrefix.length());
106            if (Strings.isNullOrEmpty(hash)) {
107                return null;
108            }
109            if (BaseEncoding.base64().canDecode(hash)) {
110                return EntityCapabilities.EntityCapsHash.of(hash);
111            }
112        } else if (node.startsWith(caps2Prefix)) {
113            final String caps = node.substring(caps2Prefix.length());
114            if (Strings.isNullOrEmpty(caps)) {
115                return null;
116            }
117            final int separator = caps.lastIndexOf('.');
118            if (separator < 0) {
119                return null;
120            }
121            final Hash.Algorithm algorithm = Hash.Algorithm.tryParse(caps.substring(0, separator));
122            final String hash = caps.substring(separator + 1);
123            if (algorithm == null || Strings.isNullOrEmpty(hash)) {
124                return null;
125            }
126            if (BaseEncoding.base64().canDecode(hash)) {
127                return EntityCapabilities2.EntityCaps2Hash.of(algorithm, hash);
128            }
129        }
130        return null;
131    }
132
133    public ListenableFuture<Void> infoOrCache(
134            final Entity entity,
135            final im.conversations.android.xmpp.model.capabilties.EntityCapabilities.NodeHash
136                    nodeHash) {
137        if (nodeHash == null) {
138            return infoOrCache(entity, null, null);
139        }
140        return infoOrCache(entity, nodeHash.node, nodeHash.hash);
141    }
142
143    public ListenableFuture<Void> infoOrCache(
144            final Entity entity, final String node, final EntityCapabilities.Hash hash) {
145        final var cached = getDatabase().getInfoQuery(hash);
146        if (cached != null && Config.ENABLE_CAPS_CACHE) {
147            if (node == null || hash != null) {
148                this.put(entity.address, cached);
149            }
150            return Futures.immediateFuture(null);
151        }
152        return Futures.transform(
153                info(entity, node, hash), f -> null, MoreExecutors.directExecutor());
154    }
155
156    public ListenableFuture<InfoQuery> info(
157            @NonNull final Entity entity, @Nullable final String node) {
158        return info(entity, node, null);
159    }
160
161    public ListenableFuture<InfoQuery> info(
162            final Entity entity, @Nullable final String node, final EntityCapabilities.Hash hash) {
163        final var requestNode = hash != null ? hash.capabilityNode(node) : node;
164        final var iqRequest = new Iq(Iq.Type.GET);
165        iqRequest.setTo(entity.address);
166        final InfoQuery infoQueryRequest = iqRequest.addExtension(new InfoQuery());
167        if (requestNode != null) {
168            infoQueryRequest.setNode(requestNode);
169        }
170        final var future = connection.sendIqPacket(iqRequest);
171        return Futures.transform(
172                future,
173                iqResult -> {
174                    final var infoQuery = iqResult.getExtension(InfoQuery.class);
175                    if (infoQuery == null) {
176                        throw new IllegalStateException("Response did not have query child");
177                    }
178                    if (!Objects.equals(requestNode, infoQuery.getNode())) {
179                        throw new IllegalStateException(
180                                "Node in response did not match node in request");
181                    }
182
183                    if (node == null
184                            || (hash != null
185                                    && hash.capabilityNode(node).equals(infoQuery.getNode()))) {
186                        // only storing results w/o nodes
187                        this.put(entity.address, infoQuery);
188                    }
189
190                    final var caps = EntityCapabilities.hash(infoQuery);
191                    final var caps2 = EntityCapabilities2.hash(infoQuery);
192                    if (hash instanceof EntityCapabilities.EntityCapsHash) {
193                        checkMatch(
194                                (EntityCapabilities.EntityCapsHash) hash,
195                                caps,
196                                EntityCapabilities.EntityCapsHash.class);
197                    }
198                    if (hash instanceof EntityCapabilities2.EntityCaps2Hash) {
199                        checkMatch(
200                                (EntityCapabilities2.EntityCaps2Hash) hash,
201                                caps2,
202                                EntityCapabilities2.EntityCaps2Hash.class);
203                    }
204                    // we want to avoid caching disco info for entities that put variable data (like
205                    // number of occupants in a MUC) into it
206                    final boolean cache =
207                            Objects.nonNull(hash)
208                                    || infoQuery.hasFeature(Namespace.ENTITY_CAPABILITIES)
209                                    || infoQuery.hasFeature(Namespace.ENTITY_CAPABILITIES_2);
210
211                    if (cache) {
212                        getDatabase().insertCapsCache(caps, caps2, infoQuery);
213                    }
214
215                    return infoQuery;
216                },
217                MoreExecutors.directExecutor());
218    }
219
220    private <H extends EntityCapabilities.Hash> void checkMatch(
221            final H expected, final H was, final Class<H> clazz) {
222        if (Arrays.equals(expected.hash, was.hash)) {
223            return;
224        }
225        throw new CapsHashMismatchException(
226                String.format(
227                        "%s mismatch. Expected %s was %s",
228                        clazz.getSimpleName(),
229                        BaseEncoding.base64().encode(expected.hash),
230                        BaseEncoding.base64().encode(was.hash)));
231    }
232
233    public ListenableFuture<Collection<Item>> items(final Entity.DiscoItem entity) {
234        return items(entity, null);
235    }
236
237    public ListenableFuture<Collection<Item>> items(
238            final Entity.DiscoItem entity, @Nullable final String node) {
239        final var requestNode = Strings.emptyToNull(node);
240        final var iqPacket = new Iq(Iq.Type.GET);
241        iqPacket.setTo(entity.address);
242        final ItemsQuery itemsQueryRequest = iqPacket.addExtension(new ItemsQuery());
243        if (requestNode != null) {
244            itemsQueryRequest.setNode(requestNode);
245        }
246        final var future = connection.sendIqPacket(iqPacket);
247        return Futures.transform(
248                future,
249                iqResult -> {
250                    final var itemsQuery = iqResult.getExtension(ItemsQuery.class);
251                    if (itemsQuery == null) {
252                        throw new IllegalStateException();
253                    }
254                    if (!Objects.equals(requestNode, itemsQuery.getNode())) {
255                        throw new IllegalStateException(
256                                "Node in response did not match node in request");
257                    }
258                    final var items = itemsQuery.getExtensions(Item.class);
259
260                    final var validItems =
261                            Collections2.filter(items, i -> Objects.nonNull(i.getJid()));
262
263                    final var itemsAsAddresses =
264                            ImmutableSet.copyOf(Collections2.transform(validItems, Item::getJid));
265                    if (node == null) {
266                        this.discoItems.put(entity.address, itemsAsAddresses);
267                    }
268                    return validItems;
269                },
270                MoreExecutors.directExecutor());
271    }
272
273    public ListenableFuture<List<InfoQuery>> itemsWithInfo(final Entity.DiscoItem entity) {
274        final var itemsFutures = items(entity);
275        final var filtered =
276                Futures.transform(
277                        itemsFutures,
278                        items ->
279                                Collections2.filter(
280                                        items,
281                                        i ->
282                                                i.getNode() == null
283                                                        && !entity.address.equals(i.getJid())),
284                        MoreExecutors.directExecutor());
285        return Futures.transformAsync(
286                filtered,
287                items -> {
288                    Collection<ListenableFuture<InfoQuery>> infoFutures =
289                            Collections2.transform(
290                                    items, i -> info(Entity.discoItem(i.getJid()), i.getNode()));
291                    return Futures.allAsList(infoFutures);
292                },
293                MoreExecutors.directExecutor());
294    }
295
296    public ListenableFuture<Map<String, Jid>> commands(final Entity.DiscoItem entity) {
297        final var itemsFuture = items(entity, Namespace.COMMANDS);
298        return Futures.transform(
299                itemsFuture,
300                items -> {
301                    final var builder = new ImmutableMap.Builder<String, Jid>();
302                    for (final var item : items) {
303                        final var jid = item.getJid();
304                        final var node = item.getNode();
305                        if (Jid.Invalid.isValid(jid) && node != null) {
306                            builder.put(node, jid);
307                        }
308                    }
309                    return builder.buildKeepingLast();
310                },
311                MoreExecutors.directExecutor());
312    }
313
314    ServiceDescription getServiceDescription() {
315        final var appSettings = new AppSettings(context);
316        final var account = connection.getAccount();
317        final ImmutableList.Builder<String> features = ImmutableList.builder();
318        features.addAll(STATIC_FEATURES);
319        if (Config.MESSAGE_DISPLAYED_SYNCHRONIZATION) {
320            features.add(Namespace.MDS_DISPLAYED + "+notify");
321        }
322        if (appSettings.isConfirmMessages()) {
323            features.addAll(MESSAGE_CONFIRMATION_FEATURES);
324        }
325        if (appSettings.isAllowMessageCorrection()) {
326            features.addAll(MESSAGE_CORRECTION_FEATURES);
327        }
328        if (Config.supportOmemo()) {
329            features.add(AxolotlService.PEP_DEVICE_LIST_NOTIFY);
330        }
331        if (!appSettings.isUseTor() && !account.isOnion()) {
332            features.addAll(PRIVACY_SENSITIVE);
333            features.addAll(VOIP_NAMESPACES);
334            features.add(Namespace.JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL);
335        }
336        if (appSettings.isBroadcastLastActivity()) {
337            features.add(Namespace.IDLE);
338        }
339        if (getManager(NativeBookmarkManager.class).hasFeature()) {
340            features.add(Namespace.BOOKMARKS2 + "+notify");
341        } else {
342            features.add(Namespace.BOOKMARKS + "+notify");
343        }
344        return new ServiceDescription(
345                features.build(),
346                new ServiceDescription.Identity(BuildConfig.APP_NAME, "client", getIdentityType()));
347    }
348
349    String getIdentityVersion() {
350        return BuildConfig.VERSION_NAME;
351    }
352
353    String getIdentityType() {
354        if ("chromium".equals(android.os.Build.BRAND)) {
355            return "pc";
356        } else if (context.getResources().getBoolean(R.bool.is_device_table)) {
357            return "tablet";
358        } else {
359            return "phone";
360        }
361    }
362
363    public void handleVersionRequest(final Iq request) {
364        final var version = new Version();
365        version.setSoftwareName(context.getString(R.string.app_name));
366        version.setVersion(getIdentityVersion());
367        if ("chromium".equals(android.os.Build.BRAND)) {
368            version.setOs("Chrome OS");
369        } else {
370            version.setOs("Android");
371        }
372        Log.d(Config.LOGTAG, "responding to version request from " + request.getFrom());
373        connection.sendResultFor(request, version);
374    }
375
376    public void handleInfoQuery(final Iq request) {
377        final var infoQueryRequest = request.getExtension(InfoQuery.class);
378        final var nodeRequest = infoQueryRequest.getNode();
379        final ServiceDescription serviceDescription;
380        if (Strings.isNullOrEmpty(nodeRequest)) {
381            serviceDescription = getServiceDescription();
382            Log.d(Config.LOGTAG, "responding to disco request w/o node from " + request.getFrom());
383        } else {
384            final var hash = buildHashFromNode(nodeRequest);
385            final var cachedServiceDescription =
386                    hash != null
387                            ? getManager(PresenceManager.class).getCachedServiceDescription(hash)
388                            : null;
389            if (cachedServiceDescription != null) {
390                Log.d(
391                        Config.LOGTAG,
392                        "responding to disco request from "
393                                + request.getFrom()
394                                + " to node "
395                                + nodeRequest
396                                + " using hash "
397                                + hash.getClass().getName());
398                serviceDescription = cachedServiceDescription;
399            } else {
400                connection.sendErrorFor(request, Error.Type.CANCEL, new Condition.ItemNotFound());
401                return;
402            }
403        }
404        final var infoQuery = serviceDescription.asInfoQuery();
405        infoQuery.setNode(nodeRequest);
406        connection.sendResultFor(request, infoQuery);
407    }
408
409    public Map<Jid, InfoQuery> getServerItems() {
410        final var builder = new ImmutableMap.Builder<Jid, InfoQuery>();
411        final var domain = connection.getAccount().getDomain();
412        final var domainInfoQuery = get(domain);
413        if (domainInfoQuery != null) {
414            builder.put(domain, domainInfoQuery);
415        }
416        final var items = this.discoItems.get(domain);
417        if (items == null) {
418            return builder.build();
419        }
420        for (final var item : items) {
421            final var infoQuery = get(item);
422            if (infoQuery == null) {
423                continue;
424            }
425            builder.put(item, infoQuery);
426        }
427        return builder.buildKeepingLast();
428    }
429
430    public boolean hasServerFeature(final String feature) {
431        final var infoQuery = this.get(getAccount().getDomain());
432        return infoQuery != null && infoQuery.hasFeature(feature);
433    }
434
435    public boolean hasAccountFeature(final String feature) {
436        final var infoQuery = this.get(getAccount().getJid().asBareJid());
437        return infoQuery != null && infoQuery.hasFeature(feature);
438    }
439
440    private void put(final Jid address, final InfoQuery infoQuery) {
441        synchronized (this.entityInformation) {
442            this.entityInformation.put(address, infoQuery);
443        }
444    }
445
446    public InfoQuery get(final Jid address) {
447        synchronized (this.entityInformation) {
448            return this.entityInformation.get(address);
449        }
450    }
451
452    public Jid getAddressForCommand(final String node) {
453        synchronized (this.commands) {
454            return this.commands.get(node);
455        }
456    }
457
458    public void clear() {
459        synchronized (this.entityInformation) {
460            this.entityInformation.clear();
461        }
462        synchronized (this.commands) {
463            this.commands.clear();
464        }
465    }
466
467    public void clear(final Jid address) {
468        synchronized (this.entityInformation) {
469            if (address.isFullJid()) {
470                this.entityInformation.remove(address);
471            } else {
472                final var iterator = this.entityInformation.entrySet().iterator();
473                while (iterator.hasNext()) {
474                    final var entry = iterator.next();
475                    if (entry.getKey().asBareJid().equals(address)) {
476                        iterator.remove();
477                    }
478                }
479            }
480        }
481    }
482
483    public Map<Jid, InfoQuery> findDiscoItemsByFeature(final String feature) {
484        return Maps.filterValues(getServerItems(), v -> v.hasFeature(feature));
485    }
486
487    public Map.Entry<Jid, InfoQuery> findDiscoItemByFeature(final String feature) {
488        final var items = findDiscoItemsByFeature(feature);
489        return Iterables.getFirst(items.entrySet(), null);
490    }
491
492    public boolean hasServerCommands() {
493        return hasServerFeature(Namespace.COMMANDS);
494    }
495
496    public void fetchServerCommands() {
497        final var future = commands(Entity.discoItem(getAccount().getDomain()));
498        Futures.addCallback(
499                future,
500                new FutureCallback<>() {
501                    @Override
502                    public void onSuccess(Map<String, Jid> result) {
503                        synchronized (commands) {
504                            commands.clear();
505                            commands.putAll(result);
506                        }
507                    }
508
509                    @Override
510                    public void onFailure(@androidx.annotation.NonNull Throwable throwable) {
511                        Log.d(
512                                Config.LOGTAG,
513                                getAccount().getJid().asBareJid() + ": could not fetch commands",
514                                throwable);
515                    }
516                },
517                MoreExecutors.directExecutor());
518    }
519
520    public static final class CapsHashMismatchException extends IllegalStateException {
521        public CapsHashMismatchException(final String message) {
522            super(message);
523        }
524    }
525}