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