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