QuickConversationsService.java

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