ChannelDiscoveryService.java

  1package eu.siacs.conversations.services;
  2
  3
  4import android.util.Log;
  5
  6import androidx.annotation.NonNull;
  7
  8import com.google.common.base.Strings;
  9import com.google.common.cache.Cache;
 10import com.google.common.cache.CacheBuilder;
 11
 12import eu.siacs.conversations.Config;
 13import eu.siacs.conversations.entities.Account;
 14import eu.siacs.conversations.entities.Room;
 15import eu.siacs.conversations.http.HttpConnectionManager;
 16import eu.siacs.conversations.http.services.MuclumbusService;
 17import eu.siacs.conversations.parser.IqParser;
 18import eu.siacs.conversations.xmpp.Jid;
 19import eu.siacs.conversations.xmpp.XmppConnection;
 20
 21import im.conversations.android.xmpp.model.stanza.Iq;
 22
 23import okhttp3.OkHttpClient;
 24import okhttp3.ResponseBody;
 25
 26import retrofit2.Call;
 27import retrofit2.Callback;
 28import retrofit2.Response;
 29import retrofit2.Retrofit;
 30import retrofit2.converter.gson.GsonConverterFactory;
 31
 32import java.io.IOException;
 33import java.util.ArrayList;
 34import java.util.Collections;
 35import java.util.HashMap;
 36import java.util.List;
 37import java.util.Map;
 38import java.util.concurrent.Executors;
 39import java.util.concurrent.TimeUnit;
 40import java.util.concurrent.atomic.AtomicInteger;
 41
 42public class ChannelDiscoveryService {
 43
 44    private final XmppConnectionService service;
 45
 46    private MuclumbusService muclumbusService;
 47
 48    private final Cache<String, List<Room>> cache;
 49
 50    ChannelDiscoveryService(XmppConnectionService service) {
 51        this.service = service;
 52        this.cache = CacheBuilder.newBuilder().expireAfterWrite(5, TimeUnit.MINUTES).build();
 53    }
 54
 55    void initializeMuclumbusService() {
 56        if (Strings.isNullOrEmpty(Config.CHANNEL_DISCOVERY)) {
 57            this.muclumbusService = null;
 58            return;
 59        }
 60        final OkHttpClient.Builder builder = HttpConnectionManager.okHttpClient(service).newBuilder();
 61        if (service.useTorToConnect()) {
 62            builder.proxy(HttpConnectionManager.getProxy());
 63        }
 64        final Retrofit retrofit =
 65                new Retrofit.Builder()
 66                        .client(builder.build())
 67                        .baseUrl(Config.CHANNEL_DISCOVERY)
 68                        .addConverterFactory(GsonConverterFactory.create())
 69                        .callbackExecutor(Executors.newSingleThreadExecutor())
 70                        .build();
 71        this.muclumbusService = retrofit.create(MuclumbusService.class);
 72    }
 73
 74    void cleanCache() {
 75        cache.invalidateAll();
 76    }
 77
 78    void discover(
 79            @NonNull final String query,
 80            Method method,
 81            Map<Jid, Account> mucServices,
 82            OnChannelSearchResultsFound onChannelSearchResultsFound) {
 83        final List<Room> result = cache.getIfPresent(key(method, mucServices, query));
 84        if (result != null) {
 85            onChannelSearchResultsFound.onChannelSearchResultsFound(result);
 86            return;
 87        }
 88        if (method == Method.LOCAL_SERVER) {
 89            discoverChannelsLocalServers(query, mucServices, onChannelSearchResultsFound);
 90        } else {
 91            if (query.isEmpty()) {
 92                discoverChannelsJabberNetwork(onChannelSearchResultsFound);
 93            } else {
 94                discoverChannelsJabberNetwork(query, onChannelSearchResultsFound);
 95            }
 96        }
 97    }
 98
 99    private void discoverChannelsJabberNetwork(final OnChannelSearchResultsFound listener) {
100        if (muclumbusService == null) {
101            listener.onChannelSearchResultsFound(Collections.emptyList());
102            return;
103        }
104        final Call<MuclumbusService.Rooms> call = muclumbusService.getRooms(1);
105        call.enqueue(
106                new Callback<MuclumbusService.Rooms>() {
107                    @Override
108                    public void onResponse(
109                            @NonNull Call<MuclumbusService.Rooms> call,
110                            @NonNull Response<MuclumbusService.Rooms> response) {
111                        final MuclumbusService.Rooms body = response.body();
112                        if (body == null) {
113                            listener.onChannelSearchResultsFound(Collections.emptyList());
114                            logError(response);
115                            return;
116                        }
117                        cache.put(key(Method.JABBER_NETWORK, null, ""), body.items);
118                        listener.onChannelSearchResultsFound(body.items);
119                    }
120
121                    @Override
122                    public void onFailure(
123                            @NonNull Call<MuclumbusService.Rooms> call,
124                            @NonNull Throwable throwable) {
125                        Log.d(
126                                Config.LOGTAG,
127                                "Unable to query muclumbus on " + Config.CHANNEL_DISCOVERY,
128                                throwable);
129                        listener.onChannelSearchResultsFound(Collections.emptyList());
130                    }
131                });
132    }
133
134    private void discoverChannelsJabberNetwork(
135            final String query, final OnChannelSearchResultsFound listener) {
136        if (muclumbusService == null) {
137            listener.onChannelSearchResultsFound(Collections.emptyList());
138            return;
139        }
140        final MuclumbusService.SearchRequest searchRequest =
141                new MuclumbusService.SearchRequest(query);
142        final Call<MuclumbusService.SearchResult> searchResultCall =
143                muclumbusService.search(searchRequest);
144        searchResultCall.enqueue(
145                new Callback<MuclumbusService.SearchResult>() {
146                    @Override
147                    public void onResponse(
148                            @NonNull Call<MuclumbusService.SearchResult> call,
149                            @NonNull Response<MuclumbusService.SearchResult> response) {
150                        final MuclumbusService.SearchResult body = response.body();
151                        if (body == null) {
152                            listener.onChannelSearchResultsFound(Collections.emptyList());
153                            logError(response);
154                            return;
155                        }
156                        cache.put(key(Method.JABBER_NETWORK, null, query), body.result.items);
157                        listener.onChannelSearchResultsFound(body.result.items);
158                    }
159
160                    @Override
161                    public void onFailure(
162                            @NonNull Call<MuclumbusService.SearchResult> call,
163                            @NonNull Throwable throwable) {
164                        Log.d(
165                                Config.LOGTAG,
166                                "Unable to query muclumbus on " + Config.CHANNEL_DISCOVERY,
167                                throwable);
168                        listener.onChannelSearchResultsFound(Collections.emptyList());
169                    }
170                });
171    }
172
173    private void discoverChannelsLocalServers(
174            final String query, Map<Jid, Account> mucServices, final OnChannelSearchResultsFound listener) {
175        final Map<Jid, Account> localMucService = mucServices == null ? getLocalMucServices() : mucServices;
176        Log.d(Config.LOGTAG, "checking with " + localMucService.size() + " muc services");
177        if (localMucService.isEmpty()) {
178            listener.onChannelSearchResultsFound(Collections.emptyList());
179            return;
180        }
181        if (!query.isEmpty()) {
182            final List<Room> cached = cache.getIfPresent(key(Method.LOCAL_SERVER, mucServices, ""));
183            if (cached != null) {
184                final List<Room> results = copyMatching(cached, query);
185                cache.put(key(Method.LOCAL_SERVER, mucServices, query), results);
186                listener.onChannelSearchResultsFound(results);
187            }
188        }
189        final AtomicInteger queriesInFlight = new AtomicInteger();
190        final List<Room> rooms = new ArrayList<>();
191        for (final Map.Entry<Jid, Account> entry : localMucService.entrySet()) {
192            Iq itemsRequest = service.getIqGenerator().queryDiscoItems(entry.getKey());
193            queriesInFlight.incrementAndGet();
194            final var account = entry.getValue();
195            service.sendIqPacket(
196                    account,
197                    itemsRequest,
198                    (itemsResponse) -> {
199                        if (itemsResponse.getType() == Iq.Type.RESULT) {
200                            final List<Jid> items = IqParser.items(itemsResponse);
201                            for (final Jid item : items) {
202                                if (item.isDomainJid()) continue; // Only looking for MUCs for now, and by spec they have a localpart
203                                final Iq infoRequest =
204                                        service.getIqGenerator().queryDiscoInfo(item);
205                                queriesInFlight.incrementAndGet();
206                                service.sendIqPacket(
207                                        account,
208                                        infoRequest,
209                                        infoResponse -> {
210                                            if (infoResponse.getType()
211                                                    == Iq.Type.RESULT) {
212                                                final Room room =
213                                                        IqParser.parseRoom(infoResponse);
214                                                if (room != null) {
215                                                    rooms.add(room);
216                                                }
217                                                if (queriesInFlight.decrementAndGet() <= 0) {
218                                                    finishDiscoSearch(rooms, query, mucServices, listener);
219                                                }
220                                            } else {
221                                                queriesInFlight.decrementAndGet();
222                                            }
223                                        }, 20L);
224                            }
225                        }
226                        if (queriesInFlight.decrementAndGet() <= 0) {
227                            finishDiscoSearch(rooms, query, mucServices, listener);
228                        }
229                    });
230        }
231    }
232
233    private void finishDiscoSearch(
234            List<Room> rooms, String query, Map<Jid, Account> mucServices, OnChannelSearchResultsFound listener) {
235        Collections.sort(rooms);
236        cache.put(key(Method.LOCAL_SERVER, mucServices, ""), rooms);
237        if (query.isEmpty()) {
238            listener.onChannelSearchResultsFound(rooms);
239        } else {
240            List<Room> results = copyMatching(rooms, query);
241            cache.put(key(Method.LOCAL_SERVER, mucServices, query), results);
242            listener.onChannelSearchResultsFound(rooms);
243        }
244    }
245
246    private static List<Room> copyMatching(List<Room> haystack, String needle) {
247        ArrayList<Room> result = new ArrayList<>();
248        for (Room room : haystack) {
249            if (room.contains(needle)) {
250                result.add(room);
251            }
252        }
253        return result;
254    }
255
256    private Map<Jid, Account> getLocalMucServices() {
257        final HashMap<Jid, Account> localMucServices = new HashMap<>();
258        for (Account account : service.getAccounts()) {
259            if (account.isEnabled()) {
260                final XmppConnection xmppConnection = account.getXmppConnection();
261                if (xmppConnection == null) {
262                    continue;
263                }
264                for (final String mucService : xmppConnection.getMucServers()) {
265                    Jid jid = Jid.ofEscaped(mucService);
266                    if (!localMucServices.containsKey(jid)) {
267                        localMucServices.put(jid, account);
268                    }
269                }
270            }
271        }
272        return localMucServices;
273    }
274
275    private static String key(Method method, Map<Jid, Account> mucServices, String query) {
276        final String servicesKey = mucServices == null ? "\00" : String.join("\00", mucServices.keySet());
277        return String.format("%s\00%s\00%s", method, servicesKey, query);
278    }
279
280    private static void logError(final Response response) {
281        final ResponseBody errorBody = response.errorBody();
282        Log.d(Config.LOGTAG, "code from muclumbus=" + response.code());
283        if (errorBody == null) {
284            return;
285        }
286        try {
287            Log.d(Config.LOGTAG, "error body=" + errorBody.string());
288        } catch (IOException e) {
289            // ignored
290        }
291    }
292
293    public interface OnChannelSearchResultsFound {
294        void onChannelSearchResultsFound(List<Room> results);
295    }
296
297    public enum Method {
298        JABBER_NETWORK,
299        LOCAL_SERVER
300    }
301}