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