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