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