/*
* Kontalk Android client
* Copyright (C) 2017 Kontalk Devteam <devteam@kontalk.org>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.kontalk.data;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import com.amulyakhare.textdrawable.TextDrawable;
import com.amulyakhare.textdrawable.util.ColorGenerator;
import org.jxmpp.util.XmppStringUtils;
import org.spongycastle.openpgp.PGPPublicKeyRing;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.database.ContentObserver;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Handler;
import android.provider.ContactsContract;
import android.provider.ContactsContract.Contacts;
import android.provider.ContactsContract.PhoneLookup;
import android.support.annotation.NonNull;
import android.support.v4.util.LruCache;
import org.kontalk.Log;
import org.kontalk.R;
import org.kontalk.crypto.PGPLazyPublicKeyRingLoader;
import org.kontalk.provider.Keyring;
import org.kontalk.provider.MyUsers.Keys;
import org.kontalk.provider.MyUsers.Users;
import org.kontalk.util.MessageUtils;
import org.kontalk.util.Preferences;
/**
* A simple contact.
* @author Daniele Ricci
*/
public class Contact {
final static String TAG = Contact.class.getSimpleName();
private final static String[] ALL_CONTACTS_PROJECTION = {
Users._ID,
Users.CONTACT_ID,
Users.LOOKUP_KEY,
Users.DISPLAY_NAME,
Users.NUMBER,
Users.JID,
Users.REGISTERED,
Users.STATUS,
Users.BLOCKED,
};
public static final int COLUMN_ID = 0;
public static final int COLUMN_CONTACT_ID = 1;
public static final int COLUMN_LOOKUP_KEY = 2;
public static final int COLUMN_DISPLAY_NAME = 3;
public static final int COLUMN_NUMBER = 4;
public static final int COLUMN_JID = 5;
public static final int COLUMN_REGISTERED = 6;
public static final int COLUMN_STATUS = 7;
public static final int COLUMN_BLOCKED = 8;
/** The aggregated Contact id identified by this object. */
private final long mContactId;
private String mNumber;
private String mName;
private String mJID;
private String mLookupKey;
private Uri mContactUri;
private boolean mRegistered;
private String mStatus;
private boolean mBlocked;
private Drawable mAvatar;
private byte [] mAvatarData;
private String mFingerprint;
private PGPLazyPublicKeyRingLoader mTrustedKeyRing;
// trust level for the above trusted keyring
private int mTrustedLevel;
/** Timestamp the user was last seen. Not coming from the database. */
private long mLastSeen;
public interface ContactCallback {
void avatarLoaded(Contact contact, Drawable avatar);
}
public interface ContactChangeListener {
void onContactInvalidated(String userId);
}
private static final Set<ContactChangeListener> sListeners = new HashSet<>();
/**
* Contact cache.
* @author Daniele Ricci
*/
private final static class ContactCache extends LruCache<String, Contact> {
private static final int MAX_ENTRIES = 20;
public ContactCache() {
super(MAX_ENTRIES);
}
public synchronized Contact get(Context context, String userId, String numberHint) {
Contact c = get(userId);
if (c == null) {
c = _findByUserId(context, userId);
if (c != null) {
// put the contact in the cache
put(userId, c);
}
// try system contacts lookup
else if (numberHint != null) {
Log.v(TAG, "contact not found, trying with system contacts (" + numberHint + ")");
ContentResolver resolver = context.getContentResolver();
Uri uri = Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, Uri.encode(numberHint));
Cursor cur = resolver.query(uri, new String[] {
PhoneLookup.DISPLAY_NAME,
PhoneLookup.LOOKUP_KEY,
PhoneLookup._ID,
}, null, null, null);
if (cur.moveToFirst()) {
String name = cur.getString(0);
String lookupKey = cur.getString(1);
long cid = cur.getLong(2);
c = new Contact(cid, lookupKey, name, numberHint, userId, false);
put(userId, c);
// insert result into users database immediately
ContentValues values = new ContentValues(5);
values.put(Users.NUMBER, numberHint);
values.put(Users.DISPLAY_NAME, name);
values.put(Users.JID, userId);
values.put(Users.LOOKUP_KEY, lookupKey);
values.put(Users.CONTACT_ID, cid);
resolver.insert(Users.CONTENT_URI, values);
}
cur.close();
}
}
return c;
}
}
private final static ContactCache cache = new ContactCache();
/** Stores volatile and connection-time information about a contact. */
private static final class ContactState {
private final String mJID;
private boolean mTyping;
/** Version information. Not coming from the database. */
private String mVersion;
ContactState(String jid) {
mJID = jid;
}
public boolean isTyping() {
return mTyping;
}
public void setTyping(boolean typing) {
mTyping = typing;
}
public String getVersion() {
return mVersion;
}
public void setVersion(String version) {
mVersion = version;
}
@Override
public boolean equals(Object o) {
return o == this ||
(o instanceof ContactState &&
((ContactState) o).mJID.equals(mJID));
}
@Override
public int hashCode() {
return mJID.hashCode();
}
}
// keys is the full JID because typing is not a global but a device state
private static final Map<String, ContactState> sStates = new HashMap<>();
public static void init(Context context, Handler handler) {
context.getContentResolver().registerContentObserver(Contacts.CONTENT_URI, false,
new ContentObserver(handler) {
@Override
public void onChange(boolean selfChange) {
invalidate();
}
}
);
}
private static ContactState getContactState(String jid) {
ContactState state = sStates.get(jid);
if (state == null) {
state = new ContactState(jid);
sStates.put(jid, state);
}
return state;
}
public static void setTyping(String jid, boolean typing) {
getContactState(jid).setTyping(typing);
}
public static boolean isTyping(String jid) {
ContactState state = sStates.get(jid);
return state != null && state.isTyping();
}
public static void setVersion(String jid, String version) {
getContactState(jid).setVersion(version);
}
public static String getVersion(String jid) {
ContactState state = sStates.get(jid);
return state != null ? state.getVersion() : null;
}
public static void clearState(String jid) {
sStates.remove(jid);
}
Contact(long contactId, String lookupKey, String name, String number, String jid, boolean blocked) {
mContactId = contactId;
mLookupKey = lookupKey;
mName = name;
mNumber = number;
mJID = jid;
mBlocked = blocked;
}
/** Returns the {@link Contacts} {@link Uri} identified by this object. */
public Uri getUri() {
if (mContactUri == null) {
if (mLookupKey != null) {
mContactUri = ContactsContract.Contacts.getLookupUri(mContactId, mLookupKey);
}
else if (mContactId > 0) {
mContactUri = ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, mContactId);
}
}
return mContactUri;
}
public long getId() {
return mContactId;
}
public String getNumber() {
return mNumber;
}
public String getName() {
return mName;
}
/** Returns a visible and readable name that can be used across the UI. */
public String getDisplayName() {
if (mName != null && mName.length() > 0)
return mName;
if (mNumber != null && mNumber.length() > 0)
return mNumber;
return mJID;
}
public String getJID() {
return mJID;
}
public boolean isRegistered() {
return mRegistered;
}
public String getStatus() {
return mStatus;
}
public boolean isBlocked() {
return mBlocked;
}
public PGPPublicKeyRing getTrustedPublicKeyRing() {
try {
if (mTrustedKeyRing != null)
return mTrustedKeyRing.getPublicKeyRing();
}
catch (Exception e) {
// ignored for now
Log.w(TAG, "unable to load public keyring", e);
}
return null;
}
public String getTrustedFingerprint() {
try {
if (mTrustedKeyRing != null)
return mTrustedKeyRing.getFingerprint();
}
catch (Exception e) {
// ignored for now
Log.w(TAG, "unable to load public keyring", e);
}
return null;
}
public int getTrustedLevel() {
return mTrustedLevel;
}
public String getFingerprint() {
return mFingerprint;
}
/** Returns true if the key is unknown, i.e. no key was trusted yet. */
public boolean isKeyUnknown() {
return mTrustedKeyRing == null;
}
/** Returns true if the key has changed and not approved yet. */
public boolean isKeyChanged() {
String trustedFingerprint = getTrustedFingerprint();
return (trustedFingerprint == null || mFingerprint == null) ||
!mFingerprint.equals(trustedFingerprint);
}
public long getLastSeen() {
return mLastSeen;
}
public void setLastSeen(long lastSeen) {
mLastSeen = lastSeen;
}
private static Drawable generateRandomAvatar(Context context, Contact contact) {
String letter = (contact.mName != null && contact.mName.length() > 0) ?
contact.mName : contact.mJID;
int size = context.getResources().getDimensionPixelSize(R.dimen.avatar_size);
return TextDrawable.builder()
.beginConfig()
.width(size)
.height(size)
.endConfig()
.buildRect(letter.substring(0, 1).toUpperCase(Locale.US),
ColorGenerator.MATERIAL.getColor(contact.mJID));
}
public void getAvatarAsync(final Context context, final ContactCallback callback) {
if (mAvatar != null) {
callback.avatarLoaded(this, mAvatar);
}
else {
// start async load
new Thread(new Runnable() {
public void run() {
try {
Drawable avatar = getAvatar(context);
callback.avatarLoaded(Contact.this, avatar);
}
catch (Exception e) {
// do not throw any exception while loading
Log.w(TAG, "error while loading avatar", e);
}
}
}).start();
}
}
public synchronized Drawable getAvatar(Context context) {
if (mAvatar == null) {
Bitmap b = loadAvatarBitmap(context);
if (b != null) {
mAvatar = new BitmapDrawable(context.getResources(), b);
}
}
if (mAvatar == null)
mAvatar = generateRandomAvatar(context, this);
return mAvatar;
}
private synchronized Bitmap loadAvatarBitmap(Context context) {
if (mAvatarData == null) {
Uri uri = getUri();
if (uri != null)
mAvatarData = loadAvatarData(context, uri);
}
if (mAvatarData != null)
return BitmapFactory.decodeByteArray(mAvatarData, 0, mAvatarData.length);
return null;
}
/**
* Public version of {@link #loadAvatarBitmap} which includes the random
* avatar generation.
* @return a newly-allocated {@link Bitmap}
*/
public synchronized Bitmap getAvatarBitmap(Context context) {
Bitmap avatar = loadAvatarBitmap(context);
if (avatar == null) {
Drawable d = generateRandomAvatar(context, this);
if (d != null) {
avatar = MessageUtils.drawableToBitmap(d);
}
}
return avatar;
}
private void clear() {
mLastSeen = 0;
}
public static void invalidate(String userId) {
cache.remove(userId);
fireContactInvalidated(userId);
}
public static void invalidate() {
cache.evictAll();
fireContactInvalidated(null);
}
/** Invalidates cached data for all contacts. Does not delete contact information. */
public static void invalidateData() {
synchronized (cache) {
for (Contact c : cache.snapshot().values()) {
c.clear();
}
}
// invalidate contact state
sStates.clear();
}
/** Invalidates cached data for the given contact. Does not delete contact information. */
public static void invalidateData(String userId) {
Contact c = cache.get(XmppStringUtils.parseBareJid(userId));
if (c != null)
c.clear();
// invalidate contact state
clearState(userId);
}
public static void registerContactChangeListener(ContactChangeListener l) {
sListeners.add(l);
}
public static void unregisterContactChangeListener(ContactChangeListener l) {
sListeners.remove(l);
}
private static void fireContactInvalidated(String userId) {
for (ContactChangeListener l : sListeners) {
l.onContactInvalidated(userId);
}
}
/** Builds a contact from a UsersProvider cursor. */
public static Contact fromUsersCursor(Context context, Cursor cursor) {
// try the cache
String jid = cursor.getString(COLUMN_JID);
Contact c = cache.get(jid);
if (c == null) {
// don't let the cache fetch contact data again - we'll populate it
final long contactId = cursor.getLong(COLUMN_CONTACT_ID);
final String key = cursor.getString(COLUMN_LOOKUP_KEY);
final String name = cursor.getString(COLUMN_DISPLAY_NAME);
final String number = cursor.getString(COLUMN_NUMBER);
final boolean registered = (cursor.getInt(COLUMN_REGISTERED) != 0);
final String status = cursor.getString(COLUMN_STATUS);
final boolean blocked = (cursor.getInt(COLUMN_BLOCKED) != 0);
c = new Contact(contactId, key, name, number, jid, blocked);
c.mRegistered = registered;
c.mStatus = status;
retrieveKeyInfo(context, c);
cache.put(jid, c);
}
return c;
}
public static String numberByUserId(Context context, String userId) {
Cursor c = null;
try {
ContentResolver cres = context.getContentResolver();
c = cres.query(Uri.withAppendedPath(Users.CONTENT_URI, userId),
new String[] { Users.NUMBER },
null, null, null);
if (c.moveToFirst())
return c.getString(0);
}
finally {
if (c != null)
c.close();
}
return null;
}
public static Contact findByUserId(Context context, @NonNull String userId) {
return findByUserId(context, userId, null);
}
@NonNull
public static Contact findByUserId(Context context, @NonNull String userId, String numberHint) {
Contact c = cache.get(context, userId, numberHint);
// build dummy contact if not found
if (c == null) {
c = new Contact(-1, null, userId, numberHint, userId, false);
// try to retrieve the key from the keyring
// We may find one for pending subscription users which have
// disappeared from the users table after a resync
retrieveKeyInfo(context, c);
}
return c;
}
private static void retrieveKeyInfo(Context context, Contact c) {
// trusted key
Keyring.TrustedPublicKeyData trustedKeyring = Keyring.getPublicKeyData(context, c.getJID(), Keys.TRUST_IGNORED);
// latest (possibly unknown) fingerprint
c.mFingerprint = Keyring.getFingerprint(context, c.getJID(), Keys.TRUST_UNKNOWN);
if (trustedKeyring != null) {
c.mTrustedKeyRing = new PGPLazyPublicKeyRingLoader(trustedKeyring.keyData);
c.mTrustedLevel = trustedKeyring.trustLevel;
}
}
static Contact _findByUserId(Context context, String userId) {
ContentResolver cres = context.getContentResolver();
Cursor c = cres.query(Uri.withAppendedPath(Users.CONTENT_URI, userId),
new String[] {
Users.NUMBER,
Users.DISPLAY_NAME,
Users.LOOKUP_KEY,
Users.CONTACT_ID,
Users.REGISTERED,
Users.STATUS,
Users.BLOCKED,
}, null, null, null);
if (c.moveToFirst()) {
final String number = c.getString(0);
final String name = c.getString(1);
final String key = c.getString(2);
final long cid = c.getLong(3);
final boolean registered = (c.getInt(4) != 0);
final String status = c.getString(5);
final boolean blocked = (c.getInt(6) != 0);
c.close();
Contact contact = new Contact(cid, key, name, number, userId, blocked);
contact.mRegistered = registered;
contact.mStatus = status;
retrieveKeyInfo(context, contact);
return contact;
}
c.close();
return null;
}
private static byte[] loadAvatarData(Context context, Uri contactUri) {
byte[] data = null;
InputStream avatarDataStream;
try {
avatarDataStream = Contacts.openContactPhotoInputStream(
context.getContentResolver(), contactUri);
}
catch (Exception e) {
// fallback to old behaviour
try {
long cid = ContentUris.parseId(contactUri);
Uri uri = ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, cid);
avatarDataStream = Contacts.openContactPhotoInputStream(
context.getContentResolver(), uri);
}
catch (Exception ignored) {
// no way of getting avatar, sorry
return null;
}
}
if (avatarDataStream != null) {
try {
data = new byte[avatarDataStream.available()];
avatarDataStream.read(data, 0, data.length);
}
catch (IOException e) {
Log.e(TAG, "cannot retrieve contact avatar", e);
}
finally {
try {
avatarDataStream.close();
}
catch (IOException ignored) {
}
}
}
return data;
}
public static Cursor queryContacts(Context context) {
String selection = Users.REGISTERED + " <> 0";
if (!Preferences.getShowBlockedUsers(context)) {
selection += " AND " + Users.BLOCKED + " = 0";
}
return context.getContentResolver().query(Users.CONTENT_URI.buildUpon()
.appendQueryParameter(Users.EXTRA_INDEX, "true").build(),
ALL_CONTACTS_PROJECTION,
selection, null,
Users.DISPLAY_NAME + " COLLATE NOCASE," + Users.NUMBER + " COLLATE NOCASE");
}
}