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}