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