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