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