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