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