QuickConversationsService.java

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