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