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