QuickConversationsService.java

  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}