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}