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 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}