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