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 {
235 return jid.toString().contains(parts[0]) ||
236 getDisplayName().toLowerCase(Locale.US).contains(parts[0]) ||
237 matchInTag(context, parts[0]);
238 }
239 }
240
241 private boolean matchInTag(Context context, String needle) {
242 needle = needle.toLowerCase(Locale.US);
243 for (Tag tag : getTags(context)) {
244 if (tag.getName().toLowerCase(Locale.US).contains(needle)) {
245 return true;
246 }
247 }
248 return false;
249 }
250
251 public ContentValues getContentValues() {
252 synchronized (this.keys) {
253 final ContentValues values = new ContentValues();
254 values.put(ACCOUNT, accountUuid);
255 values.put(SYSTEMNAME, systemName);
256 values.put(SERVERNAME, serverName);
257 values.put(PRESENCE_NAME, presenceName);
258 values.put(JID, jid.toString());
259 values.put(OPTIONS, subscription);
260 values.put(SYSTEMACCOUNT, systemAccount != null ? systemAccount.toString() : null);
261 values.put(PHOTOURI, photoUri);
262 values.put(KEYS, keys.toString());
263 values.put(AVATAR, avatar == null ? null : avatar.getFilename());
264 values.put(LAST_PRESENCE, mLastPresence);
265 values.put(LAST_TIME, mLastseen);
266 values.put(GROUPS, groups.toString());
267 values.put(RTP_CAPABILITY, rtpCapability == null ? null : rtpCapability.toString());
268 return values;
269 }
270 }
271
272 public Account getAccount() {
273 return this.account;
274 }
275
276 public void setAccount(Account account) {
277 this.account = account;
278 this.accountUuid = account.getUuid();
279 }
280
281 public Presences getPresences() {
282 return this.presences;
283 }
284
285 public void updatePresence(final String resource, final Presence presence) {
286 this.presences.updatePresence(resource, presence);
287 }
288
289 public void removePresence(final String resource) {
290 this.presences.removePresence(resource);
291 }
292
293 public void clearPresences() {
294 this.presences.clearPresences();
295 this.resetOption(Options.PENDING_SUBSCRIPTION_REQUEST);
296 }
297
298 public Presence.Status getShownStatus() {
299 return this.presences.getShownStatus();
300 }
301
302 public Jid resourceWhichSupport(final String namespace) {
303 final String resource = getPresences().firstWhichSupport(namespace);
304 if (resource == null) return null;
305
306 return resource.equals("") ? getJid() : getJid().withResource(resource);
307 }
308
309 public boolean setPhotoUri(String uri) {
310 if (uri != null && !uri.equals(this.photoUri)) {
311 this.photoUri = uri;
312 return true;
313 } else if (this.photoUri != null && uri == null) {
314 this.photoUri = null;
315 return true;
316 } else {
317 return false;
318 }
319 }
320
321 public void setServerName(String serverName) {
322 this.serverName = serverName;
323 }
324
325 public boolean setSystemName(String systemName) {
326 final String old = getDisplayName();
327 this.systemName = systemName;
328 return !old.equals(getDisplayName());
329 }
330
331 public boolean setSystemTags(Collection<String> systemTags) {
332 final JSONArray old = this.systemTags;
333 this.systemTags = new JSONArray();
334 for(String tag : systemTags) {
335 this.systemTags.put(tag);
336 }
337 return !old.equals(this.systemTags);
338 }
339
340 public boolean setPresenceName(String presenceName) {
341 final String old = getDisplayName();
342 this.presenceName = presenceName;
343 return !old.equals(getDisplayName());
344 }
345
346 public Uri getSystemAccount() {
347 return systemAccount;
348 }
349
350 public void setSystemAccount(Uri lookupUri) {
351 this.systemAccount = lookupUri;
352 }
353
354 public void setGroups(List<String> groups) {
355 this.groups = new JSONArray(groups);
356 }
357
358 private Collection<String> getGroups(final boolean unique) {
359 final Collection<String> groups = unique ? new HashSet<>() : new ArrayList<>();
360 for (int i = 0; i < this.groups.length(); ++i) {
361 try {
362 groups.add(this.groups.getString(i));
363 } catch (final JSONException ignored) {
364 }
365 }
366 return groups;
367 }
368
369 private Collection<String> getSystemTags(final boolean unique) {
370 final Collection<String> tags = unique ? new HashSet<>() : new ArrayList<>();
371 for (int i = 0; i < this.systemTags.length(); ++i) {
372 try {
373 tags.add(this.systemTags.getString(i));
374 } catch (final JSONException ignored) {
375 }
376 }
377 return tags;
378 }
379
380 public long getPgpKeyId() {
381 synchronized (this.keys) {
382 if (this.keys.has("pgp_keyid")) {
383 try {
384 return this.keys.getLong("pgp_keyid");
385 } catch (JSONException e) {
386 return 0;
387 }
388 } else {
389 return 0;
390 }
391 }
392 }
393
394 public boolean setPgpKeyId(long keyId) {
395 final long previousKeyId = getPgpKeyId();
396 synchronized (this.keys) {
397 try {
398 this.keys.put("pgp_keyid", keyId);
399 return previousKeyId != keyId;
400 } catch (final JSONException ignored) {
401 }
402 }
403 return false;
404 }
405
406 public void setOption(int option) {
407 this.subscription |= 1 << option;
408 }
409
410 public void resetOption(int option) {
411 this.subscription &= ~(1 << option);
412 }
413
414 public boolean getOption(int option) {
415 return ((this.subscription & (1 << option)) != 0);
416 }
417
418 public boolean canInferPresence() {
419 return showInContactList() || isSelf();
420 }
421
422 public boolean showInRoster() {
423 return (this.getOption(Contact.Options.IN_ROSTER) && (!this
424 .getOption(Contact.Options.DIRTY_DELETE)))
425 || (this.getOption(Contact.Options.DIRTY_PUSH));
426 }
427
428 public boolean showInContactList() {
429 return showInRoster()
430 || getOption(Options.SYNCED_VIA_OTHER)
431 || (QuickConversationsService.isQuicksy() && systemAccount != null);
432 }
433
434 public void parseSubscriptionFromElement(Element item) {
435 String ask = item.getAttribute("ask");
436 String subscription = item.getAttribute("subscription");
437
438 if (subscription == null) {
439 this.resetOption(Options.FROM);
440 this.resetOption(Options.TO);
441 } else {
442 switch (subscription) {
443 case "to":
444 this.resetOption(Options.FROM);
445 this.setOption(Options.TO);
446 break;
447 case "from":
448 this.resetOption(Options.TO);
449 this.setOption(Options.FROM);
450 this.resetOption(Options.PREEMPTIVE_GRANT);
451 this.resetOption(Options.PENDING_SUBSCRIPTION_REQUEST);
452 break;
453 case "both":
454 this.setOption(Options.TO);
455 this.setOption(Options.FROM);
456 this.resetOption(Options.PREEMPTIVE_GRANT);
457 this.resetOption(Options.PENDING_SUBSCRIPTION_REQUEST);
458 break;
459 case "none":
460 this.resetOption(Options.FROM);
461 this.resetOption(Options.TO);
462 break;
463 }
464 }
465
466 // do NOT override asking if pending push request
467 if (!this.getOption(Contact.Options.DIRTY_PUSH)) {
468 if ((ask != null) && (ask.equals("subscribe"))) {
469 this.setOption(Contact.Options.ASKING);
470 } else {
471 this.resetOption(Contact.Options.ASKING);
472 }
473 }
474 }
475
476 public void parseGroupsFromElement(Element item) {
477 this.groups = new JSONArray();
478 for (Element element : item.getChildren()) {
479 if (element.getName().equals("group") && element.getContent() != null) {
480 this.groups.put(element.getContent());
481 }
482 }
483 }
484
485 public Element asElement() {
486 final Element item = new Element("item");
487 item.setAttribute("jid", this.jid);
488 if (this.serverName != null) {
489 item.setAttribute("name", this.serverName);
490 }
491 for (String group : getGroups(false)) {
492 item.addChild("group").setContent(group);
493 }
494 return item;
495 }
496
497 @Override
498 public int compareTo(@NonNull final ListItem another) {
499 return this.getDisplayName().compareToIgnoreCase(
500 another.getDisplayName());
501 }
502
503 public String getServer() {
504 return getJid().getDomain().toEscapedString();
505 }
506
507 public void setAvatar(Avatar avatar) {
508 setAvatar(avatar, false);
509 }
510
511 public void setAvatar(Avatar avatar, boolean previouslyOmittedPepFetch) {
512 if (this.avatar != null && this.avatar.equals(avatar)) {
513 return;
514 }
515 if (!previouslyOmittedPepFetch && this.avatar != null && this.avatar.origin == Avatar.Origin.PEP && avatar.origin == Avatar.Origin.VCARD) {
516 return;
517 }
518 this.avatar = avatar;
519 }
520
521 public String getAvatarFilename() {
522 return avatar == null ? null : avatar.getFilename();
523 }
524
525 public Avatar getAvatar() {
526 return avatar;
527 }
528
529 public boolean mutualPresenceSubscription() {
530 return getOption(Options.FROM) && getOption(Options.TO);
531 }
532
533 @Override
534 public boolean isBlocked() {
535 return getAccount().isBlocked(this);
536 }
537
538 @Override
539 public boolean isDomainBlocked() {
540 return getAccount().isBlocked(this.getJid().getDomain());
541 }
542
543 @Override
544 public Jid getBlockedJid() {
545 if (isDomainBlocked()) {
546 return getJid().getDomain();
547 } else {
548 return getJid();
549 }
550 }
551
552 public boolean isSelf() {
553 return account.getJid().asBareJid().equals(jid.asBareJid());
554 }
555
556 boolean isOwnServer() {
557 return account.getJid().getDomain().equals(jid.asBareJid());
558 }
559
560 public void setCommonName(String cn) {
561 this.commonName = cn;
562 }
563
564 public void flagActive() {
565 this.mActive = true;
566 }
567
568 public void flagInactive() {
569 this.mActive = false;
570 }
571
572 public boolean isActive() {
573 return this.mActive;
574 }
575
576 public boolean setLastseen(long timestamp) {
577 if (timestamp > this.mLastseen) {
578 this.mLastseen = timestamp;
579 return true;
580 } else {
581 return false;
582 }
583 }
584
585 public long getLastseen() {
586 return this.mLastseen;
587 }
588
589 public void setLastResource(String resource) {
590 this.mLastPresence = resource;
591 }
592
593 public String getLastResource() {
594 return this.mLastPresence;
595 }
596
597 public String getServerName() {
598 return serverName;
599 }
600
601 public synchronized boolean setPhoneContact(AbstractPhoneContact phoneContact) {
602 setOption(getOption(phoneContact.getClass()));
603 setSystemAccount(phoneContact.getLookupUri());
604 boolean changed = setSystemName(phoneContact.getDisplayName());
605 changed |= setPhotoUri(phoneContact.getPhotoUri());
606 return changed;
607 }
608
609 public synchronized boolean unsetPhoneContact(Class<? extends AbstractPhoneContact> clazz) {
610 resetOption(getOption(clazz));
611 boolean changed = false;
612 if (!getOption(Options.SYNCED_VIA_ADDRESSBOOK) && !getOption(Options.SYNCED_VIA_OTHER)) {
613 setSystemAccount(null);
614 changed |= setPhotoUri(null);
615 changed |= setSystemName(null);
616 }
617 return changed;
618 }
619
620 protected String phoneAccountLabel() {
621 return account.getJid().asBareJid().toString() +
622 "/" + getJid().asBareJid().toString();
623 }
624
625 public PhoneAccountHandle phoneAccountHandle() {
626 ComponentName componentName = new ComponentName(
627 "com.cheogram.android",
628 "com.cheogram.android.ConnectionService"
629 );
630 return new PhoneAccountHandle(componentName, phoneAccountLabel());
631 }
632
633 // This Contact is a gateway to use for voice calls, register it with OS
634 public void registerAsPhoneAccount(XmppConnectionService ctx) {
635 if (Build.VERSION.SDK_INT < 23) return;
636 if (Build.VERSION.SDK_INT >= 33) {
637 if (!ctx.getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELECOM)) return;
638 } else {
639 if (!ctx.getPackageManager().hasSystemFeature(PackageManager.FEATURE_CONNECTION_SERVICE)) return;
640 }
641
642 TelecomManager telecomManager = ctx.getSystemService(TelecomManager.class);
643
644 PhoneAccount phoneAccount = PhoneAccount.builder(
645 phoneAccountHandle(),
646 account.getJid().asBareJid().toString()
647 ).setAddress(
648 Uri.fromParts("xmpp", account.getJid().asBareJid().toString(), null)
649 ).setIcon(
650 Icon.createWithBitmap(ctx.getAvatarService().get(this, AvatarService.getSystemUiAvatarSize(ctx) / 2, false))
651 ).setHighlightColor(
652 0x7401CF
653 ).setShortDescription(
654 getJid().asBareJid().toString()
655 ).setCapabilities(
656 PhoneAccount.CAPABILITY_CALL_PROVIDER
657 ).build();
658
659 telecomManager.registerPhoneAccount(phoneAccount);
660 }
661
662 // Unregister any associated PSTN gateway integration
663 public void unregisterAsPhoneAccount(Context ctx) {
664 if (Build.VERSION.SDK_INT < 23) return;
665 if (!ctx.getPackageManager().hasSystemFeature(PackageManager.FEATURE_CONNECTION_SERVICE)) return;
666
667 TelecomManager telecomManager = ctx.getSystemService(TelecomManager.class);
668 telecomManager.unregisterPhoneAccount(phoneAccountHandle());
669 }
670
671 public static int getOption(Class<? extends AbstractPhoneContact> clazz) {
672 if (clazz == JabberIdContact.class) {
673 return Options.SYNCED_VIA_ADDRESSBOOK;
674 } else {
675 return Options.SYNCED_VIA_OTHER;
676 }
677 }
678
679 @Override
680 public int getAvatarBackgroundColor() {
681 return UIHelper.getColorForName(jid != null ? jid.asBareJid().toString() : getDisplayName());
682 }
683
684 @Override
685 public String getAvatarName() {
686 return getDisplayName();
687 }
688
689 public boolean hasAvatarOrPresenceName() {
690 return (avatar != null && avatar.getFilename() != null) || presenceName != null;
691 }
692
693 public boolean refreshRtpCapability() {
694 final RtpCapability.Capability previous = this.rtpCapability;
695 this.rtpCapability = RtpCapability.check(this, false);
696 return !Objects.equals(previous, this.rtpCapability);
697 }
698
699 public RtpCapability.Capability getRtpCapability() {
700 return this.rtpCapability == null ? RtpCapability.Capability.NONE : this.rtpCapability;
701 }
702
703 public static final class Options {
704 public static final int TO = 0;
705 public static final int FROM = 1;
706 public static final int ASKING = 2;
707 public static final int PREEMPTIVE_GRANT = 3;
708 public static final int IN_ROSTER = 4;
709 public static final int PENDING_SUBSCRIPTION_REQUEST = 5;
710 public static final int DIRTY_PUSH = 6;
711 public static final int DIRTY_DELETE = 7;
712 private static final int SYNCED_VIA_ADDRESSBOOK = 8;
713 public static final int SYNCED_VIA_OTHER = 9;
714 }
715}