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}