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