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}