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