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}