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