1package eu.siacs.conversations.services;
2
3import android.util.Log;
4import androidx.annotation.NonNull;
5import com.google.common.base.Strings;
6import com.google.common.cache.Cache;
7import com.google.common.cache.CacheBuilder;
8import com.google.common.collect.Collections2;
9import com.google.common.collect.ImmutableList;
10import com.google.common.collect.ImmutableMap;
11import com.google.common.collect.Ordering;
12import com.google.common.util.concurrent.FutureCallback;
13import com.google.common.util.concurrent.Futures;
14import com.google.common.util.concurrent.ListenableFuture;
15import com.google.common.util.concurrent.MoreExecutors;
16import eu.siacs.conversations.Config;
17import eu.siacs.conversations.entities.Account;
18import eu.siacs.conversations.entities.Room;
19import eu.siacs.conversations.http.HttpConnectionManager;
20import eu.siacs.conversations.http.services.MuclumbusService;
21import eu.siacs.conversations.xmpp.Jid;
22import eu.siacs.conversations.xmpp.XmppConnection;
23import im.conversations.android.xmpp.model.disco.info.InfoQuery;
24import im.conversations.android.xmpp.model.disco.items.Item;
25import im.conversations.android.xmpp.model.disco.items.ItemsQuery;
26import im.conversations.android.xmpp.model.stanza.Iq;
27import java.io.IOException;
28import java.util.ArrayList;
29import java.util.Collection;
30import java.util.Collections;
31import java.util.List;
32import java.util.Map;
33import java.util.Objects;
34import java.util.concurrent.Executors;
35import java.util.concurrent.TimeUnit;
36import okhttp3.OkHttpClient;
37import okhttp3.ResponseBody;
38import retrofit2.Call;
39import retrofit2.Callback;
40import retrofit2.Response;
41import retrofit2.Retrofit;
42import retrofit2.converter.gson.GsonConverterFactory;
43
44public class ChannelDiscoveryService {
45
46 private final XmppConnectionService service;
47
48 private MuclumbusService muclumbusService;
49
50 private final Cache<String, List<Room>> cache;
51
52 ChannelDiscoveryService(XmppConnectionService service) {
53 this.service = service;
54 this.cache = CacheBuilder.newBuilder().expireAfterWrite(5, TimeUnit.MINUTES).build();
55 }
56
57 void initializeMuclumbusService() {
58 if (Strings.isNullOrEmpty(Config.CHANNEL_DISCOVERY)) {
59 this.muclumbusService = null;
60 return;
61 }
62 final OkHttpClient.Builder builder =
63 HttpConnectionManager.okHttpClient(service).newBuilder();
64 if (service.useTorToConnect()) {
65 builder.proxy(HttpConnectionManager.getProxy());
66 }
67 final Retrofit retrofit =
68 new Retrofit.Builder()
69 .client(builder.build())
70 .baseUrl(Config.CHANNEL_DISCOVERY)
71 .addConverterFactory(GsonConverterFactory.create())
72 .callbackExecutor(Executors.newSingleThreadExecutor())
73 .build();
74 this.muclumbusService = retrofit.create(MuclumbusService.class);
75 }
76
77 void cleanCache() {
78 cache.invalidateAll();
79 }
80
81 void discover(
82 @NonNull final String query,
83 Method method,
84 Map<Jid, XmppConnection> mucServices,
85 OnChannelSearchResultsFound onChannelSearchResultsFound) {
86 final List<Room> result = cache.getIfPresent(key(method, mucServices, query));
87 if (result != null) {
88 onChannelSearchResultsFound.onChannelSearchResultsFound(result);
89 return;
90 }
91 if (method == Method.LOCAL_SERVER) {
92 discoverChannelsLocalServers(query, mucServices, onChannelSearchResultsFound);
93 } else {
94 if (query.isEmpty()) {
95 discoverChannelsJabberNetwork(onChannelSearchResultsFound);
96 } else {
97 discoverChannelsJabberNetwork(query, onChannelSearchResultsFound);
98 }
99 }
100 }
101
102 private void discoverChannelsJabberNetwork(final OnChannelSearchResultsFound listener) {
103 if (muclumbusService == null) {
104 listener.onChannelSearchResultsFound(Collections.emptyList());
105 return;
106 }
107 final Call<MuclumbusService.Rooms> call = muclumbusService.getRooms(1);
108 call.enqueue(
109 new Callback<MuclumbusService.Rooms>() {
110 @Override
111 public void onResponse(
112 @NonNull Call<MuclumbusService.Rooms> call,
113 @NonNull Response<MuclumbusService.Rooms> response) {
114 final MuclumbusService.Rooms body = response.body();
115 if (body == null) {
116 listener.onChannelSearchResultsFound(Collections.emptyList());
117 logError(response);
118 return;
119 }
120 cache.put(key(Method.JABBER_NETWORK, null, ""), body.items);
121 listener.onChannelSearchResultsFound(body.items);
122 }
123
124 @Override
125 public void onFailure(
126 @NonNull Call<MuclumbusService.Rooms> call,
127 @NonNull Throwable throwable) {
128 Log.d(
129 Config.LOGTAG,
130 "Unable to query muclumbus on " + Config.CHANNEL_DISCOVERY,
131 throwable);
132 listener.onChannelSearchResultsFound(Collections.emptyList());
133 }
134 });
135 }
136
137 private void discoverChannelsJabberNetwork(
138 final String query, final OnChannelSearchResultsFound listener) {
139 if (muclumbusService == null) {
140 listener.onChannelSearchResultsFound(Collections.emptyList());
141 return;
142 }
143 final MuclumbusService.SearchRequest searchRequest =
144 new MuclumbusService.SearchRequest(query);
145 final Call<MuclumbusService.SearchResult> searchResultCall =
146 muclumbusService.search(searchRequest);
147 searchResultCall.enqueue(
148 new Callback<MuclumbusService.SearchResult>() {
149 @Override
150 public void onResponse(
151 @NonNull Call<MuclumbusService.SearchResult> call,
152 @NonNull Response<MuclumbusService.SearchResult> response) {
153 final MuclumbusService.SearchResult body = response.body();
154 if (body == null) {
155 listener.onChannelSearchResultsFound(Collections.emptyList());
156 logError(response);
157 return;
158 }
159 cache.put(key(Method.JABBER_NETWORK, null, query), body.result.items);
160 listener.onChannelSearchResultsFound(body.result.items);
161 }
162
163 @Override
164 public void onFailure(
165 @NonNull Call<MuclumbusService.SearchResult> call,
166 @NonNull Throwable throwable) {
167 Log.d(
168 Config.LOGTAG,
169 "Unable to query muclumbus on " + Config.CHANNEL_DISCOVERY,
170 throwable);
171 listener.onChannelSearchResultsFound(Collections.emptyList());
172 }
173 });
174 }
175
176 private void discoverChannelsLocalServers(
177 final String query, Map<Jid, XmppConnection> mucServices, final OnChannelSearchResultsFound listener) {
178 final var localMucService = mucServices == null ? getLocalMucServices() : mucServices;
179 Log.d(Config.LOGTAG, "checking with " + localMucService.size() + " muc services");
180 if (localMucService.isEmpty()) {
181 listener.onChannelSearchResultsFound(Collections.emptyList());
182 return;
183 }
184 if (!query.isEmpty()) {
185 final List<Room> cached = cache.getIfPresent(key(Method.LOCAL_SERVER, mucServices, ""));
186 if (cached != null) {
187 final List<Room> results = copyMatching(cached, query);
188 cache.put(key(Method.LOCAL_SERVER, mucServices, query), results);
189 listener.onChannelSearchResultsFound(results);
190 }
191 }
192 final var roomsRoomsFuture =
193 Futures.successfulAsList(
194 Collections2.transform(
195 localMucService.entrySet(),
196 e -> discoverRooms(e.getValue(), e.getKey())));
197 final var roomsFuture =
198 Futures.transform(
199 roomsRoomsFuture,
200 rooms -> {
201 final var builder = new ImmutableList.Builder<Room>();
202 for (final var inner : rooms) {
203 if (inner == null) {
204 continue;
205 }
206 builder.addAll(inner);
207 }
208 return builder.build();
209 },
210 MoreExecutors.directExecutor());
211 Futures.addCallback(
212 roomsFuture,
213 new FutureCallback<>() {
214 @Override
215 public void onSuccess(ImmutableList<Room> rooms) {
216 finishDiscoSearch(rooms, query, mucServices, listener);
217 }
218
219 @Override
220 public void onFailure(@NonNull Throwable throwable) {
221 Log.d(Config.LOGTAG, "could not perform room search", throwable);
222 }
223 },
224 MoreExecutors.directExecutor());
225 }
226
227 private ListenableFuture<Collection<Room>> discoverRooms(
228 final XmppConnection connection, final Jid server) {
229 final var request = new Iq(Iq.Type.GET);
230 request.addExtension(new ItemsQuery());
231 request.setTo(server);
232 final ListenableFuture<Collection<Item>> itemsFuture =
233 Futures.transform(
234 connection.sendIqPacket(request),
235 iq -> {
236 final var itemsQuery = iq.getExtension(ItemsQuery.class);
237 if (itemsQuery == null) {
238 return Collections.emptyList();
239 }
240 final var items = itemsQuery.getExtensions(Item.class);
241 return Collections2.filter(items, i -> Objects.nonNull(i.getJid()));
242 },
243 MoreExecutors.directExecutor());
244 final var roomsFutures =
245 Futures.transformAsync(
246 itemsFuture,
247 items -> {
248 final var infoFutures =
249 Collections2.transform(
250 items, i -> discoverRoom(connection, i.getJid()));
251 return Futures.successfulAsList(infoFutures);
252 },
253 MoreExecutors.directExecutor());
254 return Futures.transform(
255 roomsFutures,
256 rooms -> Collections2.filter(rooms, Objects::nonNull),
257 MoreExecutors.directExecutor());
258 }
259
260 private ListenableFuture<Room> discoverRoom(final XmppConnection connection, final Jid room) {
261 final var request = new Iq(Iq.Type.GET);
262 request.addExtension(new InfoQuery());
263 request.setTo(room);
264 final var infoQueryResponseFuture = connection.sendIqPacket(request);
265 return Futures.transform(
266 infoQueryResponseFuture,
267 result -> {
268 final var infoQuery = result.getExtension(InfoQuery.class);
269 if (infoQuery == null) {
270 return null;
271 }
272 return Room.of(room, infoQuery);
273 },
274 MoreExecutors.directExecutor());
275 }
276
277 private void finishDiscoSearch(
278 final List<Room> rooms,
279 final String query,
280 Map<Jid, XmppConnection> mucServices,
281 final OnChannelSearchResultsFound listener) {
282 Log.d(Config.LOGTAG, "finishDiscoSearch with " + rooms.size() + " rooms");
283 final var sorted = Ordering.natural().sortedCopy(rooms);
284 cache.put(key(Method.LOCAL_SERVER, mucServices, ""), sorted);
285 if (query.isEmpty()) {
286 listener.onChannelSearchResultsFound(sorted);
287 } else {
288 List<Room> results = copyMatching(sorted, query);
289 cache.put(key(Method.LOCAL_SERVER, mucServices, query), results);
290 listener.onChannelSearchResultsFound(sorted);
291 }
292 }
293
294 private static List<Room> copyMatching(List<Room> haystack, String needle) {
295 ArrayList<Room> result = new ArrayList<>();
296 for (Room room : haystack) {
297 if (room.contains(needle)) {
298 result.add(room);
299 }
300 }
301 return result;
302 }
303
304 private Map<Jid, XmppConnection> getLocalMucServices() {
305 final ImmutableMap.Builder<Jid, XmppConnection> localMucServices =
306 new ImmutableMap.Builder<>();
307 for (final var account : service.getAccounts()) {
308 final var connection = account.getXmppConnection();
309 if (connection != null && account.isEnabled()) {
310 for (final String mucService : connection.getMucServers()) {
311 final Jid jid = Jid.ofOrInvalid(mucService);
312 if (Jid.Invalid.isValid(jid)) {
313 localMucServices.put(jid, connection);
314 }
315 }
316 }
317 }
318 return localMucServices.buildKeepingLast();
319 }
320
321 private static String key(Method method, Map<Jid, XmppConnection> mucServices, String query) {
322 final String servicesKey = mucServices == null ? "\00" : String.join("\00", mucServices.keySet());
323 return String.format("%s\00%s\00%s", method, servicesKey, query);
324 }
325
326 private static void logError(final Response response) {
327 final ResponseBody errorBody = response.errorBody();
328 Log.d(Config.LOGTAG, "code from muclumbus=" + response.code());
329 if (errorBody == null) {
330 return;
331 }
332 try {
333 Log.d(Config.LOGTAG, "error body=" + errorBody.string());
334 } catch (IOException e) {
335 // ignored
336 }
337 }
338
339 public interface OnChannelSearchResultsFound {
340 void onChannelSearchResultsFound(List<Room> results);
341 }
342
343 public enum Method {
344 JABBER_NETWORK,
345 LOCAL_SERVER
346 }
347}