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