QuickConversationsService.java

  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}