1package eu.siacs.conversations.entities;
2
3import android.content.ComponentName;
4import android.content.ContentValues;
5import android.content.Context;
6import android.database.Cursor;
7import android.graphics.drawable.Icon;
8import android.net.Uri;
9import android.os.Build;
10import android.os.Bundle;
11import android.telecom.PhoneAccount;
12import android.telecom.PhoneAccountHandle;
13import android.telecom.TelecomManager;
14import android.text.TextUtils;
15
16import androidx.annotation.NonNull;
17
18import com.google.common.base.Strings;
19
20import org.json.JSONArray;
21import org.json.JSONException;
22import org.json.JSONObject;
23
24import java.util.ArrayList;
25import java.util.Collection;
26import java.util.HashSet;
27import java.util.List;
28import java.util.Locale;
29import java.util.Objects;
30
31import eu.siacs.conversations.Config;
32import eu.siacs.conversations.R;
33import eu.siacs.conversations.android.AbstractPhoneContact;
34import eu.siacs.conversations.android.JabberIdContact;
35import eu.siacs.conversations.services.AvatarService;
36import eu.siacs.conversations.services.QuickConversationsService;
37import eu.siacs.conversations.services.XmppConnectionService;
38import eu.siacs.conversations.utils.JidHelper;
39import eu.siacs.conversations.utils.UIHelper;
40import eu.siacs.conversations.xml.Element;
41import eu.siacs.conversations.xmpp.Jid;
42import eu.siacs.conversations.xmpp.jingle.RtpCapability;
43import eu.siacs.conversations.xmpp.pep.Avatar;
44
45public class Contact implements ListItem, Blockable {
46 public static final String TABLENAME = "contacts";
47
48 public static final String SYSTEMNAME = "systemname";
49 public static final String SERVERNAME = "servername";
50 public static final String PRESENCE_NAME = "presence_name";
51 public static final String JID = "jid";
52 public static final String OPTIONS = "options";
53 public static final String SYSTEMACCOUNT = "systemaccount";
54 public static final String PHOTOURI = "photouri";
55 public static final String KEYS = "pgpkey";
56 public static final String ACCOUNT = "accountUuid";
57 public static final String AVATAR = "avatar";
58 public static final String LAST_PRESENCE = "last_presence";
59 public static final String LAST_TIME = "last_time";
60 public static final String GROUPS = "groups";
61 public static final String RTP_CAPABILITY = "rtpCapability";
62 private String accountUuid;
63 private String systemName;
64 private String serverName;
65 private String presenceName;
66 private String commonName;
67 protected Jid jid;
68 private int subscription = 0;
69 private Uri systemAccount;
70 private String photoUri;
71 private final JSONObject keys;
72 private JSONArray groups = new JSONArray();
73 private JSONArray systemTags = new JSONArray();
74 private final Presences presences = new Presences();
75 protected Account account;
76 protected Avatar avatar;
77
78 private boolean mActive = false;
79 private long mLastseen = 0;
80 private String mLastPresence = null;
81 private RtpCapability.Capability rtpCapability;
82
83 public Contact(final String account, final String systemName, final String serverName, final String presenceName,
84 final Jid jid, final int subscription, final String photoUri,
85 final Uri systemAccount, final String keys, final String avatar, final long lastseen,
86 final String presence, final String groups, final RtpCapability.Capability rtpCapability) {
87 this.accountUuid = account;
88 this.systemName = systemName;
89 this.serverName = serverName;
90 this.presenceName = presenceName;
91 this.jid = jid;
92 this.subscription = subscription;
93 this.photoUri = photoUri;
94 this.systemAccount = systemAccount;
95 JSONObject tmpJsonObject;
96 try {
97 tmpJsonObject = (keys == null ? new JSONObject("") : new JSONObject(keys));
98 } catch (JSONException e) {
99 tmpJsonObject = new JSONObject();
100 }
101 this.keys = tmpJsonObject;
102 if (avatar != null) {
103 this.avatar = new Avatar();
104 this.avatar.sha1sum = avatar;
105 this.avatar.origin = Avatar.Origin.VCARD; //always assume worst
106 }
107 try {
108 this.groups = (groups == null ? new JSONArray() : new JSONArray(groups));
109 } catch (JSONException e) {
110 this.groups = new JSONArray();
111 }
112 this.mLastseen = lastseen;
113 this.mLastPresence = presence;
114 this.rtpCapability = rtpCapability;
115 }
116
117 public Contact(final Jid jid) {
118 this.jid = jid;
119 this.keys = new JSONObject();
120 }
121
122 public static Contact fromCursor(final Cursor cursor) {
123 final Jid jid;
124 try {
125 jid = Jid.of(cursor.getString(cursor.getColumnIndex(JID)));
126 } catch (final IllegalArgumentException e) {
127 // TODO: Borked DB... handle this somehow?
128 return null;
129 }
130 Uri systemAccount;
131 try {
132 systemAccount = Uri.parse(cursor.getString(cursor.getColumnIndex(SYSTEMACCOUNT)));
133 } catch (Exception e) {
134 systemAccount = null;
135 }
136 return new Contact(cursor.getString(cursor.getColumnIndex(ACCOUNT)),
137 cursor.getString(cursor.getColumnIndex(SYSTEMNAME)),
138 cursor.getString(cursor.getColumnIndex(SERVERNAME)),
139 cursor.getString(cursor.getColumnIndex(PRESENCE_NAME)),
140 jid,
141 cursor.getInt(cursor.getColumnIndex(OPTIONS)),
142 cursor.getString(cursor.getColumnIndex(PHOTOURI)),
143 systemAccount,
144 cursor.getString(cursor.getColumnIndex(KEYS)),
145 cursor.getString(cursor.getColumnIndex(AVATAR)),
146 cursor.getLong(cursor.getColumnIndex(LAST_TIME)),
147 cursor.getString(cursor.getColumnIndex(LAST_PRESENCE)),
148 cursor.getString(cursor.getColumnIndex(GROUPS)),
149 RtpCapability.Capability.of(cursor.getString(cursor.getColumnIndex(RTP_CAPABILITY))));
150 }
151
152 public String getDisplayName() {
153 if (isSelf()) {
154 final String displayName = account.getDisplayName();
155 if (!Strings.isNullOrEmpty(displayName)) {
156 return displayName;
157 }
158 }
159 if (Config.X509_VERIFICATION && !TextUtils.isEmpty(this.commonName)) {
160 return this.commonName;
161 } else if (!TextUtils.isEmpty(this.systemName)) {
162 return this.systemName;
163 } else if (!TextUtils.isEmpty(this.serverName)) {
164 return this.serverName;
165 } else if (!TextUtils.isEmpty(this.presenceName) && ((QuickConversationsService.isQuicksy() && JidHelper.isQuicksyDomain(jid.getDomain())) || mutualPresenceSubscription())) {
166 return this.presenceName;
167 } else if (jid.getLocal() != null) {
168 return JidHelper.localPartOrFallback(jid);
169 } else {
170 return jid.getDomain().toEscapedString();
171 }
172 }
173
174 public String getPublicDisplayName() {
175 if (!TextUtils.isEmpty(this.presenceName)) {
176 return this.presenceName;
177 } else if (jid.getLocal() != null) {
178 return JidHelper.localPartOrFallback(jid);
179 } else {
180 return jid.getDomain().toEscapedString();
181 }
182 }
183
184 public String getProfilePhoto() {
185 return this.photoUri;
186 }
187
188 public Jid getJid() {
189 return jid;
190 }
191
192 @Override
193 public List<Tag> getTags(Context context) {
194 final ArrayList<Tag> tags = new ArrayList<>();
195 for (final String group : getGroups(true)) {
196 tags.add(new Tag(group, UIHelper.getColorForName(group)));
197 }
198 for (final String tag : getSystemTags(true)) {
199 tags.add(new Tag(tag, UIHelper.getColorForName(tag)));
200 }
201 Presence.Status status = getShownStatus();
202 if (status != Presence.Status.OFFLINE) {
203 tags.add(UIHelper.getTagForStatus(context, status));
204 }
205 if (isBlocked()) {
206 tags.add(new Tag(context.getString(R.string.blocked), 0xff2e2f3b));
207 }
208 if (!showInRoster() && getSystemAccount() != null) {
209 tags.add(new Tag("Android", UIHelper.getColorForName("Android")));
210 }
211 return tags;
212 }
213
214 public boolean match(Context context, String needle) {
215 if (TextUtils.isEmpty(needle)) {
216 return true;
217 }
218 needle = needle.toLowerCase(Locale.US).trim();
219 String[] parts = needle.split("\\s+");
220 if (parts.length > 1) {
221 for (String part : parts) {
222 if (!match(context, part)) {
223 return false;
224 }
225 }
226 return true;
227 } else {
228 return jid.toString().contains(needle) ||
229 getDisplayName().toLowerCase(Locale.US).contains(needle) ||
230 matchInTag(context, needle);
231 }
232 }
233
234 private boolean matchInTag(Context context, String needle) {
235 needle = needle.toLowerCase(Locale.US);
236 for (Tag tag : getTags(context)) {
237 if (tag.getName().toLowerCase(Locale.US).contains(needle)) {
238 return true;
239 }
240 }
241 return false;
242 }
243
244 public ContentValues getContentValues() {
245 synchronized (this.keys) {
246 final ContentValues values = new ContentValues();
247 values.put(ACCOUNT, accountUuid);
248 values.put(SYSTEMNAME, systemName);
249 values.put(SERVERNAME, serverName);
250 values.put(PRESENCE_NAME, presenceName);
251 values.put(JID, jid.toString());
252 values.put(OPTIONS, subscription);
253 values.put(SYSTEMACCOUNT, systemAccount != null ? systemAccount.toString() : null);
254 values.put(PHOTOURI, photoUri);
255 values.put(KEYS, keys.toString());
256 values.put(AVATAR, avatar == null ? null : avatar.getFilename());
257 values.put(LAST_PRESENCE, mLastPresence);
258 values.put(LAST_TIME, mLastseen);
259 values.put(GROUPS, groups.toString());
260 values.put(RTP_CAPABILITY, rtpCapability == null ? null : rtpCapability.toString());
261 return values;
262 }
263 }
264
265 public Account getAccount() {
266 return this.account;
267 }
268
269 public void setAccount(Account account) {
270 this.account = account;
271 this.accountUuid = account.getUuid();
272 }
273
274 public Presences getPresences() {
275 return this.presences;
276 }
277
278 public void updatePresence(final String resource, final Presence presence) {
279 this.presences.updatePresence(resource, presence);
280 }
281
282 public void removePresence(final String resource) {
283 this.presences.removePresence(resource);
284 }
285
286 public void clearPresences() {
287 this.presences.clearPresences();
288 this.resetOption(Options.PENDING_SUBSCRIPTION_REQUEST);
289 }
290
291 public Presence.Status getShownStatus() {
292 return this.presences.getShownStatus();
293 }
294
295 public boolean setPhotoUri(String uri) {
296 if (uri != null && !uri.equals(this.photoUri)) {
297 this.photoUri = uri;
298 return true;
299 } else if (this.photoUri != null && uri == null) {
300 this.photoUri = null;
301 return true;
302 } else {
303 return false;
304 }
305 }
306
307 public void setServerName(String serverName) {
308 this.serverName = serverName;
309 }
310
311 public boolean setSystemName(String systemName) {
312 final String old = getDisplayName();
313 this.systemName = systemName;
314 return !old.equals(getDisplayName());
315 }
316
317 public boolean setSystemTags(Collection<String> systemTags) {
318 final JSONArray old = this.systemTags;
319 this.systemTags = new JSONArray();
320 for(String tag : systemTags) {
321 this.systemTags.put(tag);
322 }
323 return !old.equals(this.systemTags);
324 }
325
326 public boolean setPresenceName(String presenceName) {
327 final String old = getDisplayName();
328 this.presenceName = presenceName;
329 return !old.equals(getDisplayName());
330 }
331
332 public Uri getSystemAccount() {
333 return systemAccount;
334 }
335
336 public void setSystemAccount(Uri lookupUri) {
337 this.systemAccount = lookupUri;
338 }
339
340 private Collection<String> getGroups(final boolean unique) {
341 final Collection<String> groups = unique ? new HashSet<>() : new ArrayList<>();
342 for (int i = 0; i < this.groups.length(); ++i) {
343 try {
344 groups.add(this.groups.getString(i));
345 } catch (final JSONException ignored) {
346 }
347 }
348 return groups;
349 }
350
351 private Collection<String> getSystemTags(final boolean unique) {
352 final Collection<String> tags = unique ? new HashSet<>() : new ArrayList<>();
353 for (int i = 0; i < this.systemTags.length(); ++i) {
354 try {
355 tags.add(this.systemTags.getString(i));
356 } catch (final JSONException ignored) {
357 }
358 }
359 return tags;
360 }
361
362 public long getPgpKeyId() {
363 synchronized (this.keys) {
364 if (this.keys.has("pgp_keyid")) {
365 try {
366 return this.keys.getLong("pgp_keyid");
367 } catch (JSONException e) {
368 return 0;
369 }
370 } else {
371 return 0;
372 }
373 }
374 }
375
376 public boolean setPgpKeyId(long keyId) {
377 final long previousKeyId = getPgpKeyId();
378 synchronized (this.keys) {
379 try {
380 this.keys.put("pgp_keyid", keyId);
381 return previousKeyId != keyId;
382 } catch (final JSONException ignored) {
383 }
384 }
385 return false;
386 }
387
388 public void setOption(int option) {
389 this.subscription |= 1 << option;
390 }
391
392 public void resetOption(int option) {
393 this.subscription &= ~(1 << option);
394 }
395
396 public boolean getOption(int option) {
397 return ((this.subscription & (1 << option)) != 0);
398 }
399
400 public boolean showInRoster() {
401 return (this.getOption(Contact.Options.IN_ROSTER) && (!this
402 .getOption(Contact.Options.DIRTY_DELETE)))
403 || (this.getOption(Contact.Options.DIRTY_PUSH));
404 }
405
406 public boolean showInContactList() {
407 return showInRoster()
408 || getOption(Options.SYNCED_VIA_OTHER)
409 || (QuickConversationsService.isQuicksy() && systemAccount != null);
410 }
411
412 public void parseSubscriptionFromElement(Element item) {
413 String ask = item.getAttribute("ask");
414 String subscription = item.getAttribute("subscription");
415
416 if (subscription == null) {
417 this.resetOption(Options.FROM);
418 this.resetOption(Options.TO);
419 } else {
420 switch (subscription) {
421 case "to":
422 this.resetOption(Options.FROM);
423 this.setOption(Options.TO);
424 break;
425 case "from":
426 this.resetOption(Options.TO);
427 this.setOption(Options.FROM);
428 this.resetOption(Options.PREEMPTIVE_GRANT);
429 this.resetOption(Options.PENDING_SUBSCRIPTION_REQUEST);
430 break;
431 case "both":
432 this.setOption(Options.TO);
433 this.setOption(Options.FROM);
434 this.resetOption(Options.PREEMPTIVE_GRANT);
435 this.resetOption(Options.PENDING_SUBSCRIPTION_REQUEST);
436 break;
437 case "none":
438 this.resetOption(Options.FROM);
439 this.resetOption(Options.TO);
440 break;
441 }
442 }
443
444 // do NOT override asking if pending push request
445 if (!this.getOption(Contact.Options.DIRTY_PUSH)) {
446 if ((ask != null) && (ask.equals("subscribe"))) {
447 this.setOption(Contact.Options.ASKING);
448 } else {
449 this.resetOption(Contact.Options.ASKING);
450 }
451 }
452 }
453
454 public void parseGroupsFromElement(Element item) {
455 this.groups = new JSONArray();
456 for (Element element : item.getChildren()) {
457 if (element.getName().equals("group") && element.getContent() != null) {
458 this.groups.put(element.getContent());
459 }
460 }
461 }
462
463 public Element asElement() {
464 final Element item = new Element("item");
465 item.setAttribute("jid", this.jid);
466 if (this.serverName != null) {
467 item.setAttribute("name", this.serverName);
468 }
469 for (String group : getGroups(false)) {
470 item.addChild("group").setContent(group);
471 }
472 return item;
473 }
474
475 @Override
476 public int compareTo(@NonNull final ListItem another) {
477 return this.getDisplayName().compareToIgnoreCase(
478 another.getDisplayName());
479 }
480
481 public String getServer() {
482 return getJid().getDomain().toEscapedString();
483 }
484
485 public void setAvatar(Avatar avatar) {
486 setAvatar(avatar, false);
487 }
488
489 public void setAvatar(Avatar avatar, boolean previouslyOmittedPepFetch) {
490 if (this.avatar != null && this.avatar.equals(avatar)) {
491 return;
492 }
493 if (!previouslyOmittedPepFetch && this.avatar != null && this.avatar.origin == Avatar.Origin.PEP && avatar.origin == Avatar.Origin.VCARD) {
494 return;
495 }
496 this.avatar = avatar;
497 }
498
499 public String getAvatarFilename() {
500 return avatar == null ? null : avatar.getFilename();
501 }
502
503 public Avatar getAvatar() {
504 return avatar;
505 }
506
507 public boolean mutualPresenceSubscription() {
508 return getOption(Options.FROM) && getOption(Options.TO);
509 }
510
511 @Override
512 public boolean isBlocked() {
513 return getAccount().isBlocked(this);
514 }
515
516 @Override
517 public boolean isDomainBlocked() {
518 return getAccount().isBlocked(this.getJid().getDomain());
519 }
520
521 @Override
522 public Jid getBlockedJid() {
523 if (isDomainBlocked()) {
524 return getJid().getDomain();
525 } else {
526 return getJid();
527 }
528 }
529
530 public boolean isSelf() {
531 return account.getJid().asBareJid().equals(jid.asBareJid());
532 }
533
534 boolean isOwnServer() {
535 return account.getJid().getDomain().equals(jid.asBareJid());
536 }
537
538 public void setCommonName(String cn) {
539 this.commonName = cn;
540 }
541
542 public void flagActive() {
543 this.mActive = true;
544 }
545
546 public void flagInactive() {
547 this.mActive = false;
548 }
549
550 public boolean isActive() {
551 return this.mActive;
552 }
553
554 public boolean setLastseen(long timestamp) {
555 if (timestamp > this.mLastseen) {
556 this.mLastseen = timestamp;
557 return true;
558 } else {
559 return false;
560 }
561 }
562
563 public long getLastseen() {
564 return this.mLastseen;
565 }
566
567 public void setLastResource(String resource) {
568 this.mLastPresence = resource;
569 }
570
571 public String getLastResource() {
572 return this.mLastPresence;
573 }
574
575 public String getServerName() {
576 return serverName;
577 }
578
579 public synchronized boolean setPhoneContact(AbstractPhoneContact phoneContact) {
580 setOption(getOption(phoneContact.getClass()));
581 setSystemAccount(phoneContact.getLookupUri());
582 boolean changed = setSystemName(phoneContact.getDisplayName());
583 changed |= setPhotoUri(phoneContact.getPhotoUri());
584 return changed;
585 }
586
587 public synchronized boolean unsetPhoneContact(Class<? extends AbstractPhoneContact> clazz) {
588 resetOption(getOption(clazz));
589 boolean changed = false;
590 if (!getOption(Options.SYNCED_VIA_ADDRESSBOOK) && !getOption(Options.SYNCED_VIA_OTHER)) {
591 setSystemAccount(null);
592 changed |= setPhotoUri(null);
593 changed |= setSystemName(null);
594 }
595 return changed;
596 }
597
598 protected String phoneAccountLabel() {
599 return account.getJid().asBareJid().toString() +
600 "/" + getJid().asBareJid().toString();
601 }
602
603 public PhoneAccountHandle phoneAccountHandle() {
604 ComponentName componentName = new ComponentName(
605 "com.cheogram.android",
606 "com.cheogram.android.ConnectionService"
607 );
608 return new PhoneAccountHandle(componentName, phoneAccountLabel());
609 }
610
611 // This Contact is a gateway to use for voice calls, register it with OS
612 public void registerAsPhoneAccount(XmppConnectionService ctx) {
613 if (Build.VERSION.SDK_INT < 23) return;
614
615 TelecomManager telecomManager = ctx.getSystemService(TelecomManager.class);
616
617 PhoneAccount phoneAccount = PhoneAccount.builder(
618 phoneAccountHandle(),
619 account.getJid().asBareJid().toString()
620 ).setAddress(
621 Uri.fromParts("xmpp", account.getJid().asBareJid().toString(), null)
622 ).setIcon(
623 Icon.createWithBitmap(ctx.getAvatarService().get(this, AvatarService.getSystemUiAvatarSize(ctx) / 2, false))
624 ).setHighlightColor(
625 0x7401CF
626 ).setShortDescription(
627 getJid().asBareJid().toString()
628 ).setCapabilities(
629 PhoneAccount.CAPABILITY_CALL_PROVIDER
630 ).build();
631
632 telecomManager.registerPhoneAccount(phoneAccount);
633 }
634
635 // Unregister any associated PSTN gateway integration
636 public void unregisterAsPhoneAccount(Context ctx) {
637 if (Build.VERSION.SDK_INT < 23) return;
638
639 TelecomManager telecomManager = ctx.getSystemService(TelecomManager.class);
640 telecomManager.unregisterPhoneAccount(phoneAccountHandle());
641 }
642
643 public static int getOption(Class<? extends AbstractPhoneContact> clazz) {
644 if (clazz == JabberIdContact.class) {
645 return Options.SYNCED_VIA_ADDRESSBOOK;
646 } else {
647 return Options.SYNCED_VIA_OTHER;
648 }
649 }
650
651 @Override
652 public int getAvatarBackgroundColor() {
653 return UIHelper.getColorForName(jid != null ? jid.asBareJid().toString() : getDisplayName());
654 }
655
656 @Override
657 public String getAvatarName() {
658 return getDisplayName();
659 }
660
661 public boolean hasAvatarOrPresenceName() {
662 return (avatar != null && avatar.getFilename() != null) || presenceName != null;
663 }
664
665 public boolean refreshRtpCapability() {
666 final RtpCapability.Capability previous = this.rtpCapability;
667 this.rtpCapability = RtpCapability.check(this, false);
668 return !Objects.equals(previous, this.rtpCapability);
669 }
670
671 public RtpCapability.Capability getRtpCapability() {
672 return this.rtpCapability == null ? RtpCapability.Capability.NONE : this.rtpCapability;
673 }
674
675 public static final class Options {
676 public static final int TO = 0;
677 public static final int FROM = 1;
678 public static final int ASKING = 2;
679 public static final int PREEMPTIVE_GRANT = 3;
680 public static final int IN_ROSTER = 4;
681 public static final int PENDING_SUBSCRIPTION_REQUEST = 5;
682 public static final int DIRTY_PUSH = 6;
683 public static final int DIRTY_DELETE = 7;
684 private static final int SYNCED_VIA_ADDRESSBOOK = 8;
685 public static final int SYNCED_VIA_OTHER = 9;
686 }
687}