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 OnChannelSearchResultsFound onChannelSearchResultsFound) {
82 final List<Room> result = cache.getIfPresent(key(method, query));
83 if (result != null) {
84 onChannelSearchResultsFound.onChannelSearchResultsFound(result);
85 return;
86 }
87 if (method == Method.LOCAL_SERVER) {
88 discoverChannelsLocalServers(query, onChannelSearchResultsFound);
89 } else {
90 if (query.isEmpty()) {
91 discoverChannelsJabberNetwork(onChannelSearchResultsFound);
92 } else {
93 discoverChannelsJabberNetwork(query, onChannelSearchResultsFound);
94 }
95 }
96 }
97
98 private void discoverChannelsJabberNetwork(final OnChannelSearchResultsFound listener) {
99 if (muclumbusService == null) {
100 listener.onChannelSearchResultsFound(Collections.emptyList());
101 return;
102 }
103 final Call<MuclumbusService.Rooms> call = muclumbusService.getRooms(1);
104 call.enqueue(
105 new Callback<MuclumbusService.Rooms>() {
106 @Override
107 public void onResponse(
108 @NonNull Call<MuclumbusService.Rooms> call,
109 @NonNull Response<MuclumbusService.Rooms> response) {
110 final MuclumbusService.Rooms body = response.body();
111 if (body == null) {
112 listener.onChannelSearchResultsFound(Collections.emptyList());
113 logError(response);
114 return;
115 }
116 cache.put(key(Method.JABBER_NETWORK, ""), body.items);
117 listener.onChannelSearchResultsFound(body.items);
118 }
119
120 @Override
121 public void onFailure(
122 @NonNull Call<MuclumbusService.Rooms> call,
123 @NonNull Throwable throwable) {
124 Log.d(
125 Config.LOGTAG,
126 "Unable to query muclumbus on " + Config.CHANNEL_DISCOVERY,
127 throwable);
128 listener.onChannelSearchResultsFound(Collections.emptyList());
129 }
130 });
131 }
132
133 private void discoverChannelsJabberNetwork(
134 final String query, final OnChannelSearchResultsFound listener) {
135 if (muclumbusService == null) {
136 listener.onChannelSearchResultsFound(Collections.emptyList());
137 return;
138 }
139 final MuclumbusService.SearchRequest searchRequest =
140 new MuclumbusService.SearchRequest(query);
141 final Call<MuclumbusService.SearchResult> searchResultCall =
142 muclumbusService.search(searchRequest);
143 searchResultCall.enqueue(
144 new Callback<MuclumbusService.SearchResult>() {
145 @Override
146 public void onResponse(
147 @NonNull Call<MuclumbusService.SearchResult> call,
148 @NonNull Response<MuclumbusService.SearchResult> response) {
149 final MuclumbusService.SearchResult body = response.body();
150 if (body == null) {
151 listener.onChannelSearchResultsFound(Collections.emptyList());
152 logError(response);
153 return;
154 }
155 cache.put(key(Method.JABBER_NETWORK, query), body.result.items);
156 listener.onChannelSearchResultsFound(body.result.items);
157 }
158
159 @Override
160 public void onFailure(
161 @NonNull Call<MuclumbusService.SearchResult> call,
162 @NonNull Throwable throwable) {
163 Log.d(
164 Config.LOGTAG,
165 "Unable to query muclumbus on " + Config.CHANNEL_DISCOVERY,
166 throwable);
167 listener.onChannelSearchResultsFound(Collections.emptyList());
168 }
169 });
170 }
171
172 private void discoverChannelsLocalServers(
173 final String query, final OnChannelSearchResultsFound listener) {
174 final Map<Jid, Account> localMucService = getLocalMucServices();
175 Log.d(Config.LOGTAG, "checking with " + localMucService.size() + " muc services");
176 if (localMucService.isEmpty()) {
177 listener.onChannelSearchResultsFound(Collections.emptyList());
178 return;
179 }
180 if (!query.isEmpty()) {
181 final List<Room> cached = cache.getIfPresent(key(Method.LOCAL_SERVER, ""));
182 if (cached != null) {
183 final List<Room> results = copyMatching(cached, query);
184 cache.put(key(Method.LOCAL_SERVER, query), results);
185 listener.onChannelSearchResultsFound(results);
186 }
187 }
188 final AtomicInteger queriesInFlight = new AtomicInteger();
189 final List<Room> rooms = new ArrayList<>();
190 for (final Map.Entry<Jid, Account> entry : localMucService.entrySet()) {
191 Iq itemsRequest = service.getIqGenerator().queryDiscoItems(entry.getKey());
192 queriesInFlight.incrementAndGet();
193 final var account = entry.getValue();
194 service.sendIqPacket(
195 account,
196 itemsRequest,
197 (itemsResponse) -> {
198 if (itemsResponse.getType() == Iq.Type.RESULT) {
199 final List<Jid> items = IqParser.items(itemsResponse);
200 for (final Jid item : items) {
201 final Iq infoRequest =
202 service.getIqGenerator().queryDiscoInfo(item);
203 queriesInFlight.incrementAndGet();
204 service.sendIqPacket(
205 account,
206 infoRequest,
207 infoResponse -> {
208 if (infoResponse.getType()
209 == Iq.Type.RESULT) {
210 final Room room =
211 IqParser.parseRoom(infoResponse);
212 if (room != null) {
213 rooms.add(room);
214 }
215 if (queriesInFlight.decrementAndGet() <= 0) {
216 finishDiscoSearch(rooms, query, listener);
217 }
218 } else {
219 queriesInFlight.decrementAndGet();
220 }
221 });
222 }
223 }
224 if (queriesInFlight.decrementAndGet() <= 0) {
225 finishDiscoSearch(rooms, query, listener);
226 }
227 });
228 }
229 }
230
231 private void finishDiscoSearch(
232 List<Room> rooms, String query, OnChannelSearchResultsFound listener) {
233 Collections.sort(rooms);
234 cache.put(key(Method.LOCAL_SERVER, ""), rooms);
235 if (query.isEmpty()) {
236 listener.onChannelSearchResultsFound(rooms);
237 } else {
238 List<Room> results = copyMatching(rooms, query);
239 cache.put(key(Method.LOCAL_SERVER, query), results);
240 listener.onChannelSearchResultsFound(rooms);
241 }
242 }
243
244 private static List<Room> copyMatching(List<Room> haystack, String needle) {
245 ArrayList<Room> result = new ArrayList<>();
246 for (Room room : haystack) {
247 if (room.contains(needle)) {
248 result.add(room);
249 }
250 }
251 return result;
252 }
253
254 private Map<Jid, Account> getLocalMucServices() {
255 final HashMap<Jid, Account> localMucServices = new HashMap<>();
256 for (Account account : service.getAccounts()) {
257 if (account.isEnabled()) {
258 final XmppConnection xmppConnection = account.getXmppConnection();
259 if (xmppConnection == null) {
260 continue;
261 }
262 for (final String mucService : xmppConnection.getMucServers()) {
263 Jid jid = Jid.ofEscaped(mucService);
264 if (!localMucServices.containsKey(jid)) {
265 localMucServices.put(jid, account);
266 }
267 }
268 }
269 }
270 return localMucServices;
271 }
272
273 private static String key(Method method, String query) {
274 return String.format("%s\00%s", method, 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}