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