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