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}