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