1package eu.siacs.conversations.entities;
2
3import android.content.ContentValues;
4import android.database.Cursor;
5import android.os.SystemClock;
6import android.util.Log;
7
8import com.google.common.base.Strings;
9import com.google.common.collect.ImmutableList;
10
11import org.json.JSONException;
12import org.json.JSONObject;
13
14import java.util.ArrayList;
15import java.util.Collection;
16import java.util.HashMap;
17import java.util.HashSet;
18import java.util.List;
19import java.util.Map;
20import java.util.Set;
21import java.util.concurrent.CopyOnWriteArraySet;
22
23import eu.siacs.conversations.Config;
24import eu.siacs.conversations.R;
25import eu.siacs.conversations.crypto.PgpDecryptionService;
26import eu.siacs.conversations.crypto.axolotl.AxolotlService;
27import eu.siacs.conversations.crypto.axolotl.XmppAxolotlSession;
28import eu.siacs.conversations.services.AvatarService;
29import eu.siacs.conversations.services.XmppConnectionService;
30import eu.siacs.conversations.utils.UIHelper;
31import eu.siacs.conversations.utils.XmppUri;
32import eu.siacs.conversations.xmpp.Jid;
33import eu.siacs.conversations.xmpp.XmppConnection;
34import eu.siacs.conversations.xmpp.jingle.RtpCapability;
35
36public class Account extends AbstractEntity implements AvatarService.Avatarable {
37
38 public static final String TABLENAME = "accounts";
39
40 public static final String USERNAME = "username";
41 public static final String SERVER = "server";
42 public static final String PASSWORD = "password";
43 public static final String OPTIONS = "options";
44 public static final String ROSTERVERSION = "rosterversion";
45 public static final String KEYS = "keys";
46 public static final String AVATAR = "avatar";
47 public static final String DISPLAY_NAME = "display_name";
48 public static final String HOSTNAME = "hostname";
49 public static final String PORT = "port";
50 public static final String STATUS = "status";
51 public static final String STATUS_MESSAGE = "status_message";
52 public static final String RESOURCE = "resource";
53
54 public static final String PINNED_MECHANISM_KEY = "pinned_mechanism";
55 public static final String PRE_AUTH_REGISTRATION_TOKEN = "pre_auth_registration";
56
57 public static final int OPTION_USETLS = 0;
58 public static final int OPTION_DISABLED = 1;
59 public static final int OPTION_REGISTER = 2;
60 public static final int OPTION_USECOMPRESSION = 3;
61 public static final int OPTION_MAGIC_CREATE = 4;
62 public static final int OPTION_REQUIRES_ACCESS_MODE_CHANGE = 5;
63 public static final int OPTION_LOGGED_IN_SUCCESSFULLY = 6;
64 public static final int OPTION_HTTP_UPLOAD_AVAILABLE = 7;
65 public static final int OPTION_UNVERIFIED = 8;
66 public static final int OPTION_FIXED_USERNAME = 9;
67 private static final String KEY_PGP_SIGNATURE = "pgp_signature";
68 private static final String KEY_PGP_ID = "pgp_id";
69 protected final JSONObject keys;
70 private final Roster roster = new Roster(this);
71 private final Collection<Jid> blocklist = new CopyOnWriteArraySet<>();
72 public final Set<Conversation> pendingConferenceJoins = new HashSet<>();
73 public final Set<Conversation> pendingConferenceLeaves = new HashSet<>();
74 public final Set<Conversation> inProgressConferenceJoins = new HashSet<>();
75 public final Set<Conversation> inProgressConferencePings = new HashSet<>();
76 protected Jid jid;
77 protected String password;
78 protected int options = 0;
79 protected State status = State.OFFLINE;
80 private State lastErrorStatus = State.OFFLINE;
81 protected String resource;
82 protected String avatar;
83 protected String hostname = null;
84 protected int port = 5222;
85 protected boolean online = false;
86 private String rosterVersion;
87 private String displayName = null;
88 private AxolotlService axolotlService = null;
89 private PgpDecryptionService pgpDecryptionService = null;
90 private XmppConnection xmppConnection = null;
91 private long mEndGracePeriod = 0L;
92 private final Map<Jid, Bookmark> bookmarks = new HashMap<>();
93 private Presence.Status presenceStatus = Presence.Status.ONLINE;
94 private String presenceStatusMessage = null;
95
96 public Account(final Jid jid, final String password) {
97 this(java.util.UUID.randomUUID().toString(), jid,
98 password, 0, null, "", null, null, null, 5222, Presence.Status.ONLINE, null);
99 }
100
101 private Account(final String uuid, final Jid jid,
102 final String password, final int options, final String rosterVersion, final String keys,
103 final String avatar, String displayName, String hostname, int port,
104 final Presence.Status status, String statusMessage) {
105 this.uuid = uuid;
106 this.jid = jid;
107 this.password = password;
108 this.options = options;
109 this.rosterVersion = rosterVersion;
110 JSONObject tmp;
111 try {
112 tmp = new JSONObject(keys);
113 } catch (JSONException e) {
114 tmp = new JSONObject();
115 }
116 this.keys = tmp;
117 this.avatar = avatar;
118 this.displayName = displayName;
119 this.hostname = hostname;
120 this.port = port;
121 this.presenceStatus = status;
122 this.presenceStatusMessage = statusMessage;
123 }
124
125 public static Account fromCursor(final Cursor cursor) {
126 final Jid jid;
127 try {
128 String resource = cursor.getString(cursor.getColumnIndex(RESOURCE));
129 jid = Jid.of(
130 cursor.getString(cursor.getColumnIndex(USERNAME)),
131 cursor.getString(cursor.getColumnIndex(SERVER)),
132 resource == null || resource.trim().isEmpty() ? null : resource);
133 } catch (final IllegalArgumentException ignored) {
134 Log.d(Config.LOGTAG, cursor.getString(cursor.getColumnIndex(USERNAME)) + "@" + cursor.getString(cursor.getColumnIndex(SERVER)));
135 throw new AssertionError(ignored);
136 }
137 return new Account(cursor.getString(cursor.getColumnIndex(UUID)),
138 jid,
139 cursor.getString(cursor.getColumnIndex(PASSWORD)),
140 cursor.getInt(cursor.getColumnIndex(OPTIONS)),
141 cursor.getString(cursor.getColumnIndex(ROSTERVERSION)),
142 cursor.getString(cursor.getColumnIndex(KEYS)),
143 cursor.getString(cursor.getColumnIndex(AVATAR)),
144 cursor.getString(cursor.getColumnIndex(DISPLAY_NAME)),
145 cursor.getString(cursor.getColumnIndex(HOSTNAME)),
146 cursor.getInt(cursor.getColumnIndex(PORT)),
147 Presence.Status.fromShowString(cursor.getString(cursor.getColumnIndex(STATUS))),
148 cursor.getString(cursor.getColumnIndex(STATUS_MESSAGE)));
149 }
150
151 public boolean httpUploadAvailable(long filesize) {
152 return xmppConnection != null && xmppConnection.getFeatures().httpUpload(filesize);
153 }
154
155 public boolean httpUploadAvailable() {
156 return isOptionSet(OPTION_HTTP_UPLOAD_AVAILABLE) || httpUploadAvailable(0);
157 }
158
159 public String getDisplayName() {
160 return displayName;
161 }
162
163 public void setDisplayName(String displayName) {
164 this.displayName = displayName;
165 }
166
167 public XmppConnection.Identity getServerIdentity() {
168 if (xmppConnection == null) {
169 return XmppConnection.Identity.UNKNOWN;
170 } else {
171 return xmppConnection.getServerIdentity();
172 }
173 }
174
175 public Contact getSelfContact() {
176 return getRoster().getContact(jid);
177 }
178
179 public boolean hasPendingPgpIntent(Conversation conversation) {
180 return pgpDecryptionService != null && pgpDecryptionService.hasPendingIntent(conversation);
181 }
182
183 public boolean isPgpDecryptionServiceConnected() {
184 return pgpDecryptionService != null && pgpDecryptionService.isConnected();
185 }
186
187 public boolean setShowErrorNotification(boolean newValue) {
188 boolean oldValue = showErrorNotification();
189 setKey("show_error", Boolean.toString(newValue));
190 return newValue != oldValue;
191 }
192
193 public boolean showErrorNotification() {
194 String key = getKey("show_error");
195 return key == null || Boolean.parseBoolean(key);
196 }
197
198 public boolean isEnabled() {
199 return !isOptionSet(Account.OPTION_DISABLED);
200 }
201
202 public boolean isOptionSet(final int option) {
203 return ((options & (1 << option)) != 0);
204 }
205
206 public boolean setOption(final int option, final boolean value) {
207 final int before = this.options;
208 if (value) {
209 this.options |= 1 << option;
210 } else {
211 this.options &= ~(1 << option);
212 }
213 return before != this.options;
214 }
215
216 public String getUsername() {
217 return jid.getEscapedLocal();
218 }
219
220 public boolean setJid(final Jid next) {
221 final Jid previousFull = this.jid;
222 final Jid prev = this.jid != null ? this.jid.asBareJid() : null;
223 final boolean changed = prev == null || (next != null && !prev.equals(next.asBareJid()));
224 if (changed) {
225 final AxolotlService oldAxolotlService = this.axolotlService;
226 if (oldAxolotlService != null) {
227 oldAxolotlService.destroy();
228 this.jid = next;
229 this.axolotlService = oldAxolotlService.makeNew();
230 }
231 }
232 this.jid = next;
233 return next != null && !next.equals(previousFull);
234 }
235
236 public Jid getDomain() {
237 return jid.getDomain();
238 }
239
240 public String getServer() {
241 return jid.getDomain().toEscapedString();
242 }
243
244 public String getPassword() {
245 return password;
246 }
247
248 public void setPassword(final String password) {
249 this.password = password;
250 }
251
252 public String getHostname() {
253 return Strings.nullToEmpty(this.hostname);
254 }
255
256 public void setHostname(String hostname) {
257 this.hostname = hostname;
258 }
259
260 public boolean isOnion() {
261 final String server = getServer();
262 return server != null && server.endsWith(".onion");
263 }
264
265 public int getPort() {
266 return this.port;
267 }
268
269 public void setPort(int port) {
270 this.port = port;
271 }
272
273 public State getStatus() {
274 if (isOptionSet(OPTION_DISABLED)) {
275 return State.DISABLED;
276 } else {
277 return this.status;
278 }
279 }
280
281 public State getLastErrorStatus() {
282 return this.lastErrorStatus;
283 }
284
285 public void setStatus(final State status) {
286 this.status = status;
287 if (status.isError || status == State.ONLINE) {
288 this.lastErrorStatus = status;
289 }
290 }
291
292 public State getTrueStatus() {
293 return this.status;
294 }
295
296 public boolean errorStatus() {
297 return getStatus().isError();
298 }
299
300 public boolean hasErrorStatus() {
301 return getXmppConnection() != null
302 && (getStatus().isError() || getStatus() == State.CONNECTING)
303 && getXmppConnection().getAttempt() >= 3;
304 }
305
306 public Presence.Status getPresenceStatus() {
307 return this.presenceStatus;
308 }
309
310 public void setPresenceStatus(Presence.Status status) {
311 this.presenceStatus = status;
312 }
313
314 public String getPresenceStatusMessage() {
315 return this.presenceStatusMessage;
316 }
317
318 public void setPresenceStatusMessage(String message) {
319 this.presenceStatusMessage = message;
320 }
321
322 public String getResource() {
323 return jid.getResource();
324 }
325
326 public void setResource(final String resource) {
327 this.jid = this.jid.withResource(resource);
328 }
329
330 public Jid getJid() {
331 return jid;
332 }
333
334 public JSONObject getKeys() {
335 return keys;
336 }
337
338 public String getKey(final String name) {
339 synchronized (this.keys) {
340 return this.keys.optString(name, null);
341 }
342 }
343
344 public int getKeyAsInt(final String name, int defaultValue) {
345 String key = getKey(name);
346 try {
347 return key == null ? defaultValue : Integer.parseInt(key);
348 } catch (NumberFormatException e) {
349 return defaultValue;
350 }
351 }
352
353 public boolean setKey(final String keyName, final String keyValue) {
354 synchronized (this.keys) {
355 try {
356 this.keys.put(keyName, keyValue);
357 return true;
358 } catch (final JSONException e) {
359 return false;
360 }
361 }
362 }
363
364 public boolean setPrivateKeyAlias(String alias) {
365 return setKey("private_key_alias", alias);
366 }
367
368 public String getPrivateKeyAlias() {
369 return getKey("private_key_alias");
370 }
371
372 @Override
373 public ContentValues getContentValues() {
374 final ContentValues values = new ContentValues();
375 values.put(UUID, uuid);
376 values.put(USERNAME, jid.getLocal());
377 values.put(SERVER, jid.getDomain().toEscapedString());
378 values.put(PASSWORD, password);
379 values.put(OPTIONS, options);
380 synchronized (this.keys) {
381 values.put(KEYS, this.keys.toString());
382 }
383 values.put(ROSTERVERSION, rosterVersion);
384 values.put(AVATAR, avatar);
385 values.put(DISPLAY_NAME, displayName);
386 values.put(HOSTNAME, hostname);
387 values.put(PORT, port);
388 values.put(STATUS, presenceStatus.toShowString());
389 values.put(STATUS_MESSAGE, presenceStatusMessage);
390 values.put(RESOURCE, jid.getResource());
391 return values;
392 }
393
394 public AxolotlService getAxolotlService() {
395 return axolotlService;
396 }
397
398 public void initAccountServices(final XmppConnectionService context) {
399 this.axolotlService = new AxolotlService(this, context);
400 this.pgpDecryptionService = new PgpDecryptionService(context);
401 if (xmppConnection != null) {
402 xmppConnection.addOnAdvancedStreamFeaturesAvailableListener(axolotlService);
403 }
404 }
405
406 public PgpDecryptionService getPgpDecryptionService() {
407 return this.pgpDecryptionService;
408 }
409
410 public XmppConnection getXmppConnection() {
411 return this.xmppConnection;
412 }
413
414 public void setXmppConnection(final XmppConnection connection) {
415 this.xmppConnection = connection;
416 }
417
418 public String getRosterVersion() {
419 if (this.rosterVersion == null) {
420 return "";
421 } else {
422 return this.rosterVersion;
423 }
424 }
425
426 public void setRosterVersion(final String version) {
427 this.rosterVersion = version;
428 }
429
430 public int countPresences() {
431 return this.getSelfContact().getPresences().size();
432 }
433
434 public int activeDevicesWithRtpCapability() {
435 int i = 0;
436 for(Presence presence : getSelfContact().getPresences().getPresences()) {
437 if (RtpCapability.check(presence) != RtpCapability.Capability.NONE) {
438 i++;
439 }
440 }
441 return i;
442 }
443
444 public String getPgpSignature() {
445 return getKey(KEY_PGP_SIGNATURE);
446 }
447
448 public boolean setPgpSignature(String signature) {
449 return setKey(KEY_PGP_SIGNATURE, signature);
450 }
451
452 public boolean unsetPgpSignature() {
453 synchronized (this.keys) {
454 return keys.remove(KEY_PGP_SIGNATURE) != null;
455 }
456 }
457
458 public long getPgpId() {
459 synchronized (this.keys) {
460 if (keys.has(KEY_PGP_ID)) {
461 try {
462 return keys.getLong(KEY_PGP_ID);
463 } catch (JSONException e) {
464 return 0;
465 }
466 } else {
467 return 0;
468 }
469 }
470 }
471
472 public boolean setPgpSignId(long pgpID) {
473 synchronized (this.keys) {
474 try {
475 if (pgpID == 0) {
476 keys.remove(KEY_PGP_ID);
477 } else {
478 keys.put(KEY_PGP_ID, pgpID);
479 }
480 } catch (JSONException e) {
481 return false;
482 }
483 return true;
484 }
485 }
486
487 public Roster getRoster() {
488 return this.roster;
489 }
490
491 public Collection<Bookmark> getBookmarks() {
492 synchronized (this.bookmarks) {
493 return ImmutableList.copyOf(this.bookmarks.values());
494 }
495 }
496
497 public void setBookmarks(final Map<Jid, Bookmark> bookmarks) {
498 synchronized (this.bookmarks) {
499 this.bookmarks.clear();
500 this.bookmarks.putAll(bookmarks);
501 }
502 }
503
504 public void putBookmark(final Bookmark bookmark) {
505 synchronized (this.bookmarks) {
506 this.bookmarks.put(bookmark.getJid(), bookmark);
507 }
508 }
509
510 public void removeBookmark(Bookmark bookmark) {
511 synchronized (this.bookmarks) {
512 this.bookmarks.remove(bookmark.getJid());
513 }
514 }
515
516 public void removeBookmark(Jid jid) {
517 synchronized (this.bookmarks) {
518 this.bookmarks.remove(jid);
519 }
520 }
521
522 public Set<Jid> getBookmarkedJids() {
523 synchronized (this.bookmarks) {
524 return new HashSet<>(this.bookmarks.keySet());
525 }
526 }
527
528 public Bookmark getBookmark(final Jid jid) {
529 synchronized (this.bookmarks) {
530 return this.bookmarks.get(jid.asBareJid());
531 }
532 }
533
534 public boolean setAvatar(final String filename) {
535 if (this.avatar != null && this.avatar.equals(filename)) {
536 return false;
537 } else {
538 this.avatar = filename;
539 return true;
540 }
541 }
542
543 public String getAvatar() {
544 return this.avatar;
545 }
546
547 public void activateGracePeriod(final long duration) {
548 if (duration > 0) {
549 this.mEndGracePeriod = SystemClock.elapsedRealtime() + duration;
550 }
551 }
552
553 public void deactivateGracePeriod() {
554 this.mEndGracePeriod = 0L;
555 }
556
557 public boolean inGracePeriod() {
558 return SystemClock.elapsedRealtime() < this.mEndGracePeriod;
559 }
560
561 public String getShareableUri() {
562 List<XmppUri.Fingerprint> fingerprints = this.getFingerprints();
563 String uri = "xmpp:" + this.getJid().asBareJid().toEscapedString();
564 if (fingerprints.size() > 0) {
565 return XmppUri.getFingerprintUri(uri, fingerprints, ';');
566 } else {
567 return uri;
568 }
569 }
570
571 public String getShareableLink() {
572 List<XmppUri.Fingerprint> fingerprints = this.getFingerprints();
573 String uri = "https://conversations.im/i/" + XmppUri.lameUrlEncode(this.getJid().asBareJid().toEscapedString());
574 if (fingerprints.size() > 0) {
575 return XmppUri.getFingerprintUri(uri, fingerprints, '&');
576 } else {
577 return uri;
578 }
579 }
580
581 private List<XmppUri.Fingerprint> getFingerprints() {
582 ArrayList<XmppUri.Fingerprint> fingerprints = new ArrayList<>();
583 if (axolotlService == null) {
584 return fingerprints;
585 }
586 fingerprints.add(new XmppUri.Fingerprint(XmppUri.FingerprintType.OMEMO, axolotlService.getOwnFingerprint().substring(2), axolotlService.getOwnDeviceId()));
587 for (XmppAxolotlSession session : axolotlService.findOwnSessions()) {
588 if (session.getTrust().isVerified() && session.getTrust().isActive()) {
589 fingerprints.add(new XmppUri.Fingerprint(XmppUri.FingerprintType.OMEMO, session.getFingerprint().substring(2).replaceAll("\\s", ""), session.getRemoteAddress().getDeviceId()));
590 }
591 }
592 return fingerprints;
593 }
594
595 public boolean isBlocked(final ListItem contact) {
596 final Jid jid = contact.getJid();
597 return jid != null && (blocklist.contains(jid.asBareJid()) || blocklist.contains(jid.getDomain()));
598 }
599
600 public boolean isBlocked(final Jid jid) {
601 return jid != null && blocklist.contains(jid.asBareJid());
602 }
603
604 public Collection<Jid> getBlocklist() {
605 return this.blocklist;
606 }
607
608 public void clearBlocklist() {
609 getBlocklist().clear();
610 }
611
612 public boolean isOnlineAndConnected() {
613 return this.getStatus() == State.ONLINE && this.getXmppConnection() != null;
614 }
615
616 @Override
617 public int getAvatarBackgroundColor() {
618 return UIHelper.getColorForName(jid.asBareJid().toString());
619 }
620
621 @Override
622 public String getAvatarName() {
623 throw new IllegalStateException("This method should not be called");
624 }
625
626 public enum State {
627 DISABLED(false, false),
628 OFFLINE(false),
629 CONNECTING(false),
630 ONLINE(false),
631 NO_INTERNET(false),
632 UNAUTHORIZED,
633 TEMPORARY_AUTH_FAILURE,
634 SERVER_NOT_FOUND,
635 REGISTRATION_SUCCESSFUL(false),
636 REGISTRATION_FAILED(true, false),
637 REGISTRATION_WEB(true, false),
638 REGISTRATION_CONFLICT(true, false),
639 REGISTRATION_NOT_SUPPORTED(true, false),
640 REGISTRATION_PLEASE_WAIT(true, false),
641 REGISTRATION_INVALID_TOKEN(true,false),
642 REGISTRATION_PASSWORD_TOO_WEAK(true, false),
643 TLS_ERROR,
644 TLS_ERROR_DOMAIN,
645 INCOMPATIBLE_SERVER,
646 INCOMPATIBLE_CLIENT,
647 TOR_NOT_AVAILABLE,
648 DOWNGRADE_ATTACK,
649 SESSION_FAILURE,
650 BIND_FAILURE,
651 HOST_UNKNOWN,
652 STREAM_ERROR,
653 STREAM_OPENING_ERROR,
654 POLICY_VIOLATION,
655 PAYMENT_REQUIRED,
656 MISSING_INTERNET_PERMISSION(false);
657
658 private final boolean isError;
659 private final boolean attemptReconnect;
660
661 State(final boolean isError) {
662 this(isError, true);
663 }
664
665 State(final boolean isError, final boolean reconnect) {
666 this.isError = isError;
667 this.attemptReconnect = reconnect;
668 }
669
670 State() {
671 this(true, true);
672 }
673
674 public boolean isError() {
675 return this.isError;
676 }
677
678 public boolean isAttemptReconnect() {
679 return this.attemptReconnect;
680 }
681
682 public int getReadableId() {
683 switch (this) {
684 case DISABLED:
685 return R.string.account_status_disabled;
686 case ONLINE:
687 return R.string.account_status_online;
688 case CONNECTING:
689 return R.string.account_status_connecting;
690 case OFFLINE:
691 return R.string.account_status_offline;
692 case UNAUTHORIZED:
693 return R.string.account_status_unauthorized;
694 case SERVER_NOT_FOUND:
695 return R.string.account_status_not_found;
696 case NO_INTERNET:
697 return R.string.account_status_no_internet;
698 case REGISTRATION_FAILED:
699 return R.string.account_status_regis_fail;
700 case REGISTRATION_WEB:
701 return R.string.account_status_regis_web;
702 case REGISTRATION_CONFLICT:
703 return R.string.account_status_regis_conflict;
704 case REGISTRATION_SUCCESSFUL:
705 return R.string.account_status_regis_success;
706 case REGISTRATION_NOT_SUPPORTED:
707 return R.string.account_status_regis_not_sup;
708 case REGISTRATION_INVALID_TOKEN:
709 return R.string.account_status_regis_invalid_token;
710 case TLS_ERROR:
711 return R.string.account_status_tls_error;
712 case TLS_ERROR_DOMAIN:
713 return R.string.account_status_tls_error_domain;
714 case INCOMPATIBLE_SERVER:
715 return R.string.account_status_incompatible_server;
716 case INCOMPATIBLE_CLIENT:
717 return R.string.account_status_incompatible_client;
718 case TOR_NOT_AVAILABLE:
719 return R.string.account_status_tor_unavailable;
720 case BIND_FAILURE:
721 return R.string.account_status_bind_failure;
722 case SESSION_FAILURE:
723 return R.string.session_failure;
724 case DOWNGRADE_ATTACK:
725 return R.string.sasl_downgrade;
726 case HOST_UNKNOWN:
727 return R.string.account_status_host_unknown;
728 case POLICY_VIOLATION:
729 return R.string.account_status_policy_violation;
730 case REGISTRATION_PLEASE_WAIT:
731 return R.string.registration_please_wait;
732 case REGISTRATION_PASSWORD_TOO_WEAK:
733 return R.string.registration_password_too_weak;
734 case STREAM_ERROR:
735 return R.string.account_status_stream_error;
736 case STREAM_OPENING_ERROR:
737 return R.string.account_status_stream_opening_error;
738 case PAYMENT_REQUIRED:
739 return R.string.payment_required;
740 case MISSING_INTERNET_PERMISSION:
741 return R.string.missing_internet_permission;
742 case TEMPORARY_AUTH_FAILURE:
743 return R.string.account_status_temporary_auth_failure;
744 default:
745 return R.string.account_status_unknown;
746 }
747 }
748 }
749}