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