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            OnChannelSearchResultsFound onChannelSearchResultsFound) {
111        final List<Room> result = cache.getIfPresent(key(method, query));
112        if (result != null) {
113            onChannelSearchResultsFound.onChannelSearchResultsFound(result);
114            return;
115        }
116        if (method == Method.LOCAL_SERVER) {
117            discoverChannelsLocalServers(query, onChannelSearchResultsFound);
118        } else {
119            if (query.isEmpty()) {
120                discoverChannelsJabberNetwork(onChannelSearchResultsFound);
121            } else {
122                discoverChannelsJabberNetwork(query, onChannelSearchResultsFound);
123            }
124        }
125    }
126
127    private void discoverChannelsJabberNetwork(final OnChannelSearchResultsFound listener) {
128        if (muclumbusService == null) {
129            listener.onChannelSearchResultsFound(Collections.emptyList());
130            return;
131        }
132        final Call<MuclumbusService.Rooms> call = muclumbusService.getRooms(1);
133        call.enqueue(
134                new Callback<MuclumbusService.Rooms>() {
135                    @Override
136                    public void onResponse(
137                            @NonNull Call<MuclumbusService.Rooms> call,
138                            @NonNull Response<MuclumbusService.Rooms> response) {
139                        final MuclumbusService.Rooms body = response.body();
140                        if (body == null) {
141                            listener.onChannelSearchResultsFound(Collections.emptyList());
142                            logError(response);
143                            return;
144                        }
145                        cache.put(key(Method.JABBER_NETWORK, ""), body.items);
146                        listener.onChannelSearchResultsFound(body.items);
147                    }
148
149                    @Override
150                    public void onFailure(
151                            @NonNull Call<MuclumbusService.Rooms> call,
152                            @NonNull Throwable throwable) {
153                        Log.d(
154                                Config.LOGTAG,
155                                "Unable to query muclumbus on " + Config.CHANNEL_DISCOVERY,
156                                throwable);
157                        listener.onChannelSearchResultsFound(Collections.emptyList());
158                    }
159                });
160    }
161
162    private void discoverChannelsJabberNetwork(
163            final String query, final OnChannelSearchResultsFound listener) {
164        if (muclumbusService == null) {
165            listener.onChannelSearchResultsFound(Collections.emptyList());
166            return;
167        }
168        final MuclumbusService.SearchRequest searchRequest =
169                new MuclumbusService.SearchRequest(query);
170        final Call<MuclumbusService.SearchResult> searchResultCall =
171                muclumbusService.search(searchRequest);
172        searchResultCall.enqueue(
173                new Callback<MuclumbusService.SearchResult>() {
174                    @Override
175                    public void onResponse(
176                            @NonNull Call<MuclumbusService.SearchResult> call,
177                            @NonNull Response<MuclumbusService.SearchResult> response) {
178                        final MuclumbusService.SearchResult body = response.body();
179                        if (body == null) {
180                            listener.onChannelSearchResultsFound(Collections.emptyList());
181                            logError(response);
182                            return;
183                        }
184                        cache.put(key(Method.JABBER_NETWORK, query), body.result.items);
185                        listener.onChannelSearchResultsFound(body.result.items);
186                    }
187
188                    @Override
189                    public void onFailure(
190                            @NonNull Call<MuclumbusService.SearchResult> call,
191                            @NonNull Throwable throwable) {
192                        Log.d(
193                                Config.LOGTAG,
194                                "Unable to query muclumbus on " + Config.CHANNEL_DISCOVERY,
195                                throwable);
196                        listener.onChannelSearchResultsFound(Collections.emptyList());
197                    }
198                });
199    }
200
201    private void discoverChannelsLocalServers(
202            final String query, final OnChannelSearchResultsFound listener) {
203        final Map<Jid, Account> localMucService = getLocalMucServices();
204        Log.d(Config.LOGTAG, "checking with " + localMucService.size() + " muc services");
205        if (localMucService.size() == 0) {
206            listener.onChannelSearchResultsFound(Collections.emptyList());
207            return;
208        }
209        if (!query.isEmpty()) {
210            final List<Room> cached = cache.getIfPresent(key(Method.LOCAL_SERVER, ""));
211            if (cached != null) {
212                final List<Room> results = copyMatching(cached, query);
213                cache.put(key(Method.LOCAL_SERVER, query), results);
214                listener.onChannelSearchResultsFound(results);
215            }
216        }
217        final AtomicInteger queriesInFlight = new AtomicInteger();
218        final List<Room> rooms = new ArrayList<>();
219        for (Map.Entry<Jid, Account> entry : localMucService.entrySet()) {
220            IqPacket itemsRequest = service.getIqGenerator().queryDiscoItems(entry.getKey());
221            queriesInFlight.incrementAndGet();
222            service.sendIqPacket(
223                    entry.getValue(),
224                    itemsRequest,
225                    (account, itemsResponse) -> {
226                        if (itemsResponse.getType() == IqPacket.TYPE.RESULT) {
227                            final List<Jid> items = IqParser.items(itemsResponse);
228                            for (Jid item : items) {
229                                IqPacket infoRequest =
230                                        service.getIqGenerator().queryDiscoInfo(item);
231                                queriesInFlight.incrementAndGet();
232                                service.sendIqPacket(
233                                        account,
234                                        infoRequest,
235                                        new OnIqPacketReceived() {
236                                            @Override
237                                            public void onIqPacketReceived(
238                                                    Account account, IqPacket infoResponse) {
239                                                if (infoResponse.getType()
240                                                        == IqPacket.TYPE.RESULT) {
241                                                    final Room room =
242                                                            IqParser.parseRoom(infoResponse);
243                                                    if (room != null) {
244                                                        rooms.add(room);
245                                                    }
246                                                    if (queriesInFlight.decrementAndGet() <= 0) {
247                                                        finishDiscoSearch(rooms, query, listener);
248                                                    }
249                                                } else {
250                                                    queriesInFlight.decrementAndGet();
251                                                }
252                                            }
253                                        });
254                            }
255                        }
256                        if (queriesInFlight.decrementAndGet() <= 0) {
257                            finishDiscoSearch(rooms, query, listener);
258                        }
259                    });
260        }
261    }
262
263    private void finishDiscoSearch(
264            List<Room> rooms, String query, OnChannelSearchResultsFound listener) {
265        Collections.sort(rooms);
266        cache.put(key(Method.LOCAL_SERVER, ""), 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, 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, String query) {
306        return String.format("%s\00%s", method, query);
307    }
308
309    private static void logError(final Response response) {
310        final ResponseBody errorBody = response.errorBody();
311        Log.d(Config.LOGTAG, "code from muclumbus=" + response.code());
312        if (errorBody == null) {
313            return;
314        }
315        try {
316            Log.d(Config.LOGTAG, "error body=" + errorBody.string());
317        } catch (IOException e) {
318            // ignored
319        }
320    }
321
322    public interface OnChannelSearchResultsFound {
323        void onChannelSearchResultsFound(List<Room> results);
324    }
325
326    public enum Method {
327        JABBER_NETWORK,
328        LOCAL_SERVER
329    }
330}