1package eu.siacs.conversations.services;
2
3
4import android.content.SharedPreferences;
5import android.net.Uri;
6import android.os.SystemClock;
7import android.preference.PreferenceManager;
8import android.util.Log;
9
10import java.io.BufferedWriter;
11import java.io.OutputStream;
12import java.io.OutputStreamWriter;
13import java.net.ConnectException;
14import java.net.HttpURLConnection;
15import java.net.URL;
16import java.net.UnknownHostException;
17import java.security.SecureRandom;
18import java.util.ArrayList;
19import java.util.Collection;
20import java.util.Collections;
21import java.util.List;
22import java.util.Locale;
23import java.util.Map;
24import java.util.Set;
25import java.util.UUID;
26import java.util.WeakHashMap;
27import java.util.concurrent.CountDownLatch;
28import java.util.concurrent.TimeUnit;
29import java.util.concurrent.atomic.AtomicBoolean;
30import java.util.concurrent.atomic.AtomicInteger;
31
32import javax.net.ssl.SSLHandshakeException;
33
34import eu.siacs.conversations.Config;
35import eu.siacs.conversations.android.JabberIdContact;
36import eu.siacs.conversations.android.PhoneNumberContact;
37import eu.siacs.conversations.crypto.sasl.Plain;
38import eu.siacs.conversations.entities.Account;
39import eu.siacs.conversations.entities.Contact;
40import eu.siacs.conversations.entities.Entry;
41import eu.siacs.conversations.utils.AccountUtils;
42import eu.siacs.conversations.utils.CryptoHelper;
43import eu.siacs.conversations.utils.PhoneNumberUtilWrapper;
44import eu.siacs.conversations.utils.SerialSingleThreadExecutor;
45import eu.siacs.conversations.xml.Element;
46import eu.siacs.conversations.xml.Namespace;
47import eu.siacs.conversations.xmpp.OnIqPacketReceived;
48import eu.siacs.conversations.xmpp.XmppConnection;
49import eu.siacs.conversations.xmpp.stanzas.IqPacket;
50import io.michaelrocks.libphonenumber.android.Phonenumber;
51import rocks.xmpp.addr.Jid;
52
53public class QuickConversationsService extends AbstractQuickConversationsService {
54
55
56 public static final int API_ERROR_OTHER = -1;
57 public static final int API_ERROR_UNKNOWN_HOST = -2;
58 public static final int API_ERROR_CONNECT = -3;
59 public static final int API_ERROR_SSL_HANDSHAKE = -4;
60 public static final int API_ERROR_AIRPLANE_MODE = -5;
61
62 private static final String API_DOMAIN = "api." + Config.QUICKSY_DOMAIN;
63
64 private static final String BASE_URL = "https://" + API_DOMAIN;
65
66 private static final String INSTALLATION_ID = "eu.siacs.conversations.installation-id";
67
68 private final Set<OnVerificationRequested> mOnVerificationRequested = Collections.newSetFromMap(new WeakHashMap<>());
69 private final Set<OnVerification> mOnVerification = Collections.newSetFromMap(new WeakHashMap<>());
70
71 private final AtomicBoolean mVerificationInProgress = new AtomicBoolean(false);
72 private final AtomicBoolean mVerificationRequestInProgress = new AtomicBoolean(false);
73 private final AtomicInteger mRunningSyncJobs = new AtomicInteger(0);
74 private CountDownLatch awaitingAccountStateChange;
75
76 private Attempt mLastSyncAttempt = Attempt.NULL;
77
78 private final SerialSingleThreadExecutor mSerialSingleThreadExecutor = new SerialSingleThreadExecutor(QuickConversationsService.class.getSimpleName());
79
80 QuickConversationsService(XmppConnectionService xmppConnectionService) {
81 super(xmppConnectionService);
82 }
83
84 private static long retryAfter(HttpURLConnection connection) {
85 try {
86 return SystemClock.elapsedRealtime() + (Long.parseLong(connection.getHeaderField("Retry-After")) * 1000L);
87 } catch (Exception e) {
88 return 0;
89 }
90 }
91
92 public void addOnVerificationRequestedListener(OnVerificationRequested onVerificationRequested) {
93 synchronized (mOnVerificationRequested) {
94 mOnVerificationRequested.add(onVerificationRequested);
95 }
96 }
97
98 public void removeOnVerificationRequestedListener(OnVerificationRequested onVerificationRequested) {
99 synchronized (mOnVerificationRequested) {
100 mOnVerificationRequested.remove(onVerificationRequested);
101 }
102 }
103
104 public void addOnVerificationListener(OnVerification onVerification) {
105 synchronized (mOnVerification) {
106 mOnVerification.add(onVerification);
107 }
108 }
109
110 public void removeOnVerificationListener(OnVerification onVerification) {
111 synchronized (mOnVerification) {
112 mOnVerification.remove(onVerification);
113 }
114 }
115
116 public void requestVerification(Phonenumber.PhoneNumber phoneNumber) {
117 final String e164 = PhoneNumberUtilWrapper.normalize(service, phoneNumber);
118 if (mVerificationRequestInProgress.compareAndSet(false, true)) {
119 new Thread(() -> {
120 try {
121 final URL url = new URL(BASE_URL + "/authentication/" + e164);
122 HttpURLConnection connection = (HttpURLConnection) url.openConnection();
123 connection.setConnectTimeout(Config.SOCKET_TIMEOUT * 1000);
124 connection.setReadTimeout(Config.SOCKET_TIMEOUT * 1000);
125 setHeader(connection);
126 final int code = connection.getResponseCode();
127 if (code == 200) {
128 createAccountAndWait(phoneNumber, 0L);
129 } else if (code == 429) {
130 createAccountAndWait(phoneNumber, retryAfter(connection));
131 } else {
132 synchronized (mOnVerificationRequested) {
133 for (OnVerificationRequested onVerificationRequested : mOnVerificationRequested) {
134 onVerificationRequested.onVerificationRequestFailed(code);
135 }
136 }
137 }
138 } catch (Exception e) {
139 final int code = getApiErrorCode(e);
140 synchronized (mOnVerificationRequested) {
141 for (OnVerificationRequested onVerificationRequested : mOnVerificationRequested) {
142 onVerificationRequested.onVerificationRequestFailed(code);
143 }
144 }
145 } finally {
146 mVerificationRequestInProgress.set(false);
147 }
148 }).start();
149 }
150
151
152 }
153
154 public void signalAccountStateChange() {
155 if (awaitingAccountStateChange != null && awaitingAccountStateChange.getCount() > 0) {
156 Log.d(Config.LOGTAG, "signaled state change");
157 awaitingAccountStateChange.countDown();
158 }
159 }
160
161 private void createAccountAndWait(Phonenumber.PhoneNumber phoneNumber, final long timestamp) {
162 String local = PhoneNumberUtilWrapper.normalize(service, phoneNumber);
163 Log.d(Config.LOGTAG, "requesting verification for " + PhoneNumberUtilWrapper.normalize(service, phoneNumber));
164 Jid jid = Jid.of(local, Config.QUICKSY_DOMAIN, null);
165 Account account = AccountUtils.getFirst(service);
166 if (account == null || !account.getJid().asBareJid().equals(jid.asBareJid())) {
167 if (account != null) {
168 service.deleteAccount(account);
169 }
170 account = new Account(jid, CryptoHelper.createPassword(new SecureRandom()));
171 account.setOption(Account.OPTION_DISABLED, true);
172 account.setOption(Account.OPTION_MAGIC_CREATE, true);
173 account.setOption(Account.OPTION_UNVERIFIED, true);
174 service.createAccount(account);
175 }
176 synchronized (mOnVerificationRequested) {
177 for (OnVerificationRequested onVerificationRequested : mOnVerificationRequested) {
178 if (timestamp <= 0) {
179 onVerificationRequested.onVerificationRequested();
180 } else {
181 onVerificationRequested.onVerificationRequestedRetryAt(timestamp);
182 }
183 }
184 }
185 }
186
187 public void verify(final Account account, String pin) {
188 if (mVerificationInProgress.compareAndSet(false, true)) {
189 new Thread(() -> {
190 try {
191 final URL url = new URL(BASE_URL + "/password");
192 final HttpURLConnection connection = (HttpURLConnection) url.openConnection();
193 connection.setConnectTimeout(Config.SOCKET_TIMEOUT * 1000);
194 connection.setReadTimeout(Config.SOCKET_TIMEOUT * 1000);
195 connection.setRequestMethod("POST");
196 connection.setRequestProperty("Authorization", Plain.getMessage(account.getUsername(), pin));
197 setHeader(connection);
198 final OutputStream os = connection.getOutputStream();
199 final BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(os, "UTF-8"));
200 writer.write(account.getPassword());
201 writer.flush();
202 writer.close();
203 os.close();
204 connection.connect();
205 final int code = connection.getResponseCode();
206 if (code == 200 || code == 201) {
207 account.setOption(Account.OPTION_UNVERIFIED, false);
208 account.setOption(Account.OPTION_DISABLED, false);
209 awaitingAccountStateChange = new CountDownLatch(1);
210 service.updateAccount(account);
211 try {
212 awaitingAccountStateChange.await(5, TimeUnit.SECONDS);
213 } catch (InterruptedException e) {
214 Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": timer expired while waiting for account to connect");
215 }
216 synchronized (mOnVerification) {
217 for (OnVerification onVerification : mOnVerification) {
218 onVerification.onVerificationSucceeded();
219 }
220 }
221 } else if (code == 429) {
222 final long retryAfter = retryAfter(connection);
223 synchronized (mOnVerification) {
224 for (OnVerification onVerification : mOnVerification) {
225 onVerification.onVerificationRetryAt(retryAfter);
226 }
227 }
228 } else {
229 synchronized (mOnVerification) {
230 for (OnVerification onVerification : mOnVerification) {
231 onVerification.onVerificationFailed(code);
232 }
233 }
234 }
235 } catch (Exception e) {
236 final int code = getApiErrorCode(e);
237 synchronized (mOnVerification) {
238 for (OnVerification onVerification : mOnVerification) {
239 onVerification.onVerificationFailed(code);
240 }
241 }
242 } finally {
243 mVerificationInProgress.set(false);
244 }
245 }).start();
246 }
247 }
248
249 private void setHeader(HttpURLConnection connection) {
250 connection.setRequestProperty("User-Agent", service.getIqGenerator().getUserAgent());
251 connection.setRequestProperty("Installation-Id", getInstallationId());
252 connection.setRequestProperty("Accept-Language", Locale.getDefault().getLanguage());
253 }
254
255 private String getInstallationId() {
256 SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(service);
257 String id = preferences.getString(INSTALLATION_ID, null);
258 if (id != null) {
259 return id;
260 } else {
261 id = UUID.randomUUID().toString();
262 preferences.edit().putString(INSTALLATION_ID, id).apply();
263 return id;
264 }
265
266 }
267
268 private int getApiErrorCode(Exception e) {
269 if (!service.hasInternetConnection()) {
270 return API_ERROR_AIRPLANE_MODE;
271 } else if (e instanceof UnknownHostException) {
272 return API_ERROR_UNKNOWN_HOST;
273 } else if (e instanceof ConnectException) {
274 return API_ERROR_CONNECT;
275 } else if (e instanceof SSLHandshakeException) {
276 return API_ERROR_SSL_HANDSHAKE;
277 } else {
278 Log.d(Config.LOGTAG, e.getClass().getName());
279 return API_ERROR_OTHER;
280 }
281 }
282
283 public boolean isVerifying() {
284 return mVerificationInProgress.get();
285 }
286
287 public boolean isRequestingVerification() {
288 return mVerificationRequestInProgress.get();
289 }
290
291
292 @Override
293 public boolean isSynchronizing() {
294 return mRunningSyncJobs.get() > 0;
295 }
296
297 @Override
298 public void considerSync() {
299 considerSync(false);
300 }
301
302 @Override
303 public void considerSyncBackground(final boolean forced) {
304 mRunningSyncJobs.incrementAndGet();
305 mSerialSingleThreadExecutor.execute(() -> {
306 considerSync(forced);
307 if (mRunningSyncJobs.decrementAndGet() == 0) {
308 service.updateRosterUi();
309 }
310 });
311 }
312
313
314 private void considerSync(boolean forced) {
315 Map<String, PhoneNumberContact> contacts = PhoneNumberContact.load(service);
316 for (Account account : service.getAccounts()) {
317 refresh(account, contacts.values());
318 if (!considerSync(account, contacts, forced)) {
319 service.syncRoster(account);
320 }
321 }
322 }
323
324 private void refresh(Account account, Collection<PhoneNumberContact> contacts) {
325 for (Contact contact : account.getRoster().getWithSystemAccounts(PhoneNumberContact.class)) {
326 final Uri uri = contact.getSystemAccount();
327 if (uri == null) {
328 continue;
329 }
330 PhoneNumberContact phoneNumberContact = PhoneNumberContact.findByUri(contacts, uri);
331 final boolean needsCacheClean;
332 if (phoneNumberContact != null) {
333 needsCacheClean = contact.setPhoneContact(phoneNumberContact);
334 } else {
335 needsCacheClean = contact.unsetPhoneContact(PhoneNumberContact.class);
336 Log.d(Config.LOGTAG, uri.toString() + " vanished from address book");
337 }
338 if (needsCacheClean) {
339 service.getAvatarService().clear(contact);
340 }
341 }
342 }
343
344 private boolean considerSync(Account account, final Map<String, PhoneNumberContact> contacts, final boolean forced) {
345 final int hash = contacts.keySet().hashCode();
346 Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": consider sync of " + hash);
347 if (!mLastSyncAttempt.retry(hash) && !forced) {
348 Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": do not attempt sync");
349 return false;
350 }
351 mRunningSyncJobs.incrementAndGet();
352 final Jid syncServer = Jid.of(API_DOMAIN);
353 Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": sending phone list to " + syncServer);
354 List<Element> entries = new ArrayList<>();
355 for (PhoneNumberContact c : contacts.values()) {
356 entries.add(new Element("entry").setAttribute("number", c.getPhoneNumber()));
357 }
358 IqPacket query = new IqPacket(IqPacket.TYPE.GET);
359 query.setTo(syncServer);
360 Element book = new Element("phone-book", Namespace.SYNCHRONIZATION).setChildren(entries);
361 String statusQuo = Entry.statusQuo(contacts.values(), account.getRoster().getWithSystemAccounts(PhoneNumberContact.class));
362 book.setAttribute("ver", statusQuo);
363 query.addChild(book);
364 mLastSyncAttempt = Attempt.create(hash);
365 service.sendIqPacket(account, query, (a, response) -> {
366 if (response.getType() == IqPacket.TYPE.RESULT) {
367 final Element phoneBook = response.findChild("phone-book", Namespace.SYNCHRONIZATION);
368 if (phoneBook != null) {
369 List<Contact> withSystemAccounts = account.getRoster().getWithSystemAccounts(PhoneNumberContact.class);
370 for (Entry entry : Entry.ofPhoneBook(phoneBook)) {
371 PhoneNumberContact phoneContact = contacts.get(entry.getNumber());
372 if (phoneContact == null) {
373 continue;
374 }
375 for (Jid jid : entry.getJids()) {
376 Contact contact = account.getRoster().getContact(jid);
377 final boolean needsCacheClean = contact.setPhoneContact(phoneContact);
378 if (needsCacheClean) {
379 service.getAvatarService().clear(contact);
380 }
381 withSystemAccounts.remove(contact);
382 }
383 }
384 for (Contact contact : withSystemAccounts) {
385 final boolean needsCacheClean = contact.unsetPhoneContact(PhoneNumberContact.class);
386 if (needsCacheClean) {
387 service.getAvatarService().clear(contact);
388 }
389 }
390 } else {
391 Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": phone number contact list remains unchanged");
392 }
393 } else if (response.getType() == IqPacket.TYPE.TIMEOUT) {
394 mLastSyncAttempt = Attempt.NULL;
395 } else {
396 Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": failed to sync contact list with api server");
397 }
398 mRunningSyncJobs.decrementAndGet();
399 service.syncRoster(account);
400 service.updateRosterUi();
401 });
402 return true;
403 }
404
405
406 public interface OnVerificationRequested {
407 void onVerificationRequestFailed(int code);
408
409 void onVerificationRequested();
410
411 void onVerificationRequestedRetryAt(long timestamp);
412 }
413
414 public interface OnVerification {
415 void onVerificationFailed(int code);
416
417 void onVerificationSucceeded();
418
419 void onVerificationRetryAt(long timestamp);
420 }
421
422 private static class Attempt {
423 private final long timestamp;
424 private int hash;
425
426 private static final Attempt NULL = new Attempt(0, 0);
427
428 private Attempt(long timestamp, int hash) {
429 this.timestamp = timestamp;
430 this.hash = hash;
431 }
432
433 public static Attempt create(int hash) {
434 return new Attempt(SystemClock.elapsedRealtime(), hash);
435 }
436
437 public boolean retry(int hash) {
438 return hash != this.hash || SystemClock.elapsedRealtime() - timestamp >= Config.CONTACT_SYNC_RETRY_INTERVAL;
439 }
440 }
441}