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