1package eu.siacs.conversations.services;
2
3import static eu.siacs.conversations.utils.Random.SECURE_RANDOM;
4
5import android.content.Context;
6import android.content.Intent;
7import android.net.Uri;
8import android.os.Bundle;
9import android.os.SystemClock;
10import android.util.Log;
11import com.google.common.collect.ImmutableMap;
12import de.gultsch.common.TrustManagers;
13import eu.siacs.conversations.Config;
14import eu.siacs.conversations.android.PhoneNumberContact;
15import eu.siacs.conversations.crypto.sasl.Plain;
16import eu.siacs.conversations.entities.Account;
17import eu.siacs.conversations.entities.Contact;
18import eu.siacs.conversations.entities.Entry;
19import eu.siacs.conversations.http.HttpConnectionManager;
20import eu.siacs.conversations.utils.AccountUtils;
21import eu.siacs.conversations.utils.CryptoHelper;
22import eu.siacs.conversations.utils.PhoneNumberUtilWrapper;
23import eu.siacs.conversations.utils.SerialSingleThreadExecutor;
24import eu.siacs.conversations.utils.SmsRetrieverWrapper;
25import eu.siacs.conversations.utils.TLSSocketFactory;
26import eu.siacs.conversations.xml.Element;
27import eu.siacs.conversations.xml.Namespace;
28import eu.siacs.conversations.xmpp.Jid;
29import eu.siacs.conversations.xmpp.manager.RosterManager;
30import im.conversations.android.xmpp.model.stanza.Iq;
31import io.michaelrocks.libphonenumber.android.Phonenumber;
32import java.io.BufferedWriter;
33import java.io.IOException;
34import java.io.OutputStream;
35import java.io.OutputStreamWriter;
36import java.net.ConnectException;
37import java.net.HttpURLConnection;
38import java.net.SocketTimeoutException;
39import java.net.URL;
40import java.net.UnknownHostException;
41import java.security.GeneralSecurityException;
42import java.security.KeyManagementException;
43import java.security.KeyStoreException;
44import java.security.NoSuchAlgorithmException;
45import java.security.SecureRandom;
46import java.security.cert.CertificateException;
47import java.util.ArrayList;
48import java.util.Collection;
49import java.util.Collections;
50import java.util.HashMap;
51import java.util.List;
52import java.util.Locale;
53import java.util.Map;
54import java.util.Set;
55import java.util.WeakHashMap;
56import java.util.concurrent.CountDownLatch;
57import java.util.concurrent.TimeUnit;
58import java.util.concurrent.atomic.AtomicBoolean;
59import java.util.concurrent.atomic.AtomicInteger;
60import javax.net.ssl.HttpsURLConnection;
61import javax.net.ssl.SSLException;
62import javax.net.ssl.SSLHandshakeException;
63import javax.net.ssl.SSLPeerUnverifiedException;
64import javax.net.ssl.SSLSocketFactory;
65import javax.net.ssl.X509TrustManager;
66
67public class QuickConversationsService extends AbstractQuickConversationsService {
68
69 public static final int API_ERROR_OTHER = -1;
70 public static final int API_ERROR_UNKNOWN_HOST = -2;
71 public static final int API_ERROR_CONNECT = -3;
72 public static final int API_ERROR_SSL_HANDSHAKE = -4;
73 public static final int API_ERROR_AIRPLANE_MODE = -5;
74 public static final int API_ERROR_SSL_CERTIFICATE = -6;
75 public static final int API_ERROR_SSL_GENERAL = -7;
76 public static final int API_ERROR_TIMEOUT = -8;
77
78 private static final String API_DOMAIN = "api." + Config.QUICKSY_DOMAIN;
79
80 private static final String BASE_URL = "https://" + API_DOMAIN;
81
82 private final Set<OnVerificationRequested> mOnVerificationRequested =
83 Collections.newSetFromMap(new WeakHashMap<>());
84 private final Set<OnVerification> mOnVerification =
85 Collections.newSetFromMap(new WeakHashMap<>());
86
87 private final AtomicBoolean mVerificationInProgress = new AtomicBoolean(false);
88 private final AtomicBoolean mVerificationRequestInProgress = new AtomicBoolean(false);
89 private final AtomicInteger mRunningSyncJobs = new AtomicInteger(0);
90 private CountDownLatch awaitingAccountStateChange;
91
92 private Attempt mLastSyncAttempt = Attempt.NULL;
93
94 private final SerialSingleThreadExecutor mSerialSingleThreadExecutor =
95 new SerialSingleThreadExecutor(QuickConversationsService.class.getSimpleName());
96
97 QuickConversationsService(XmppConnectionService xmppConnectionService) {
98 super(xmppConnectionService);
99 }
100
101 private static long retryAfter(HttpURLConnection connection) {
102 try {
103 return SystemClock.elapsedRealtime()
104 + (Long.parseLong(connection.getHeaderField("Retry-After")) * 1000L);
105 } catch (Exception e) {
106 return 0;
107 }
108 }
109
110 public void addOnVerificationRequestedListener(
111 OnVerificationRequested onVerificationRequested) {
112 synchronized (mOnVerificationRequested) {
113 mOnVerificationRequested.add(onVerificationRequested);
114 }
115 }
116
117 public void removeOnVerificationRequestedListener(
118 OnVerificationRequested onVerificationRequested) {
119 synchronized (mOnVerificationRequested) {
120 mOnVerificationRequested.remove(onVerificationRequested);
121 }
122 }
123
124 public void addOnVerificationListener(OnVerification onVerification) {
125 synchronized (mOnVerification) {
126 mOnVerification.add(onVerification);
127 }
128 }
129
130 public void removeOnVerificationListener(OnVerification onVerification) {
131 synchronized (mOnVerification) {
132 mOnVerification.remove(onVerification);
133 }
134 }
135
136 public void requestVerification(Phonenumber.PhoneNumber phoneNumber) {
137 final String e164 = PhoneNumberUtilWrapper.normalize(service, phoneNumber);
138 if (mVerificationRequestInProgress.compareAndSet(false, true)) {
139 SmsRetrieverWrapper.start(service);
140 new Thread(
141 () -> {
142 try {
143 final URL url = new URL(BASE_URL + "/authentication/" + e164);
144 final HttpURLConnection connection =
145 (HttpURLConnection) url.openConnection();
146 setBundledLetsEncrypt(service, connection);
147 connection.setConnectTimeout(Config.SOCKET_TIMEOUT * 1000);
148 connection.setReadTimeout(Config.SOCKET_TIMEOUT * 1000);
149 setHeader(connection);
150 final int code = connection.getResponseCode();
151 if (code == 200) {
152 createAccountAndWait(phoneNumber, 0L);
153 } else if (code == 429) {
154 createAccountAndWait(phoneNumber, retryAfter(connection));
155 } else {
156 synchronized (mOnVerificationRequested) {
157 for (OnVerificationRequested onVerificationRequested :
158 mOnVerificationRequested) {
159 onVerificationRequested.onVerificationRequestFailed(
160 code);
161 }
162 }
163 }
164 } catch (IOException e) {
165 final int code = getApiErrorCode(e);
166 synchronized (mOnVerificationRequested) {
167 for (OnVerificationRequested onVerificationRequested :
168 mOnVerificationRequested) {
169 onVerificationRequested.onVerificationRequestFailed(
170 code);
171 }
172 }
173 } finally {
174 mVerificationRequestInProgress.set(false);
175 }
176 })
177 .start();
178 }
179 }
180
181 private static void setBundledLetsEncrypt(
182 final Context context, final HttpURLConnection connection) {
183 if (connection instanceof HttpsURLConnection httpsURLConnection) {
184 final SSLSocketFactory socketFactory;
185 try {
186 socketFactory =
187 new TLSSocketFactory(
188 new X509TrustManager[] {
189 TrustManagers.createForAndroidVersion(context)
190 },
191 SECURE_RANDOM);
192 } catch (final KeyManagementException
193 | NoSuchAlgorithmException
194 | KeyStoreException
195 | CertificateException
196 | IOException e) {
197 Log.e(Config.LOGTAG, "could not configured bundled LetsEncrypt", e);
198 return;
199 }
200 httpsURLConnection.setSSLSocketFactory(socketFactory);
201 }
202 }
203
204 public void signalAccountStateChange() {
205 if (awaitingAccountStateChange != null && awaitingAccountStateChange.getCount() > 0) {
206 Log.d(Config.LOGTAG, "signaled state change");
207 awaitingAccountStateChange.countDown();
208 }
209 }
210
211 private void createAccountAndWait(Phonenumber.PhoneNumber phoneNumber, final long timestamp) {
212 String local = PhoneNumberUtilWrapper.normalize(service, phoneNumber);
213 Log.d(
214 Config.LOGTAG,
215 "requesting verification for "
216 + PhoneNumberUtilWrapper.normalize(service, phoneNumber));
217 Jid jid = Jid.of(local, Config.QUICKSY_DOMAIN, null);
218 Account account = AccountUtils.getFirst(service);
219 if (account == null || !account.getJid().asBareJid().equals(jid.asBareJid())) {
220 if (account != null) {
221 service.deleteAccount(account);
222 }
223 account = new Account(jid, CryptoHelper.createPassword(new SecureRandom()));
224 account.setOption(Account.OPTION_DISABLED, true);
225 account.setOption(Account.OPTION_MAGIC_CREATE, true);
226 account.setOption(Account.OPTION_UNVERIFIED, true);
227 service.createAccount(account);
228 }
229 synchronized (mOnVerificationRequested) {
230 for (OnVerificationRequested onVerificationRequested : mOnVerificationRequested) {
231 if (timestamp <= 0) {
232 onVerificationRequested.onVerificationRequested();
233 } else {
234 onVerificationRequested.onVerificationRequestedRetryAt(timestamp);
235 }
236 }
237 }
238 }
239
240 public void verify(final Account account, String pin) {
241 if (mVerificationInProgress.compareAndSet(false, true)) {
242 new Thread(
243 () -> {
244 try {
245 final URL url = new URL(BASE_URL + "/password");
246 final HttpURLConnection connection =
247 (HttpURLConnection) url.openConnection();
248 setBundledLetsEncrypt(service, connection);
249 connection.setConnectTimeout(Config.SOCKET_TIMEOUT * 1000);
250 connection.setReadTimeout(Config.SOCKET_TIMEOUT * 1000);
251 connection.setRequestMethod("POST");
252 connection.setRequestProperty(
253 "Authorization",
254 Plain.getMessage(account.getUsername(), pin));
255 setHeader(connection);
256 final OutputStream os = connection.getOutputStream();
257 final BufferedWriter writer =
258 new BufferedWriter(new OutputStreamWriter(os, "UTF-8"));
259 writer.write(account.getPassword());
260 writer.flush();
261 writer.close();
262 os.close();
263 connection.connect();
264 final int code = connection.getResponseCode();
265 if (code == 200 || code == 201) {
266 account.setOption(Account.OPTION_UNVERIFIED, false);
267 account.setOption(Account.OPTION_DISABLED, false);
268 awaitingAccountStateChange = new CountDownLatch(1);
269 service.updateAccount(account);
270 try {
271 awaitingAccountStateChange.await(5, TimeUnit.SECONDS);
272 } catch (InterruptedException e) {
273 Log.d(
274 Config.LOGTAG,
275 account.getJid().asBareJid()
276 + ": timer expired while waiting for"
277 + " account to connect");
278 }
279 synchronized (mOnVerification) {
280 for (OnVerification onVerification : mOnVerification) {
281 onVerification.onVerificationSucceeded();
282 }
283 }
284 } else if (code == 429) {
285 final long retryAfter = retryAfter(connection);
286 synchronized (mOnVerification) {
287 for (OnVerification onVerification : mOnVerification) {
288 onVerification.onVerificationRetryAt(retryAfter);
289 }
290 }
291 } else {
292 synchronized (mOnVerification) {
293 for (OnVerification onVerification : mOnVerification) {
294 onVerification.onVerificationFailed(code);
295 }
296 }
297 }
298 } catch (IOException e) {
299 final int code = getApiErrorCode(e);
300 synchronized (mOnVerification) {
301 for (OnVerification onVerification : mOnVerification) {
302 onVerification.onVerificationFailed(code);
303 }
304 }
305 } finally {
306 mVerificationInProgress.set(false);
307 }
308 })
309 .start();
310 }
311 }
312
313 private void setHeader(HttpURLConnection connection) {
314 connection.setRequestProperty("User-Agent", HttpConnectionManager.getUserAgent());
315 connection.setRequestProperty("Installation-Id", getInstallationId());
316 connection.setRequestProperty("Accept-Language", Locale.getDefault().getLanguage());
317 }
318
319 private String getInstallationId() {
320 final var appSettings = service.getAppSettings();
321 final long installationId = appSettings.getInstallationId();
322 return AccountUtils.createUuid4(installationId, installationId).toString();
323 }
324
325 private int getApiErrorCode(final Exception e) {
326 if (!service.hasInternetConnection()) {
327 return API_ERROR_AIRPLANE_MODE;
328 } else if (e instanceof UnknownHostException) {
329 return API_ERROR_UNKNOWN_HOST;
330 } else if (e instanceof ConnectException) {
331 return API_ERROR_CONNECT;
332 } else if (e instanceof SSLHandshakeException) {
333 return API_ERROR_SSL_HANDSHAKE;
334 } else if (e instanceof SSLPeerUnverifiedException || e instanceof CertificateException) {
335 return API_ERROR_SSL_CERTIFICATE;
336 } else if (e instanceof SSLException || e instanceof GeneralSecurityException) {
337 return API_ERROR_SSL_GENERAL;
338 } else if (e instanceof SocketTimeoutException) {
339 return API_ERROR_TIMEOUT;
340 } else {
341 Log.d(Config.LOGTAG, e.getClass().getName());
342 return API_ERROR_OTHER;
343 }
344 }
345
346 public boolean isVerifying() {
347 return mVerificationInProgress.get();
348 }
349
350 public boolean isRequestingVerification() {
351 return mVerificationRequestInProgress.get();
352 }
353
354 @Override
355 public boolean isSynchronizing() {
356 return mRunningSyncJobs.get() > 0;
357 }
358
359 @Override
360 public void considerSync() {
361 considerSync(false);
362 }
363
364 @Override
365 public void considerSyncBackground(final boolean forced) {
366 mRunningSyncJobs.incrementAndGet();
367 mSerialSingleThreadExecutor.execute(
368 () -> {
369 considerSync(forced);
370 if (mRunningSyncJobs.decrementAndGet() == 0) {
371 service.updateRosterUi();
372 }
373 });
374 }
375
376 @Override
377 public void handleSmsReceived(final Intent intent) {
378 final Bundle extras = intent.getExtras();
379 final String pin = SmsRetrieverWrapper.extractPin(extras);
380 if (pin == null) {
381 Log.d(Config.LOGTAG, "unable to extract Pin from received SMS");
382 return;
383 }
384 final Account account = AccountUtils.getFirst(service);
385 if (account == null) {
386 Log.d(Config.LOGTAG, "no account configured to process PIN received by SMS");
387 return;
388 }
389 verify(account, pin);
390 synchronized (mOnVerification) {
391 for (OnVerification onVerification : mOnVerification) {
392 onVerification.startBackgroundVerification(pin);
393 }
394 }
395 }
396
397 private void considerSync(boolean forced) {
398 final ImmutableMap<String, PhoneNumberContact> allContacts =
399 PhoneNumberContact.load(service);
400 for (final Account account : service.getAccounts()) {
401 final Map<String, PhoneNumberContact> contacts =
402 filtered(allContacts, account.getJid().getLocal());
403 if (contacts.size() < allContacts.size()) {
404 Log.d(
405 Config.LOGTAG,
406 account.getJid().asBareJid()
407 + ": found own phone number in address book. ignoring...");
408 }
409 refresh(account, contacts.values());
410 if (!considerSync(account, contacts, forced)) {
411 account.getXmppConnection().getManager(RosterManager.class).writeToDatabaseAsync();
412 }
413 }
414 }
415
416 @SafeVarargs
417 private static <A, B> Map<A, B> filtered(final Map<A, B> input, final A... filters) {
418 final HashMap<A, B> result = new HashMap<>(input);
419 for (final A filtered : filters) {
420 result.remove(filtered);
421 }
422 return result;
423 }
424
425 private void refresh(Account account, Collection<PhoneNumberContact> contacts) {
426 for (final var contact :
427 account.getRoster().getWithSystemAccounts(PhoneNumberContact.class)) {
428 final Uri uri = contact.getSystemAccount();
429 if (uri == null) {
430 continue;
431 }
432 final String number = getNumber(contact);
433 final PhoneNumberContact phoneNumberContact =
434 PhoneNumberContact.findByUriOrNumber(contacts, uri, number);
435 final boolean needsCacheClean;
436 if (phoneNumberContact != null) {
437 if (!uri.equals(phoneNumberContact.getLookupUri())) {
438 Log.d(
439 Config.LOGTAG,
440 "lookupUri has changed from "
441 + uri
442 + " to "
443 + phoneNumberContact.getLookupUri());
444 }
445 needsCacheClean = contact.setPhoneContact(phoneNumberContact);
446 } else {
447 needsCacheClean = contact.unsetPhoneContact(PhoneNumberContact.class);
448 Log.d(Config.LOGTAG, uri.toString() + " vanished from address book");
449 }
450 if (needsCacheClean) {
451 service.getAvatarService().clear(contact);
452 }
453 }
454 }
455
456 private static String getNumber(final Contact contact) {
457 final Jid jid = contact.getJid();
458 if (jid.getLocal() != null && Config.QUICKSY_DOMAIN.equals(jid.getDomain())) {
459 return jid.getLocal();
460 }
461 return null;
462 }
463
464 private boolean considerSync(
465 final Account account,
466 final Map<String, PhoneNumberContact> contacts,
467 final boolean forced) {
468 final int hash = contacts.keySet().hashCode();
469 Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": consider sync of " + hash);
470 if (!mLastSyncAttempt.retry(hash) && !forced) {
471 Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": do not attempt sync");
472 return false;
473 }
474 mRunningSyncJobs.incrementAndGet();
475 final Jid syncServer = Jid.of(API_DOMAIN);
476 Log.d(
477 Config.LOGTAG,
478 account.getJid().asBareJid() + ": sending phone list to " + syncServer);
479 final List<Element> entries = new ArrayList<>();
480 for (final PhoneNumberContact c : contacts.values()) {
481 entries.add(new Element("entry").setAttribute("number", c.getPhoneNumber()));
482 }
483 final Iq query = new Iq(Iq.Type.GET);
484 query.setTo(syncServer);
485 final Element book =
486 new Element("phone-book", Namespace.SYNCHRONIZATION).setChildren(entries);
487 final String statusQuo =
488 Entry.statusQuo(
489 contacts.values(),
490 account.getRoster().getWithSystemAccounts(PhoneNumberContact.class));
491 book.setAttribute("ver", statusQuo);
492 query.addChild(book);
493 mLastSyncAttempt = Attempt.create(hash);
494 service.sendIqPacket(
495 account,
496 query,
497 (response) -> {
498 if (response.getType() == Iq.Type.RESULT) {
499 final Element phoneBook =
500 response.findChild("phone-book", Namespace.SYNCHRONIZATION);
501 if (phoneBook != null) {
502 final var remaining =
503 new ArrayList<>(
504 account.getRoster()
505 .getWithSystemAccounts(
506 PhoneNumberContact.class));
507 for (Entry entry : Entry.ofPhoneBook(phoneBook)) {
508 final PhoneNumberContact phoneContact =
509 contacts.get(entry.getNumber());
510 if (phoneContact == null) {
511 continue;
512 }
513 for (final Jid jid : entry.getJids()) {
514 final Contact contact = account.getRoster().getContact(jid);
515 final boolean needsCacheClean =
516 contact.setPhoneContact(phoneContact);
517 if (needsCacheClean) {
518 service.getAvatarService().clear(contact);
519 }
520 remaining.remove(contact);
521 }
522 }
523 for (final Contact contact : remaining) {
524 final boolean needsCacheClean =
525 contact.unsetPhoneContact(PhoneNumberContact.class);
526 if (needsCacheClean) {
527 service.getAvatarService().clear(contact);
528 }
529 }
530 } else {
531 Log.d(
532 Config.LOGTAG,
533 account.getJid().asBareJid()
534 + ": phone number contact list remains unchanged");
535 }
536 } else if (response.getType() == Iq.Type.TIMEOUT) {
537 mLastSyncAttempt = Attempt.NULL;
538 } else {
539 Log.d(
540 Config.LOGTAG,
541 account.getJid().asBareJid()
542 + ": failed to sync contact list with api server");
543 }
544 mRunningSyncJobs.decrementAndGet();
545 account.getXmppConnection()
546 .getManager(RosterManager.class)
547 .writeToDatabaseAsync();
548 service.updateRosterUi();
549 });
550 return true;
551 }
552
553 public interface OnVerificationRequested {
554 void onVerificationRequestFailed(int code);
555
556 void onVerificationRequested();
557
558 void onVerificationRequestedRetryAt(long timestamp);
559 }
560
561 public interface OnVerification {
562 void onVerificationFailed(int code);
563
564 void onVerificationSucceeded();
565
566 void onVerificationRetryAt(long timestamp);
567
568 void startBackgroundVerification(String pin);
569 }
570
571 private static class Attempt {
572 private final long timestamp;
573 private final int hash;
574
575 private static final Attempt NULL = new Attempt(0, 0);
576
577 private Attempt(long timestamp, int hash) {
578 this.timestamp = timestamp;
579 this.hash = hash;
580 }
581
582 public static Attempt create(int hash) {
583 return new Attempt(SystemClock.elapsedRealtime(), hash);
584 }
585
586 public boolean retry(int hash) {
587 return hash != this.hash
588 || SystemClock.elapsedRealtime() - timestamp
589 >= Config.CONTACT_SYNC_RETRY_INTERVAL;
590 }
591 }
592}