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