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