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