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 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}