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