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