ChannelDiscoveryService.java

  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}