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