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