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