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