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.Futures;
14import com.google.common.util.concurrent.ListenableFuture;
15import com.google.common.util.concurrent.MoreExecutors;
16import eu.siacs.conversations.AppSettings;
17import eu.siacs.conversations.BuildConfig;
18import eu.siacs.conversations.Config;
19import eu.siacs.conversations.R;
20import eu.siacs.conversations.crypto.axolotl.AxolotlService;
21import eu.siacs.conversations.xml.Namespace;
22import eu.siacs.conversations.xmpp.Jid;
23import eu.siacs.conversations.xmpp.XmppConnection;
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 = "http://conversations.im";
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 Namespace.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(Context 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.addAll(STATIC_FEATURES);
317 if (Config.MESSAGE_DISPLAYED_SYNCHRONIZATION) {
318 features.add(Namespace.MDS_DISPLAYED + "+notify");
319 }
320 if (appSettings.isConfirmMessages()) {
321 features.addAll(MESSAGE_CONFIRMATION_FEATURES);
322 }
323 if (appSettings.isAllowMessageCorrection()) {
324 features.addAll(MESSAGE_CORRECTION_FEATURES);
325 }
326 if (Config.supportOmemo()) {
327 features.add(AxolotlService.PEP_DEVICE_LIST_NOTIFY);
328 }
329 if (!appSettings.isUseTor() && !account.isOnion()) {
330 features.addAll(PRIVACY_SENSITIVE);
331 features.addAll(VOIP_NAMESPACES);
332 features.add(Namespace.JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL);
333 }
334 if (appSettings.isBroadcastLastActivity()) {
335 features.add(Namespace.IDLE);
336 }
337 if (getManager(BookmarkManager.class).hasFeature()) {
338 features.add(Namespace.BOOKMARKS2 + "+notify");
339 } else {
340 features.add(Namespace.BOOKMARKS + "+notify");
341 }
342 return new ServiceDescription(
343 features.build(),
344 new ServiceDescription.Identity(BuildConfig.APP_NAME, "client", getIdentityType()));
345 }
346
347 String getIdentityVersion() {
348 return BuildConfig.VERSION_NAME;
349 }
350
351 String getIdentityType() {
352 if ("chromium".equals(android.os.Build.BRAND)) {
353 return "pc";
354 } else if (context.getResources().getBoolean(R.bool.is_device_table)) {
355 return "tablet";
356 } else {
357 return "phone";
358 }
359 }
360
361 public void handleVersionRequest(final Iq request) {
362 final var version = new Version();
363 version.setSoftwareName(context.getString(R.string.app_name));
364 version.setVersion(getIdentityVersion());
365 if ("chromium".equals(android.os.Build.BRAND)) {
366 version.setOs("Chrome OS");
367 } else {
368 version.setOs("Android");
369 }
370 Log.d(Config.LOGTAG, "responding to version request from " + request.getFrom());
371 connection.sendResultFor(request, version);
372 }
373
374 public void handleInfoQuery(final Iq request) {
375 final var infoQueryRequest = request.getExtension(InfoQuery.class);
376 final var nodeRequest = infoQueryRequest.getNode();
377 final ServiceDescription serviceDescription;
378 if (Strings.isNullOrEmpty(nodeRequest)) {
379 serviceDescription = getServiceDescription();
380 Log.d(Config.LOGTAG, "responding to disco request w/o node from " + request.getFrom());
381 } else {
382 final var hash = buildHashFromNode(nodeRequest);
383 final var cachedServiceDescription =
384 hash != null
385 ? getManager(PresenceManager.class).getCachedServiceDescription(hash)
386 : null;
387 if (cachedServiceDescription != null) {
388 Log.d(
389 Config.LOGTAG,
390 "responding to disco request from "
391 + request.getFrom()
392 + " to node "
393 + nodeRequest
394 + " using hash "
395 + hash.getClass().getName());
396 serviceDescription = cachedServiceDescription;
397 } else {
398 connection.sendErrorFor(request, Error.Type.CANCEL, new Condition.ItemNotFound());
399 return;
400 }
401 }
402 final var infoQuery = serviceDescription.asInfoQuery();
403 infoQuery.setNode(nodeRequest);
404 connection.sendResultFor(request, infoQuery);
405 }
406
407 public Map<Jid, InfoQuery> getServerItems() {
408 final var builder = new ImmutableMap.Builder<Jid, InfoQuery>();
409 final var domain = connection.getAccount().getDomain();
410 final var domainInfoQuery = get(domain);
411 if (domainInfoQuery != null) {
412 builder.put(domain, domainInfoQuery);
413 }
414 final var items = this.discoItems.get(domain);
415 if (items == null) {
416 return builder.build();
417 }
418 for (final var item : items) {
419 final var infoQuery = get(item);
420 if (infoQuery == null) {
421 continue;
422 }
423 builder.put(item, infoQuery);
424 }
425 return builder.buildKeepingLast();
426 }
427
428 public boolean hasServerFeature(final String feature) {
429 final var infoQuery = this.get(getAccount().getDomain());
430 return infoQuery != null && infoQuery.hasFeature(feature);
431 }
432
433 public boolean hasAccountFeature(final String feature) {
434 final var infoQuery = this.get(getAccount().getJid().asBareJid());
435 return infoQuery != null && infoQuery.hasFeature(feature);
436 }
437
438 private void put(final Jid address, final InfoQuery infoQuery) {
439 synchronized (this.entityInformation) {
440 this.entityInformation.put(address, infoQuery);
441 }
442 }
443
444 public InfoQuery get(final Jid address) {
445 synchronized (this.entityInformation) {
446 return this.entityInformation.get(address);
447 }
448 }
449
450 public void clear() {
451 synchronized (this.entityInformation) {
452 this.entityInformation.clear();
453 }
454 }
455
456 public void clear(final Jid address) {
457 synchronized (this.entityInformation) {
458 if (address.isFullJid()) {
459 this.entityInformation.remove(address);
460 } else {
461 final var iterator = this.entityInformation.entrySet().iterator();
462 while (iterator.hasNext()) {
463 final var entry = iterator.next();
464 if (entry.getKey().asBareJid().equals(address)) {
465 iterator.remove();
466 }
467 }
468 }
469 }
470 }
471
472 public Map<Jid, InfoQuery> findDiscoItemsByFeature(final String feature) {
473 return Maps.filterValues(getServerItems(), v -> v.hasFeature(feature));
474 }
475
476 public Map.Entry<Jid, InfoQuery> findDiscoItemByFeature(final String feature) {
477 final var items = findDiscoItemsByFeature(feature);
478 return Iterables.getFirst(items.entrySet(), null);
479 }
480
481 public static final class CapsHashMismatchException extends IllegalStateException {
482 public CapsHashMismatchException(final String message) {
483 super(message);
484 }
485 }
486}