org.kontalk.data.Contact.java Source code

Java tutorial

Introduction

Here is the source code for org.kontalk.data.Contact.java

Source

/*
 * 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");
    }

}