1package eu.siacs.conversations.xmpp.manager;
2
3import android.util.Log;
4import androidx.annotation.NonNull;
5import androidx.annotation.Nullable;
6import com.google.common.collect.Collections2;
7import com.google.common.collect.ImmutableList;
8import com.google.common.util.concurrent.FutureCallback;
9import com.google.common.util.concurrent.Futures;
10import com.google.common.util.concurrent.MoreExecutors;
11import eu.siacs.conversations.Config;
12import eu.siacs.conversations.android.AbstractPhoneContact;
13import eu.siacs.conversations.entities.Contact;
14import eu.siacs.conversations.entities.Roster;
15import eu.siacs.conversations.services.XmppConnectionService;
16import eu.siacs.conversations.utils.ReplacingSerialSingleThreadExecutor;
17import eu.siacs.conversations.xmpp.Jid;
18import eu.siacs.conversations.xmpp.XmppConnection;
19import im.conversations.android.xmpp.model.error.Condition;
20import im.conversations.android.xmpp.model.error.Error;
21import im.conversations.android.xmpp.model.roster.Item;
22import im.conversations.android.xmpp.model.roster.Query;
23import im.conversations.android.xmpp.model.stanza.Iq;
24import java.util.Collection;
25import java.util.HashMap;
26import java.util.List;
27import java.util.Map;
28import java.util.Objects;
29
30public class RosterManager extends AbstractManager implements Roster {
31
32 private final ReplacingSerialSingleThreadExecutor dbExecutor =
33 new ReplacingSerialSingleThreadExecutor(RosterManager.class.getName());
34
35 private final Map<Jid, Contact> contacts = new HashMap<>();
36 private String version;
37
38 private final XmppConnectionService service;
39
40 public RosterManager(final XmppConnectionService service, final XmppConnection connection) {
41 super(service, connection);
42 this.version = getAccount().getRosterVersion();
43 ;
44 this.service = service;
45 }
46
47 public void request() {
48 final var iq = new Iq(Iq.Type.GET);
49 final var query = iq.addExtension(new Query());
50 final var version = this.version;
51 if (version != null) {
52 Log.d(
53 Config.LOGTAG,
54 getAccount().getJid().asBareJid() + ": requesting roster version " + version);
55 query.setVersion(version);
56 } else {
57 Log.d(Config.LOGTAG, getAccount().getJid().asBareJid() + " requesting roster");
58 }
59 final var future = connection.sendIqPacket(iq);
60 Futures.addCallback(
61 future,
62 new FutureCallback<>() {
63 @Override
64 public void onSuccess(final Iq result) {
65 final var query = result.getExtension(Query.class);
66 if (query == null) {
67 // No query in result means further modifications are sent via pushes
68 return;
69 }
70 final var version = query.getVersion();
71 Log.d(
72 Config.LOGTAG,
73 getAccount().getJid().asBareJid()
74 + ": received full roster (version="
75 + version
76 + ")");
77 final var items = query.getItems();
78 // In a roster result (Section 2.1.4), the client MUST ignore values of the
79 // 'subscription'
80 // attribute other than "none", "to", "from", or "both".
81 final var validItems =
82 Collections2.filter(
83 items,
84 i ->
85 Item.RESULT_SUBSCRIPTIONS.contains(
86 i.getSubscription())
87 && Objects.nonNull(i.getJid()));
88
89 setRosterItems(version, validItems);
90 }
91
92 @Override
93 public void onFailure(@NonNull final Throwable throwable) {
94 Log.d(
95 Config.LOGTAG,
96 getAccount().getJid().asBareJid() + ": could not fetch roster",
97 throwable);
98 }
99 },
100 MoreExecutors.directExecutor());
101 }
102
103 private void setRosterItems(final String version, final Collection<Item> items) {
104 synchronized (this.contacts) {
105 markAllAsNotInRoster();
106 for (final var item : items) {
107 processRosterItem(item);
108 }
109 this.version = version;
110 }
111 this.triggerUiUpdates();
112 this.writeToDatabaseAsync();
113 }
114
115 private void modifyRosterItems(final String version, final Collection<Item> items) {
116 synchronized (this.contacts) {
117 for (final var item : items) {
118 processRosterItem(item);
119 }
120 this.version = version;
121 }
122 this.triggerUiUpdates();
123 this.writeToDatabaseAsync();
124 }
125
126 private void triggerUiUpdates() {
127 this.service.updateConversationUi();
128 this.service.updateRosterUi(XmppConnectionService.UpdateRosterReason.PUSH);
129 this.service.getShortcutService().refresh();
130 }
131
132 public void push(final Iq packet) {
133 if (connection.fromServer(packet)) {
134 final var query = packet.getExtension(Query.class);
135 final var version = query.getVersion();
136 modifyRosterItems(version, query.getItems());
137 Log.d(
138 Config.LOGTAG,
139 getAccount().getJid() + ": received roster push (version=" + version + ")");
140 } else {
141 connection.sendErrorFor(packet, Error.Type.AUTH, new Condition.Forbidden());
142 }
143 }
144
145 private void processRosterItem(final Item item) {
146 // this is verbatim the original code from IqParser.
147 // TODO there are likely better ways to handle roster management
148 final Jid jid = Jid.Invalid.getNullForInvalid(item.getAttributeAsJid("jid"));
149 if (jid == null) {
150 return;
151 }
152 final var name = item.getItemName();
153 final var subscription = item.getSubscription();
154 // getContactInternal is not synchronized because all access to processRosterItem is
155 final var contact = getContactInternal(jid);
156 boolean bothPre =
157 contact.getOption(Contact.Options.TO) && contact.getOption(Contact.Options.FROM);
158 if (!contact.getOption(Contact.Options.DIRTY_PUSH)) {
159 contact.setServerName(name);
160 contact.parseGroupsFromElement(item);
161 }
162 if (subscription == Item.Subscription.REMOVE) {
163 contact.resetOption(Contact.Options.IN_ROSTER);
164 contact.resetOption(Contact.Options.DIRTY_DELETE);
165 contact.resetOption(Contact.Options.PREEMPTIVE_GRANT);
166 } else {
167 contact.setOption(Contact.Options.IN_ROSTER);
168 contact.resetOption(Contact.Options.DIRTY_PUSH);
169 // TODO use subscription; and set asking separately
170 contact.parseSubscriptionFromElement(item);
171 }
172 boolean both =
173 contact.getOption(Contact.Options.TO) && contact.getOption(Contact.Options.FROM);
174 if ((both != bothPre) && both) {
175 final var account = getAccount();
176 Log.d(
177 Config.LOGTAG,
178 account.getJid().asBareJid()
179 + ": gained mutual presence subscription with "
180 + contact.getJid());
181 final var axolotlService = account.getAxolotlService();
182 if (axolotlService != null) {
183 axolotlService.clearErrorsInFetchStatusMap(contact.getJid());
184 }
185 }
186 service.getAvatarService().clear(contact);
187 }
188
189 @Override
190 @NonNull
191 public Contact getContact(@NonNull final Jid jid) {
192 synchronized (this.contacts) {
193 return this.getContactInternal(jid);
194 }
195 }
196
197 @NonNull
198 public Contact getContactInternal(@NonNull final Jid jid) {
199 final var existing = this.contacts.get(jid.asBareJid());
200 if (existing != null) {
201 return existing;
202 }
203 final var contact = new Contact(jid.asBareJid());
204 contact.setAccount(getAccount());
205 this.contacts.put(jid.asBareJid(), contact);
206 return contact;
207 }
208
209 @Override
210 @Nullable
211 public Contact getContactFromContactList(@NonNull final Jid jid) {
212 synchronized (this.contacts) {
213 final var contact = this.contacts.get(jid.asBareJid());
214 if (contact != null && contact.showInContactList()) {
215 return contact;
216 } else {
217 return null;
218 }
219 }
220 }
221
222 @Override
223 public List<Contact> getContacts() {
224 synchronized (this.contacts) {
225 return ImmutableList.copyOf(this.contacts.values());
226 }
227 }
228
229 @Override
230 public ImmutableList<Contact> getWithSystemAccounts(
231 final Class<? extends AbstractPhoneContact> clazz) {
232 final int option = Contact.getOption(clazz);
233 synchronized (this.contacts) {
234 return ImmutableList.copyOf(
235 Collections2.filter(this.contacts.values(), c -> c.getOption(option)));
236 }
237 }
238
239 public void clearPresences() {
240 synchronized (this.contacts) {
241 for (final var contact : this.contacts.values()) {
242 contact.clearPresences();
243 }
244 }
245 }
246
247 private void markAllAsNotInRoster() {
248 for (final var contact : this.contacts.values()) {
249 contact.resetOption(Contact.Options.IN_ROSTER);
250 }
251 }
252
253 public void restore() {
254 synchronized (this.contacts) {
255 this.contacts.clear();
256 this.contacts.putAll(getDatabase().readRoster(getAccount()));
257 }
258 }
259
260 public void writeToDatabaseAsync() {
261 this.dbExecutor.execute(this::writeToDatabase);
262 }
263
264 public void writeToDatabase() {
265 final var account = getAccount();
266 final List<Contact> contacts;
267 final String version;
268 synchronized (this.contacts) {
269 contacts = ImmutableList.copyOf(this.contacts.values());
270 version = this.version;
271 }
272 getDatabase().writeRoster(account, version, contacts);
273 context.unregisterPhoneAccounts(account);
274 try { Thread.sleep(500); } catch (InterruptedException e) { }
275 }
276
277 public void syncDirtyContacts() {
278 synchronized (this.contacts) {
279 for (final var contact : this.contacts.values()) {
280 if (contact.getOption(Contact.Options.DIRTY_PUSH)) {
281 addRosterItem(contact, null);
282 }
283 if (contact.getOption(Contact.Options.DIRTY_DELETE)) {
284 deleteRosterItem(contact);
285 }
286 }
287 }
288 }
289
290 public void addRosterItem(final Contact contact, final String preAuth) {
291 final var address = contact.getJid().asBareJid();
292 contact.resetOption(Contact.Options.DIRTY_DELETE);
293 contact.setOption(Contact.Options.DIRTY_PUSH);
294 // sync the 'dirty push' flag to disk in case we are offline
295 this.writeToDatabaseAsync();
296 final boolean ask = contact.getOption(Contact.Options.ASKING);
297 final boolean sendUpdates =
298 contact.getOption(Contact.Options.PENDING_SUBSCRIPTION_REQUEST)
299 && contact.getOption(Contact.Options.PREEMPTIVE_GRANT);
300 final Iq iq = new Iq(Iq.Type.SET);
301 final var query = iq.addExtension(new Query());
302 final var item = query.addExtension(new Item());
303 item.setJid(address);
304 final var serverName = contact.getServerName();
305 if (serverName != null) {
306 item.setItemName(serverName);
307 }
308 item.setGroups(contact.getGroups(false));
309 final var future = this.connection.sendIqPacket(iq);
310 Futures.addCallback(
311 future,
312 new FutureCallback<Iq>() {
313 @Override
314 public void onSuccess(Iq result) {
315 Log.d(
316 Config.LOGTAG,
317 getAccount().getJid().asBareJid()
318 + ": pushed roster item "
319 + address);
320 }
321
322 @Override
323 public void onFailure(@NonNull Throwable t) {
324 Log.d(
325 Config.LOGTAG,
326 getAccount().getJid().asBareJid()
327 + ": could not push roster item "
328 + address,
329 t);
330 }
331 },
332 MoreExecutors.directExecutor());
333 if (sendUpdates) {
334 getManager(PresenceManager.class).subscribed(contact.getJid().asBareJid());
335 }
336 if (ask) {
337 getManager(PresenceManager.class).subscribe(contact.getJid().asBareJid(), preAuth);
338 }
339 }
340
341 public void deleteRosterItem(final Contact contact) {
342 final var address = contact.getJid().asBareJid();
343 contact.resetOption(Contact.Options.PREEMPTIVE_GRANT);
344 contact.resetOption(Contact.Options.DIRTY_PUSH);
345 contact.setOption(Contact.Options.DIRTY_DELETE);
346 this.writeToDatabaseAsync();
347 final Iq iq = new Iq(Iq.Type.SET);
348 final var query = iq.addExtension(new Query());
349 final var item = query.addExtension(new Item());
350 item.setJid(address);
351 item.setSubscription(Item.Subscription.REMOVE);
352 final var future = this.connection.sendIqPacket(iq);
353 Futures.addCallback(
354 future,
355 new FutureCallback<Iq>() {
356 @Override
357 public void onSuccess(final Iq result) {
358 Log.d(
359 Config.LOGTAG,
360 getAccount().getJid().asBareJid()
361 + ": removed roster item "
362 + address);
363 }
364
365 @Override
366 public void onFailure(final @NonNull Throwable t) {
367 Log.d(
368 Config.LOGTAG,
369 getAccount().getJid().asBareJid()
370 + ": could not remove roster item "
371 + address,
372 t);
373 }
374 },
375 MoreExecutors.directExecutor());
376 }
377}