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.Room;
18import eu.siacs.conversations.http.HttpConnectionManager;
19import eu.siacs.conversations.http.services.MuclumbusService;
20import eu.siacs.conversations.xmpp.Jid;
21import eu.siacs.conversations.xmpp.XmppConnection;
22import eu.siacs.conversations.xmpp.manager.MultiUserChatManager;
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 OnChannelSearchResultsFound onChannelSearchResultsFound) {
85 final List<Room> result = cache.getIfPresent(key(method, query));
86 if (result != null) {
87 onChannelSearchResultsFound.onChannelSearchResultsFound(result);
88 return;
89 }
90 if (method == Method.LOCAL_SERVER) {
91 discoverChannelsLocalServers(query, onChannelSearchResultsFound);
92 } else {
93 if (query.isEmpty()) {
94 discoverChannelsJabberNetwork(onChannelSearchResultsFound);
95 } else {
96 discoverChannelsJabberNetwork(query, onChannelSearchResultsFound);
97 }
98 }
99 }
100
101 private void discoverChannelsJabberNetwork(final OnChannelSearchResultsFound listener) {
102 if (muclumbusService == null) {
103 listener.onChannelSearchResultsFound(Collections.emptyList());
104 return;
105 }
106 final Call<MuclumbusService.Rooms> call = muclumbusService.getRooms(1);
107 call.enqueue(
108 new Callback<MuclumbusService.Rooms>() {
109 @Override
110 public void onResponse(
111 @NonNull Call<MuclumbusService.Rooms> call,
112 @NonNull Response<MuclumbusService.Rooms> response) {
113 final MuclumbusService.Rooms body = response.body();
114 if (body == null) {
115 listener.onChannelSearchResultsFound(Collections.emptyList());
116 logError(response);
117 return;
118 }
119 cache.put(key(Method.JABBER_NETWORK, ""), body.items);
120 listener.onChannelSearchResultsFound(body.items);
121 }
122
123 @Override
124 public void onFailure(
125 @NonNull Call<MuclumbusService.Rooms> call,
126 @NonNull Throwable throwable) {
127 Log.d(
128 Config.LOGTAG,
129 "Unable to query muclumbus on " + Config.CHANNEL_DISCOVERY,
130 throwable);
131 listener.onChannelSearchResultsFound(Collections.emptyList());
132 }
133 });
134 }
135
136 private void discoverChannelsJabberNetwork(
137 final String query, final OnChannelSearchResultsFound listener) {
138 if (muclumbusService == null) {
139 listener.onChannelSearchResultsFound(Collections.emptyList());
140 return;
141 }
142 final MuclumbusService.SearchRequest searchRequest =
143 new MuclumbusService.SearchRequest(query);
144 final Call<MuclumbusService.SearchResult> searchResultCall =
145 muclumbusService.search(searchRequest);
146 searchResultCall.enqueue(
147 new Callback<MuclumbusService.SearchResult>() {
148 @Override
149 public void onResponse(
150 @NonNull Call<MuclumbusService.SearchResult> call,
151 @NonNull Response<MuclumbusService.SearchResult> response) {
152 final MuclumbusService.SearchResult body = response.body();
153 if (body == null) {
154 listener.onChannelSearchResultsFound(Collections.emptyList());
155 logError(response);
156 return;
157 }
158 cache.put(key(Method.JABBER_NETWORK, query), body.result.items);
159 listener.onChannelSearchResultsFound(body.result.items);
160 }
161
162 @Override
163 public void onFailure(
164 @NonNull Call<MuclumbusService.SearchResult> call,
165 @NonNull Throwable throwable) {
166 Log.d(
167 Config.LOGTAG,
168 "Unable to query muclumbus on " + Config.CHANNEL_DISCOVERY,
169 throwable);
170 listener.onChannelSearchResultsFound(Collections.emptyList());
171 }
172 });
173 }
174
175 private void discoverChannelsLocalServers(
176 final String query, final OnChannelSearchResultsFound listener) {
177 final var localMucService = getLocalMucServices();
178 Log.d(Config.LOGTAG, "checking with " + localMucService.size() + " muc services");
179 if (localMucService.isEmpty()) {
180 listener.onChannelSearchResultsFound(Collections.emptyList());
181 return;
182 }
183 if (!query.isEmpty()) {
184 final List<Room> cached = cache.getIfPresent(key(Method.LOCAL_SERVER, ""));
185 if (cached != null) {
186 final List<Room> results = copyMatching(cached, query);
187 cache.put(key(Method.LOCAL_SERVER, query), results);
188 listener.onChannelSearchResultsFound(results);
189 }
190 }
191 final var roomsRoomsFuture =
192 Futures.successfulAsList(
193 Collections2.transform(
194 localMucService.entrySet(),
195 e -> discoverRooms(e.getValue(), e.getKey())));
196 final var roomsFuture =
197 Futures.transform(
198 roomsRoomsFuture,
199 rooms -> {
200 final var builder = new ImmutableList.Builder<Room>();
201 for (final var inner : rooms) {
202 if (inner == null) {
203 continue;
204 }
205 builder.addAll(inner);
206 }
207 return builder.build();
208 },
209 MoreExecutors.directExecutor());
210 Futures.addCallback(
211 roomsFuture,
212 new FutureCallback<>() {
213 @Override
214 public void onSuccess(ImmutableList<Room> rooms) {
215 finishDiscoSearch(rooms, query, listener);
216 }
217
218 @Override
219 public void onFailure(@NonNull Throwable throwable) {
220 Log.d(Config.LOGTAG, "could not perform room search", throwable);
221 }
222 },
223 MoreExecutors.directExecutor());
224 }
225
226 private ListenableFuture<Collection<Room>> discoverRooms(
227 final XmppConnection connection, final Jid server) {
228 final var request = new Iq(Iq.Type.GET);
229 request.addExtension(new ItemsQuery());
230 request.setTo(server);
231 final ListenableFuture<Collection<Item>> itemsFuture =
232 Futures.transform(
233 connection.sendIqPacket(request),
234 iq -> {
235 final var itemsQuery = iq.getExtension(ItemsQuery.class);
236 if (itemsQuery == null) {
237 return Collections.emptyList();
238 }
239 final var items = itemsQuery.getExtensions(Item.class);
240 return Collections2.filter(items, i -> Objects.nonNull(i.getJid()));
241 },
242 MoreExecutors.directExecutor());
243 final var roomsFutures =
244 Futures.transformAsync(
245 itemsFuture,
246 items -> {
247 final var infoFutures =
248 Collections2.transform(
249 items, i -> discoverRoom(connection, i.getJid()));
250 return Futures.successfulAsList(infoFutures);
251 },
252 MoreExecutors.directExecutor());
253 return Futures.transform(
254 roomsFutures,
255 rooms -> Collections2.filter(rooms, Objects::nonNull),
256 MoreExecutors.directExecutor());
257 }
258
259 private ListenableFuture<Room> discoverRoom(final XmppConnection connection, final Jid room) {
260 final var request = new Iq(Iq.Type.GET);
261 request.addExtension(new InfoQuery());
262 request.setTo(room);
263 final var infoQueryResponseFuture = connection.sendIqPacket(request);
264 return Futures.transform(
265 infoQueryResponseFuture,
266 result -> {
267 final var infoQuery = result.getExtension(InfoQuery.class);
268 if (infoQuery == null) {
269 return null;
270 }
271 return Room.of(room, infoQuery);
272 },
273 MoreExecutors.directExecutor());
274 }
275
276 private void finishDiscoSearch(
277 final List<Room> rooms,
278 final String query,
279 final OnChannelSearchResultsFound listener) {
280 Log.d(Config.LOGTAG, "finishDiscoSearch with " + rooms.size() + " rooms");
281 final var sorted = Ordering.natural().sortedCopy(rooms);
282 cache.put(key(Method.LOCAL_SERVER, ""), sorted);
283 if (query.isEmpty()) {
284 listener.onChannelSearchResultsFound(sorted);
285 } else {
286 List<Room> results = copyMatching(sorted, query);
287 cache.put(key(Method.LOCAL_SERVER, query), results);
288 listener.onChannelSearchResultsFound(sorted);
289 }
290 }
291
292 private static List<Room> copyMatching(List<Room> haystack, String needle) {
293 ArrayList<Room> result = new ArrayList<>();
294 for (Room room : haystack) {
295 if (room.contains(needle)) {
296 result.add(room);
297 }
298 }
299 return result;
300 }
301
302 private Map<Jid, XmppConnection> getLocalMucServices() {
303 final ImmutableMap.Builder<Jid, XmppConnection> localMucServices =
304 new ImmutableMap.Builder<>();
305 for (final var account : service.getAccounts()) {
306 final var connection = account.getXmppConnection();
307 if (connection != null && account.isEnabled()) {
308 for (final var mucService :
309 connection.getManager(MultiUserChatManager.class).getServices()) {
310 if (Jid.Invalid.isValid(mucService)) {
311 localMucServices.put(mucService, connection);
312 }
313 }
314 }
315 }
316 return localMucServices.buildKeepingLast();
317 }
318
319 private static String key(Method method, String query) {
320 return String.format("%s\00%s", method, query);
321 }
322
323 private static void logError(final Response response) {
324 final ResponseBody errorBody = response.errorBody();
325 Log.d(Config.LOGTAG, "code from muclumbus=" + response.code());
326 if (errorBody == null) {
327 return;
328 }
329 try {
330 Log.d(Config.LOGTAG, "error body=" + errorBody.string());
331 } catch (IOException e) {
332 // ignored
333 }
334 }
335
336 public interface OnChannelSearchResultsFound {
337 void onChannelSearchResultsFound(List<Room> results);
338 }
339
340 public enum Method {
341 JABBER_NETWORK,
342 LOCAL_SERVER
343 }
344}