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