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