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