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