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