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