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