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.ImmutableMap;
8import com.google.common.collect.ImmutableSet;
9import com.google.common.io.BaseEncoding;
10import com.google.common.util.concurrent.Futures;
11import com.google.common.util.concurrent.ListenableFuture;
12import com.google.common.util.concurrent.MoreExecutors;
13import eu.siacs.conversations.Config;
14import eu.siacs.conversations.xml.Namespace;
15import eu.siacs.conversations.xmpp.Jid;
16import eu.siacs.conversations.xmpp.XmppConnection;
17import im.conversations.android.xmpp.Entity;
18import im.conversations.android.xmpp.EntityCapabilities;
19import im.conversations.android.xmpp.EntityCapabilities2;
20import im.conversations.android.xmpp.model.Hash;
21import im.conversations.android.xmpp.model.disco.info.InfoQuery;
22import im.conversations.android.xmpp.model.disco.items.Item;
23import im.conversations.android.xmpp.model.disco.items.ItemsQuery;
24import im.conversations.android.xmpp.model.stanza.Iq;
25import java.util.Arrays;
26import java.util.Collection;
27import java.util.HashMap;
28import java.util.List;
29import java.util.Map;
30import java.util.Objects;
31import org.jspecify.annotations.NonNull;
32import org.jspecify.annotations.Nullable;
33
34public class DiscoManager extends AbstractManager {
35
36 public static final String CAPABILITY_NODE = "http://conversations.im";
37
38 // this is the runtime cache that stores disco information for all entities seen during a
39 // session
40
41 // a caps cache will be build in the database
42
43 private final Map<Jid, InfoQuery> entityInformation = new HashMap<>();
44 private final Map<Jid, ImmutableSet<Jid>> discoItems = new HashMap<>();
45
46 public DiscoManager(Context context, XmppConnection connection) {
47 super(context, connection);
48 }
49
50 public static EntityCapabilities.Hash buildHashFromNode(final String node) {
51 final var capsPrefix = CAPABILITY_NODE + "#";
52 final var caps2Prefix = Namespace.ENTITY_CAPABILITIES_2 + "#";
53 if (node.startsWith(capsPrefix)) {
54 final String hash = node.substring(capsPrefix.length());
55 if (Strings.isNullOrEmpty(hash)) {
56 return null;
57 }
58 if (BaseEncoding.base64().canDecode(hash)) {
59 return EntityCapabilities.EntityCapsHash.of(hash);
60 }
61 } else if (node.startsWith(caps2Prefix)) {
62 final String caps = node.substring(caps2Prefix.length());
63 if (Strings.isNullOrEmpty(caps)) {
64 return null;
65 }
66 final int separator = caps.lastIndexOf('.');
67 if (separator < 0) {
68 return null;
69 }
70 final Hash.Algorithm algorithm = Hash.Algorithm.tryParse(caps.substring(0, separator));
71 final String hash = caps.substring(separator + 1);
72 if (algorithm == null || Strings.isNullOrEmpty(hash)) {
73 return null;
74 }
75 if (BaseEncoding.base64().canDecode(hash)) {
76 return EntityCapabilities2.EntityCaps2Hash.of(algorithm, hash);
77 }
78 }
79 return null;
80 }
81
82 public ListenableFuture<Void> infoOrCache(
83 final Entity entity,
84 final im.conversations.android.xmpp.model.capabilties.EntityCapabilities.NodeHash
85 nodeHash) {
86 if (nodeHash == null) {
87 return infoOrCache(entity, null, null);
88 }
89 return infoOrCache(entity, nodeHash.node, nodeHash.hash);
90 }
91
92 public ListenableFuture<Void> infoOrCache(
93 final Entity entity, final String node, final EntityCapabilities.Hash hash) {
94 final var cached = getDatabase().getInfoQuery(hash);
95 if (cached != null) {
96 if (node == null || hash != null) {
97 this.put(entity.address, cached);
98 }
99 return Futures.immediateFuture(null);
100 }
101 return Futures.transform(
102 info(entity, node, hash), f -> null, MoreExecutors.directExecutor());
103 }
104
105 public ListenableFuture<InfoQuery> info(
106 @NonNull final Entity entity, @Nullable final String node) {
107 return info(entity, node, null);
108 }
109
110 public ListenableFuture<InfoQuery> info(
111 final Entity entity, @Nullable final String node, final EntityCapabilities.Hash hash) {
112 final var requestNode = hash != null && node != null ? hash.capabilityNode(node) : node;
113 final var iqRequest = new Iq(Iq.Type.GET);
114 iqRequest.setTo(entity.address);
115 final InfoQuery infoQueryRequest = iqRequest.addExtension(new InfoQuery());
116 if (requestNode != null) {
117 infoQueryRequest.setNode(requestNode);
118 }
119 final var future = connection.sendIqPacket(iqRequest);
120 return Futures.transform(
121 future,
122 iqResult -> {
123 final var infoQuery = iqResult.getExtension(InfoQuery.class);
124 if (infoQuery == null) {
125 throw new IllegalStateException("Response did not have query child");
126 }
127 if (!Objects.equals(requestNode, infoQuery.getNode())) {
128 throw new IllegalStateException(
129 "Node in response did not match node in request");
130 }
131
132 if (node == null
133 || (hash != null
134 && hash.capabilityNode(node).equals(infoQuery.getNode()))) {
135 // only storing results w/o nodes
136 this.put(entity.address, infoQuery);
137 }
138
139 final var caps = EntityCapabilities.hash(infoQuery);
140 final var caps2 = EntityCapabilities2.hash(infoQuery);
141 if (hash instanceof EntityCapabilities.EntityCapsHash) {
142 checkMatch(
143 (EntityCapabilities.EntityCapsHash) hash,
144 caps,
145 EntityCapabilities.EntityCapsHash.class);
146 }
147 if (hash instanceof EntityCapabilities2.EntityCaps2Hash) {
148 checkMatch(
149 (EntityCapabilities2.EntityCaps2Hash) hash,
150 caps2,
151 EntityCapabilities2.EntityCaps2Hash.class);
152 }
153 // we want to avoid caching disco info for entities that put variable data (like
154 // number of occupants in a MUC) into it
155 final boolean cache =
156 Objects.nonNull(hash)
157 || infoQuery.hasFeature(Namespace.ENTITY_CAPABILITIES)
158 || infoQuery.hasFeature(Namespace.ENTITY_CAPABILITIES_2);
159
160 if (cache) {
161 getDatabase().insertCapsCache(caps, caps2, infoQuery);
162 }
163
164 return infoQuery;
165 },
166 MoreExecutors.directExecutor());
167 }
168
169 private <H extends EntityCapabilities.Hash> void checkMatch(
170 final H expected, final H was, final Class<H> clazz) {
171 if (Arrays.equals(expected.hash, was.hash)) {
172 return;
173 }
174 throw new CapsHashMismatchException(
175 String.format(
176 "%s mismatch. Expected %s was %s",
177 clazz.getSimpleName(),
178 BaseEncoding.base64().encode(expected.hash),
179 BaseEncoding.base64().encode(was.hash)));
180 }
181
182 public ListenableFuture<Collection<Item>> items(final Entity.DiscoItem entity) {
183 return items(entity, null);
184 }
185
186 public ListenableFuture<Collection<Item>> items(
187 final Entity.DiscoItem entity, @Nullable final String node) {
188 final var requestNode = Strings.emptyToNull(node);
189 final var iqPacket = new Iq(Iq.Type.GET);
190 iqPacket.setTo(entity.address);
191 final ItemsQuery itemsQueryRequest = iqPacket.addExtension(new ItemsQuery());
192 if (requestNode != null) {
193 itemsQueryRequest.setNode(requestNode);
194 }
195 final var future = connection.sendIqPacket(iqPacket);
196 return Futures.transform(
197 future,
198 iqResult -> {
199 final var itemsQuery = iqResult.getExtension(ItemsQuery.class);
200 if (itemsQuery == null) {
201 throw new IllegalStateException();
202 }
203 if (!Objects.equals(requestNode, itemsQuery.getNode())) {
204 throw new IllegalStateException(
205 "Node in response did not match node in request");
206 }
207 final var items = itemsQuery.getExtensions(Item.class);
208
209 final var validItems =
210 Collections2.filter(items, i -> Objects.nonNull(i.getJid()));
211
212 final var itemsAsAddresses =
213 ImmutableSet.copyOf(Collections2.transform(validItems, Item::getJid));
214 if (node == null) {
215 this.discoItems.put(entity.address, itemsAsAddresses);
216 }
217 return validItems;
218 },
219 MoreExecutors.directExecutor());
220 }
221
222 public ListenableFuture<List<InfoQuery>> itemsWithInfo(final Entity.DiscoItem entity) {
223 final var itemsFutures = items(entity);
224 final var filtered =
225 Futures.transform(
226 itemsFutures,
227 items ->
228 Collections2.filter(
229 items,
230 i ->
231 i.getNode() == null
232 && !entity.address.equals(i.getJid())),
233 MoreExecutors.directExecutor());
234 return Futures.transformAsync(
235 filtered,
236 items -> {
237 Collection<ListenableFuture<InfoQuery>> infoFutures =
238 Collections2.transform(
239 items, i -> info(Entity.discoItem(i.getJid()), i.getNode()));
240 return Futures.allAsList(infoFutures);
241 },
242 MoreExecutors.directExecutor());
243 }
244
245 public ListenableFuture<Map<String, Jid>> commands(final Entity.DiscoItem entity) {
246 final var itemsFuture = items(entity, Namespace.COMMANDS);
247 return Futures.transform(
248 itemsFuture,
249 items -> {
250 final var builder = new ImmutableMap.Builder<String, Jid>();
251 for (final var item : items) {
252 final var jid = item.getJid();
253 final var node = item.getNode();
254 if (Jid.Invalid.isValid(jid) && node != null) {
255 builder.put(node, jid);
256 }
257 }
258 return builder.buildKeepingLast();
259 },
260 MoreExecutors.directExecutor());
261 }
262
263 public Map<Jid, InfoQuery> getServerItems() {
264 final var builder = new ImmutableMap.Builder<Jid, InfoQuery>();
265 final var domain = connection.getAccount().getDomain();
266 final var domainInfoQuery = get(domain);
267 if (domainInfoQuery != null) {
268 builder.put(domain, domainInfoQuery);
269 }
270 final var items = this.discoItems.get(domain);
271 if (items == null) {
272 return builder.build();
273 }
274 for (final var item : items) {
275 final var infoQuery = get(item);
276 if (infoQuery == null) {
277 continue;
278 }
279 builder.put(item, infoQuery);
280 }
281 return builder.buildKeepingLast();
282 }
283
284 private void put(final Jid address, final InfoQuery infoQuery) {
285 synchronized (this.entityInformation) {
286 this.entityInformation.put(address, infoQuery);
287 }
288 }
289
290 public InfoQuery get(final Jid address) {
291 synchronized (this.entityInformation) {
292 return this.entityInformation.get(address);
293 }
294 }
295
296 public void clear() {
297 synchronized (this.entityInformation) {
298 this.entityInformation.clear();
299 }
300 }
301
302 public static final class CapsHashMismatchException extends IllegalStateException {
303 public CapsHashMismatchException(final String message) {
304 super(message);
305 }
306 }
307}