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