ChannelDiscoveryService.java

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