1package eu.siacs.conversations.xmpp.manager;
2
3import android.content.Context;
4import android.graphics.Bitmap;
5import android.graphics.BitmapFactory;
6import android.util.Log;
7import android.util.Patterns;
8import androidx.annotation.NonNull;
9import com.google.common.base.Optional;
10import com.google.common.collect.ImmutableMap;
11import com.google.common.collect.Iterables;
12import com.google.common.util.concurrent.Futures;
13import com.google.common.util.concurrent.ListenableFuture;
14import com.google.common.util.concurrent.MoreExecutors;
15import eu.siacs.conversations.Config;
16import eu.siacs.conversations.entities.Account;
17import eu.siacs.conversations.xml.Namespace;
18import eu.siacs.conversations.xmpp.XmppConnection;
19import im.conversations.android.xmpp.IqErrorException;
20import im.conversations.android.xmpp.model.data.Data;
21import im.conversations.android.xmpp.model.error.Condition;
22import im.conversations.android.xmpp.model.oob.OutOfBandData;
23import im.conversations.android.xmpp.model.pars.PreAuth;
24import im.conversations.android.xmpp.model.register.Instructions;
25import im.conversations.android.xmpp.model.register.Password;
26import im.conversations.android.xmpp.model.register.Register;
27import im.conversations.android.xmpp.model.register.Remove;
28import im.conversations.android.xmpp.model.register.Username;
29import im.conversations.android.xmpp.model.stanza.Iq;
30import java.util.Arrays;
31import java.util.List;
32import java.util.regex.Matcher;
33import okhttp3.HttpUrl;
34
35public class RegistrationManager extends AbstractManager {
36
37 public RegistrationManager(Context context, XmppConnection connection) {
38 super(context, connection);
39 }
40
41 public ListenableFuture<Void> setPassword(final String password) {
42 final var account = getAccount();
43 final var iq = new Iq(Iq.Type.SET);
44 iq.setTo(account.getJid().getDomain());
45 final var register = iq.addExtension(new Register());
46 register.addUsername(account.getJid().getLocal());
47 register.addPassword(password);
48 return Futures.transform(
49 connection.sendIqPacket(iq),
50 r -> {
51 account.setPassword(password);
52 account.setOption(Account.OPTION_MAGIC_CREATE, false);
53 getDatabase().updateAccount(account);
54 return null;
55 },
56 MoreExecutors.directExecutor());
57 }
58
59 public ListenableFuture<Void> register() {
60 final var account = getAccount();
61 final var iq = new Iq(Iq.Type.SET);
62 iq.setTo(account.getJid().getDomain());
63 final var register = iq.addExtension(new Register());
64 register.addUsername(account.getJid().getLocal());
65 register.addPassword(account.getPassword());
66 final ListenableFuture<Void> future =
67 Futures.transform(
68 connection.sendIqPacket(iq, true),
69 result -> null,
70 MoreExecutors.directExecutor());
71 return Futures.catchingAsync(
72 future,
73 IqErrorException.class,
74 ex ->
75 Futures.immediateFailedFuture(
76 new RegistrationFailedException(ex.getResponse())),
77 MoreExecutors.directExecutor());
78 }
79
80 public ListenableFuture<Void> register(final Data data, final String ocr) {
81 final var account = getAccount();
82 final var submission =
83 data.submit(
84 ImmutableMap.of(
85 "username",
86 account.getJid().getLocal(),
87 "password",
88 account.getPassword(),
89 "ocr",
90 ocr));
91 final var iq = new Iq(Iq.Type.SET);
92 final var register = iq.addExtension(new Register());
93 register.addExtension(submission);
94 final ListenableFuture<Void> future =
95 Futures.transform(
96 connection.sendIqPacket(iq, true),
97 result -> null,
98 MoreExecutors.directExecutor());
99 return Futures.catchingAsync(
100 future,
101 IqErrorException.class,
102 ex ->
103 Futures.immediateFailedFuture(
104 new RegistrationFailedException(ex.getResponse())),
105 MoreExecutors.directExecutor());
106 }
107
108 public ListenableFuture<Void> unregister() {
109 final var account = getAccount();
110 final var iq = new Iq(Iq.Type.SET);
111 iq.setTo(account.getJid().getDomain());
112 final var register = iq.addExtension(new Register());
113 register.addExtension(new Remove());
114 return Futures.transform(
115 connection.sendIqPacket(iq), r -> null, MoreExecutors.directExecutor());
116 }
117
118 public ListenableFuture<Registration> getRegistration() {
119 final var account = getAccount();
120 final var iq = new Iq(Iq.Type.GET);
121 iq.setTo(account.getDomain());
122 iq.addExtension(new Register());
123 final var future = connection.sendIqPacket(iq, true);
124 return Futures.transformAsync(
125 future,
126 result -> {
127 final var register = result.getExtension(Register.class);
128 if (register == null) {
129 throw new IllegalStateException(
130 "Server did not include register in response");
131 }
132 if (register.hasExtension(Username.class)
133 && register.hasExtension(Password.class)) {
134 return Futures.immediateFuture(new SimpleRegistration());
135 }
136
137 // find bits of binary and get captcha from there
138
139 final var data = register.getExtension(Data.class);
140 // note that the captcha namespace is incorrect here. That namespace is only
141 // used in message challenges. ejabberd uses the incorrect namespace though
142 if (data != null
143 && Arrays.asList(Namespace.REGISTER, Namespace.CAPTCHA)
144 .contains(data.getFormType())) {
145 return getExtendedRegistration(register, data);
146 }
147 final var oob = register.getExtension(OutOfBandData.class);
148 final var instructions = register.getExtension(Instructions.class);
149 final String instructionsText =
150 instructions == null ? null : instructions.getContent();
151 final String redirectUrl = oob == null ? null : oob.getURL();
152 if (redirectUrl != null) {
153 return Futures.immediateFuture(RedirectRegistration.ifValid(redirectUrl));
154 }
155 if (instructionsText != null) {
156 final Matcher matcher = Patterns.WEB_URL.matcher(instructionsText);
157 if (matcher.find()) {
158 final String instructionsUrl =
159 instructionsText.substring(matcher.start(), matcher.end());
160 return Futures.immediateFuture(
161 RedirectRegistration.ifValid(instructionsUrl));
162 }
163 }
164 throw new IllegalStateException("No supported registration method found");
165 },
166 MoreExecutors.directExecutor());
167 }
168
169 private ListenableFuture<Registration> getExtendedRegistration(
170 final Register register, final Data data) {
171 final var ocr = data.getFieldByName("ocr");
172 if (ocr == null) {
173 throw new IllegalArgumentException("Missing OCR form field");
174 }
175 final var ocrMedia = ocr.getMedia();
176 if (ocrMedia == null) {
177 throw new IllegalArgumentException("OCR form field missing media");
178 }
179 final var uris = ocrMedia.getUris();
180 final var bobUri = Iterables.find(uris, u -> "cid".equals(u.getScheme()), null);
181 final Optional<im.conversations.android.xmpp.model.bob.Data> bob;
182 if (bobUri != null) {
183 bob = im.conversations.android.xmpp.model.bob.Data.get(register, bobUri.getPath());
184 } else {
185 bob = Optional.absent();
186 }
187 if (bob.isPresent()) {
188 final var bytes = bob.get().asBytes();
189 final Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
190 return Futures.immediateFuture(new ExtendedRegistration(bitmap, data));
191 }
192 final var captchaFallbackUrl = data.getValue("captcha-fallback-url");
193 if (captchaFallbackUrl == null) {
194 throw new IllegalStateException("No captcha fallback URL provided");
195 }
196 final var captchFallbackHttpUrl = HttpUrl.parse(captchaFallbackUrl);
197 Log.d(Config.LOGTAG, "fallback url: " + captchFallbackHttpUrl);
198 throw new IllegalStateException("Not implemented");
199 }
200
201 public ListenableFuture<Registration> getRegistration(final String token) {
202 final var preAuthentication = sendPreAuthentication(token);
203 final var caught =
204 Futures.catchingAsync(
205 preAuthentication,
206 IqErrorException.class,
207 ex -> {
208 final var error = ex.getError();
209 final var condition = error == null ? null : error.getCondition();
210 if (condition instanceof Condition.ItemNotFound) {
211 return Futures.immediateFailedFuture(
212 new InvalidTokenException(ex.getResponse()));
213 } else {
214 return Futures.immediateFuture(ex);
215 }
216 },
217 MoreExecutors.directExecutor());
218 return Futures.transformAsync(
219 caught, v -> getRegistration(), MoreExecutors.directExecutor());
220 }
221
222 public ListenableFuture<Void> sendPreAuthentication(final String token) {
223 final var account = getAccount();
224 final var iq = new Iq(Iq.Type.GET);
225 iq.setTo(account.getJid().getDomain());
226 final var preAuthentication = iq.addExtension(new PreAuth());
227 preAuthentication.setToken(token);
228 final var future = connection.sendIqPacket(iq, true);
229 return Futures.transform(future, result -> null, MoreExecutors.directExecutor());
230 }
231
232 public boolean hasFeature() {
233 return getManager(DiscoManager.class).hasServerFeature(Namespace.REGISTER);
234 }
235
236 public abstract static class Registration {}
237
238 // only requires Username + Password
239 public static class SimpleRegistration extends Registration {}
240
241 // Captcha as shown here: https://xmpp.org/extensions/xep-0158.html#register
242 public static class ExtendedRegistration extends Registration {
243 private final Bitmap captcha;
244 private final Data data;
245
246 public ExtendedRegistration(final Bitmap captcha, final Data data) {
247 this.captcha = captcha;
248 this.data = data;
249 }
250
251 public Bitmap getCaptcha() {
252 return this.captcha;
253 }
254
255 public Data getData() {
256 return this.data;
257 }
258 }
259
260 // Redirection as show here: https://xmpp.org/extensions/xep-0077.html#redirect
261 public static class RedirectRegistration extends Registration {
262 private final HttpUrl url;
263
264 private RedirectRegistration(@NonNull HttpUrl url) {
265 this.url = url;
266 }
267
268 public @NonNull HttpUrl getURL() {
269 return this.url;
270 }
271
272 public static RedirectRegistration ifValid(final String url) {
273 final HttpUrl httpUrl = HttpUrl.parse(url);
274 if (httpUrl != null && httpUrl.isHttps()) {
275 return new RedirectRegistration(httpUrl);
276 }
277 throw new IllegalStateException(
278 "A URL found the registration instructions is not valid");
279 }
280 }
281
282 public static class InvalidTokenException extends IqErrorException {
283
284 public InvalidTokenException(final Iq response) {
285 super(response);
286 }
287 }
288
289 public static class RegistrationFailedException extends IqErrorException {
290
291 private final List<String> PASSWORD_TOO_WEAK_MESSAGES =
292 Arrays.asList("The password is too weak", "Please use a longer password.");
293
294 public RegistrationFailedException(final Iq response) {
295 super(response);
296 }
297
298 public Account.State asAccountState() {
299 final var error = getError();
300 final var condition = error == null ? null : error.getCondition();
301 if (condition instanceof Condition.Conflict) {
302 return Account.State.REGISTRATION_CONFLICT;
303 } else if (condition instanceof Condition.ResourceConstraint) {
304 return Account.State.REGISTRATION_PLEASE_WAIT;
305 } else if (condition instanceof Condition.NotAcceptable
306 && PASSWORD_TOO_WEAK_MESSAGES.contains(error.getTextAsString())) {
307 return Account.State.REGISTRATION_PASSWORD_TOO_WEAK;
308 } else {
309 return Account.State.REGISTRATION_FAILED;
310 }
311 }
312 }
313}