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