1package eu.siacs.conversations.services;
2
3
4import android.content.Intent;
5import android.content.SharedPreferences;
6import android.net.Uri;
7import android.os.Bundle;
8import android.os.SystemClock;
9import android.preference.PreferenceManager;
10import android.util.Log;
11
12import com.google.common.collect.ImmutableMap;
13
14import java.io.BufferedWriter;
15import java.io.IOException;
16import java.io.OutputStream;
17import java.io.OutputStreamWriter;
18import java.net.ConnectException;
19import java.net.HttpURLConnection;
20import java.net.SocketTimeoutException;
21import java.net.URL;
22import java.net.UnknownHostException;
23import java.security.GeneralSecurityException;
24import java.security.SecureRandom;
25import java.security.cert.CertificateException;
26import java.util.ArrayList;
27import java.util.Collection;
28import java.util.Collections;
29import java.util.HashMap;
30import java.util.List;
31import java.util.Locale;
32import java.util.Map;
33import java.util.Set;
34import java.util.UUID;
35import java.util.WeakHashMap;
36import java.util.concurrent.CountDownLatch;
37import java.util.concurrent.TimeUnit;
38import java.util.concurrent.atomic.AtomicBoolean;
39import java.util.concurrent.atomic.AtomicInteger;
40
41import javax.net.ssl.SSLException;
42import javax.net.ssl.SSLHandshakeException;
43import javax.net.ssl.SSLPeerUnverifiedException;
44
45import eu.siacs.conversations.Config;
46import eu.siacs.conversations.android.PhoneNumberContact;
47import eu.siacs.conversations.crypto.sasl.Plain;
48import eu.siacs.conversations.entities.Account;
49import eu.siacs.conversations.entities.Contact;
50import eu.siacs.conversations.entities.Entry;
51import eu.siacs.conversations.http.HttpConnectionManager;
52import eu.siacs.conversations.utils.AccountUtils;
53import eu.siacs.conversations.utils.CryptoHelper;
54import eu.siacs.conversations.utils.PhoneNumberUtilWrapper;
55import eu.siacs.conversations.utils.SerialSingleThreadExecutor;
56import eu.siacs.conversations.utils.SmsRetrieverWrapper;
57import eu.siacs.conversations.xml.Element;
58import eu.siacs.conversations.xml.Namespace;
59import eu.siacs.conversations.xmpp.Jid;
60import eu.siacs.conversations.xmpp.stanzas.IqPacket;
61import io.michaelrocks.libphonenumber.android.Phonenumber;
62
63public class QuickConversationsService extends AbstractQuickConversationsService {
64
65
66 public static final int API_ERROR_OTHER = -1;
67 public static final int API_ERROR_UNKNOWN_HOST = -2;
68 public static final int API_ERROR_CONNECT = -3;
69 public static final int API_ERROR_SSL_HANDSHAKE = -4;
70 public static final int API_ERROR_AIRPLANE_MODE = -5;
71 public static final int API_ERROR_SSL_CERTIFICATE = -6;
72 public static final int API_ERROR_SSL_GENERAL = -7;
73 public static final int API_ERROR_TIMEOUT = -8;
74
75 private static final String API_DOMAIN = "api." + Config.QUICKSY_DOMAIN;
76
77 private static final String BASE_URL = "https://" + API_DOMAIN;
78
79 private static final String INSTALLATION_ID = "eu.siacs.conversations.installation-id";
80
81 private final Set<OnVerificationRequested> mOnVerificationRequested = Collections.newSetFromMap(new WeakHashMap<>());
82 private final Set<OnVerification> mOnVerification = Collections.newSetFromMap(new WeakHashMap<>());
83
84 private final AtomicBoolean mVerificationInProgress = new AtomicBoolean(false);
85 private final AtomicBoolean mVerificationRequestInProgress = new AtomicBoolean(false);
86 private final AtomicInteger mRunningSyncJobs = new AtomicInteger(0);
87 private CountDownLatch awaitingAccountStateChange;
88
89 private Attempt mLastSyncAttempt = Attempt.NULL;
90
91 private final SerialSingleThreadExecutor mSerialSingleThreadExecutor = new SerialSingleThreadExecutor(QuickConversationsService.class.getSimpleName());
92
93 QuickConversationsService(XmppConnectionService xmppConnectionService) {
94 super(xmppConnectionService);
95 }
96
97 private static long retryAfter(HttpURLConnection connection) {
98 try {
99 return SystemClock.elapsedRealtime() + (Long.parseLong(connection.getHeaderField("Retry-After")) * 1000L);
100 } catch (Exception e) {
101 return 0;
102 }
103 }
104
105 public void addOnVerificationRequestedListener(OnVerificationRequested onVerificationRequested) {
106 synchronized (mOnVerificationRequested) {
107 mOnVerificationRequested.add(onVerificationRequested);
108 }
109 }
110
111 public void removeOnVerificationRequestedListener(OnVerificationRequested onVerificationRequested) {
112 synchronized (mOnVerificationRequested) {
113 mOnVerificationRequested.remove(onVerificationRequested);
114 }
115 }
116
117 public void addOnVerificationListener(OnVerification onVerification) {
118 synchronized (mOnVerification) {
119 mOnVerification.add(onVerification);
120 }
121 }
122
123 public void removeOnVerificationListener(OnVerification onVerification) {
124 synchronized (mOnVerification) {
125 mOnVerification.remove(onVerification);
126 }
127 }
128
129 public void requestVerification(Phonenumber.PhoneNumber phoneNumber) {
130 final String e164 = PhoneNumberUtilWrapper.normalize(service, phoneNumber);
131 if (mVerificationRequestInProgress.compareAndSet(false, true)) {
132 SmsRetrieverWrapper.start(service);
133 new Thread(() -> {
134 try {
135 final URL url = new URL(BASE_URL + "/authentication/" + e164);
136 HttpURLConnection connection = (HttpURLConnection) url.openConnection();
137 connection.setConnectTimeout(Config.SOCKET_TIMEOUT * 1000);
138 connection.setReadTimeout(Config.SOCKET_TIMEOUT * 1000);
139 setHeader(connection);
140 final int code = connection.getResponseCode();
141 if (code == 200) {
142 createAccountAndWait(phoneNumber, 0L);
143 } else if (code == 429) {
144 createAccountAndWait(phoneNumber, retryAfter(connection));
145 } else {
146 synchronized (mOnVerificationRequested) {
147 for (OnVerificationRequested onVerificationRequested : mOnVerificationRequested) {
148 onVerificationRequested.onVerificationRequestFailed(code);
149 }
150 }
151 }
152 } catch (IOException e) {
153 final int code = getApiErrorCode(e);
154 synchronized (mOnVerificationRequested) {
155 for (OnVerificationRequested onVerificationRequested : mOnVerificationRequested) {
156 onVerificationRequested.onVerificationRequestFailed(code);
157 }
158 }
159 } finally {
160 mVerificationRequestInProgress.set(false);
161 }
162 }).start();
163 }
164
165
166 }
167
168 public void signalAccountStateChange() {
169 if (awaitingAccountStateChange != null && awaitingAccountStateChange.getCount() > 0) {
170 Log.d(Config.LOGTAG, "signaled state change");
171 awaitingAccountStateChange.countDown();
172 }
173 }
174
175 private void createAccountAndWait(Phonenumber.PhoneNumber phoneNumber, final long timestamp) {
176 String local = PhoneNumberUtilWrapper.normalize(service, phoneNumber);
177 Log.d(Config.LOGTAG, "requesting verification for " + PhoneNumberUtilWrapper.normalize(service, phoneNumber));
178 Jid jid = Jid.of(local, Config.QUICKSY_DOMAIN, null);
179 Account account = AccountUtils.getFirst(service);
180 if (account == null || !account.getJid().asBareJid().equals(jid.asBareJid())) {
181 if (account != null) {
182 service.deleteAccount(account);
183 }
184 account = new Account(jid, CryptoHelper.createPassword(new SecureRandom()));
185 account.setOption(Account.OPTION_DISABLED, true);
186 account.setOption(Account.OPTION_MAGIC_CREATE, true);
187 account.setOption(Account.OPTION_UNVERIFIED, true);
188 service.createAccount(account);
189 }
190 synchronized (mOnVerificationRequested) {
191 for (OnVerificationRequested onVerificationRequested : mOnVerificationRequested) {
192 if (timestamp <= 0) {
193 onVerificationRequested.onVerificationRequested();
194 } else {
195 onVerificationRequested.onVerificationRequestedRetryAt(timestamp);
196 }
197 }
198 }
199 }
200
201 public void verify(final Account account, String pin) {
202 if (mVerificationInProgress.compareAndSet(false, true)) {
203 new Thread(() -> {
204 try {
205 final URL url = new URL(BASE_URL + "/password");
206 final HttpURLConnection connection = (HttpURLConnection) url.openConnection();
207 connection.setConnectTimeout(Config.SOCKET_TIMEOUT * 1000);
208 connection.setReadTimeout(Config.SOCKET_TIMEOUT * 1000);
209 connection.setRequestMethod("POST");
210 connection.setRequestProperty("Authorization", Plain.getMessage(account.getUsername(), pin));
211 setHeader(connection);
212 final OutputStream os = connection.getOutputStream();
213 final BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(os, "UTF-8"));
214 writer.write(account.getPassword());
215 writer.flush();
216 writer.close();
217 os.close();
218 connection.connect();
219 final int code = connection.getResponseCode();
220 if (code == 200 || code == 201) {
221 account.setOption(Account.OPTION_UNVERIFIED, false);
222 account.setOption(Account.OPTION_DISABLED, false);
223 awaitingAccountStateChange = new CountDownLatch(1);
224 service.updateAccount(account);
225 try {
226 awaitingAccountStateChange.await(5, TimeUnit.SECONDS);
227 } catch (InterruptedException e) {
228 Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": timer expired while waiting for account to connect");
229 }
230 synchronized (mOnVerification) {
231 for (OnVerification onVerification : mOnVerification) {
232 onVerification.onVerificationSucceeded();
233 }
234 }
235 } else if (code == 429) {
236 final long retryAfter = retryAfter(connection);
237 synchronized (mOnVerification) {
238 for (OnVerification onVerification : mOnVerification) {
239 onVerification.onVerificationRetryAt(retryAfter);
240 }
241 }
242 } else {
243 synchronized (mOnVerification) {
244 for (OnVerification onVerification : mOnVerification) {
245 onVerification.onVerificationFailed(code);
246 }
247 }
248 }
249 } catch (IOException e) {
250 final int code = getApiErrorCode(e);
251 synchronized (mOnVerification) {
252 for (OnVerification onVerification : mOnVerification) {
253 onVerification.onVerificationFailed(code);
254 }
255 }
256 } finally {
257 mVerificationInProgress.set(false);
258 }
259 }).start();
260 }
261 }
262
263 private void setHeader(HttpURLConnection connection) {
264 connection.setRequestProperty("User-Agent", HttpConnectionManager.getUserAgent());
265 connection.setRequestProperty("Installation-Id", getInstallationId());
266 connection.setRequestProperty("Accept-Language", Locale.getDefault().getLanguage());
267 }
268
269 private String getInstallationId() {
270 SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(service);
271 String id = preferences.getString(INSTALLATION_ID, null);
272 if (id != null) {
273 return id;
274 } else {
275 id = UUID.randomUUID().toString();
276 preferences.edit().putString(INSTALLATION_ID, id).apply();
277 return id;
278 }
279
280 }
281
282 private int getApiErrorCode(final Exception e) {
283 if (!service.hasInternetConnection()) {
284 return API_ERROR_AIRPLANE_MODE;
285 } else if (e instanceof UnknownHostException) {
286 return API_ERROR_UNKNOWN_HOST;
287 } else if (e instanceof ConnectException) {
288 return API_ERROR_CONNECT;
289 } else if (e instanceof SSLHandshakeException) {
290 return API_ERROR_SSL_HANDSHAKE;
291 } else if (e instanceof SSLPeerUnverifiedException || e instanceof CertificateException) {
292 return API_ERROR_SSL_CERTIFICATE;
293 } else if (e instanceof SSLException || e instanceof GeneralSecurityException) {
294 return API_ERROR_SSL_GENERAL;
295 } else if (e instanceof SocketTimeoutException) {
296 return API_ERROR_TIMEOUT;
297 } else {
298 Log.d(Config.LOGTAG, e.getClass().getName());
299 return API_ERROR_OTHER;
300 }
301 }
302
303 public boolean isVerifying() {
304 return mVerificationInProgress.get();
305 }
306
307 public boolean isRequestingVerification() {
308 return mVerificationRequestInProgress.get();
309 }
310
311
312 @Override
313 public boolean isSynchronizing() {
314 return mRunningSyncJobs.get() > 0;
315 }
316
317 @Override
318 public void considerSync() {
319 considerSync(false);
320 }
321
322 @Override
323 public void considerSyncBackground(final boolean forced) {
324 mRunningSyncJobs.incrementAndGet();
325 mSerialSingleThreadExecutor.execute(() -> {
326 considerSync(forced);
327 if (mRunningSyncJobs.decrementAndGet() == 0) {
328 service.updateRosterUi();
329 }
330 });
331 }
332
333 @Override
334 public void handleSmsReceived(final Intent intent) {
335 final Bundle extras = intent.getExtras();
336 final String pin = SmsRetrieverWrapper.extractPin(extras);
337 if (pin == null) {
338 Log.d(Config.LOGTAG, "unable to extract Pin from received SMS");
339 return;
340 }
341 final Account account = AccountUtils.getFirst(service);
342 if (account == null) {
343 Log.d(Config.LOGTAG, "no account configured to process PIN received by SMS");
344 return;
345 }
346 verify(account, pin);
347 synchronized (mOnVerification) {
348 for (OnVerification onVerification : mOnVerification) {
349 onVerification.startBackgroundVerification(pin);
350 }
351 }
352
353 }
354
355
356 private void considerSync(boolean forced) {
357 final ImmutableMap<String, PhoneNumberContact> allContacts = PhoneNumberContact.load(service);
358 for (final Account account : service.getAccounts()) {
359 final Map<String, PhoneNumberContact> contacts = filtered(allContacts, account.getJid().getLocal());
360 if (contacts.size() < allContacts.size()) {
361 Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": found own phone number in address book. ignoring...");
362 }
363 refresh(account, contacts.values());
364 if (!considerSync(account, contacts, forced)) {
365 service.syncRoster(account);
366 }
367 }
368 }
369
370 @SafeVarargs
371 private static <A, B> Map<A, B> filtered(final Map<A, B> input, final A... filters) {
372 final HashMap<A, B> result = new HashMap<>(input);
373 for (final A filtered : filters) {
374 result.remove(filtered);
375 }
376 return result;
377 }
378
379 private void refresh(Account account, Collection<PhoneNumberContact> contacts) {
380 for (Contact contact : account.getRoster().getWithSystemAccounts(PhoneNumberContact.class)) {
381 final Uri uri = contact.getSystemAccount();
382 if (uri == null) {
383 continue;
384 }
385 final String number = getNumber(contact);
386 final PhoneNumberContact phoneNumberContact = PhoneNumberContact.findByUriOrNumber(contacts, uri, number);
387 final boolean needsCacheClean;
388 if (phoneNumberContact != null) {
389 if (!uri.equals(phoneNumberContact.getLookupUri())) {
390 Log.d(Config.LOGTAG, "lookupUri has changed from " + uri + " to " + phoneNumberContact.getLookupUri());
391 }
392 needsCacheClean = contact.setPhoneContact(phoneNumberContact);
393 } else {
394 needsCacheClean = contact.unsetPhoneContact(PhoneNumberContact.class);
395 Log.d(Config.LOGTAG, uri.toString() + " vanished from address book");
396 }
397 if (needsCacheClean) {
398 service.getAvatarService().clear(contact);
399 }
400 }
401 }
402
403 private static String getNumber(final Contact contact) {
404 final Jid jid = contact.getJid();
405 if (jid.getLocal() != null && Config.QUICKSY_DOMAIN.equals(jid.getDomain())) {
406 return jid.getLocal();
407 }
408 return null;
409 }
410
411 private boolean considerSync(final Account account, final Map<String, PhoneNumberContact> contacts, final boolean forced) {
412 final int hash = contacts.keySet().hashCode();
413 Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": consider sync of " + hash);
414 if (!mLastSyncAttempt.retry(hash) && !forced) {
415 Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": do not attempt sync");
416 return false;
417 }
418 mRunningSyncJobs.incrementAndGet();
419 final Jid syncServer = Jid.of(API_DOMAIN);
420 Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": sending phone list to " + syncServer);
421 final List<Element> entries = new ArrayList<>();
422 for (final PhoneNumberContact c : contacts.values()) {
423 entries.add(new Element("entry").setAttribute("number", c.getPhoneNumber()));
424 }
425 final IqPacket query = new IqPacket(IqPacket.TYPE.GET);
426 query.setTo(syncServer);
427 final Element book = new Element("phone-book", Namespace.SYNCHRONIZATION).setChildren(entries);
428 final String statusQuo = Entry.statusQuo(contacts.values(), account.getRoster().getWithSystemAccounts(PhoneNumberContact.class));
429 book.setAttribute("ver", statusQuo);
430 query.addChild(book);
431 mLastSyncAttempt = Attempt.create(hash);
432 service.sendIqPacket(account, query, (a, response) -> {
433 if (response.getType() == IqPacket.TYPE.RESULT) {
434 final Element phoneBook = response.findChild("phone-book", Namespace.SYNCHRONIZATION);
435 if (phoneBook != null) {
436 final List<Contact> withSystemAccounts = account.getRoster().getWithSystemAccounts(PhoneNumberContact.class);
437 for (Entry entry : Entry.ofPhoneBook(phoneBook)) {
438 final PhoneNumberContact phoneContact = contacts.get(entry.getNumber());
439 if (phoneContact == null) {
440 continue;
441 }
442 for (final Jid jid : entry.getJids()) {
443 final Contact contact = account.getRoster().getContact(jid);
444 final boolean needsCacheClean = contact.setPhoneContact(phoneContact);
445 if (needsCacheClean) {
446 service.getAvatarService().clear(contact);
447 }
448 withSystemAccounts.remove(contact);
449 }
450 }
451 for (final Contact contact : withSystemAccounts) {
452 final boolean needsCacheClean = contact.unsetPhoneContact(PhoneNumberContact.class);
453 if (needsCacheClean) {
454 service.getAvatarService().clear(contact);
455 }
456 }
457 } else {
458 Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": phone number contact list remains unchanged");
459 }
460 } else if (response.getType() == IqPacket.TYPE.TIMEOUT) {
461 mLastSyncAttempt = Attempt.NULL;
462 } else {
463 Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": failed to sync contact list with api server");
464 }
465 mRunningSyncJobs.decrementAndGet();
466 service.syncRoster(account);
467 service.updateRosterUi();
468 });
469 return true;
470 }
471
472
473 public interface OnVerificationRequested {
474 void onVerificationRequestFailed(int code);
475
476 void onVerificationRequested();
477
478 void onVerificationRequestedRetryAt(long timestamp);
479 }
480
481 public interface OnVerification {
482 void onVerificationFailed(int code);
483
484 void onVerificationSucceeded();
485
486 void onVerificationRetryAt(long timestamp);
487
488 void startBackgroundVerification(String pin);
489 }
490
491 private static class Attempt {
492 private final long timestamp;
493 private final int hash;
494
495 private static final Attempt NULL = new Attempt(0, 0);
496
497 private Attempt(long timestamp, int hash) {
498 this.timestamp = timestamp;
499 this.hash = hash;
500 }
501
502 public static Attempt create(int hash) {
503 return new Attempt(SystemClock.elapsedRealtime(), hash);
504 }
505
506 public boolean retry(int hash) {
507 return hash != this.hash || SystemClock.elapsedRealtime() - timestamp >= Config.CONTACT_SYNC_RETRY_INTERVAL;
508 }
509 }
510}