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