org.kontalk.provider.UsersProvider.java Source code

Java tutorial

Introduction

Here is the source code for org.kontalk.provider.UsersProvider.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.provider;

import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import com.android.providers.contacts.ContactLocaleUtils;
import com.android.providers.contacts.FastScrollingIndexCache;

import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.content.ContentProvider;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.SQLException;
import android.database.sqlite.SQLiteConstraintException;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.database.sqlite.SQLiteQueryBuilder;
import android.database.sqlite.SQLiteStatement;
import android.net.Uri;
import android.os.Bundle;
import android.provider.BaseColumns;
import android.provider.ContactsContract;
import android.provider.ContactsContract.CommonDataKinds.Phone;
import android.provider.ContactsContract.RawContacts;
import android.support.annotation.NonNull;
import android.support.annotation.VisibleForTesting;
import android.support.v4.database.DatabaseUtilsCompat;

import org.kontalk.BuildConfig;
import org.kontalk.Kontalk;
import org.kontalk.Log;
import org.kontalk.authenticator.Authenticator;
import org.kontalk.client.NumberValidator;
import org.kontalk.crypto.PersonalKey;
import org.kontalk.data.Contact;
import org.kontalk.provider.MyUsers.Keys;
import org.kontalk.provider.MyUsers.Users;
import org.kontalk.sync.SyncAdapter;
import org.kontalk.util.MessageUtils;
import org.kontalk.util.Preferences;
import org.kontalk.util.XMPPUtils;

/**
 * The users provider. Also stores the key trust database.
 * Fast scrolling cache from Google AOSP.
 * @author Daniele Ricci
 */
public class UsersProvider extends ContentProvider {
    public static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".users";

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    static final int DATABASE_VERSION = 10;
    private static final String DATABASE_NAME = "users.db";
    private static final String TABLE_USERS = "users";
    private static final String TABLE_USERS_OFFLINE = "users_offline";
    private static final String TABLE_KEYS = "keys";

    private static final int USERS = 1;
    private static final int USERS_JID = 2;
    private static final int KEYS = 3;
    private static final int KEYS_JID = 4;
    private static final int KEYS_JID_FINGERPRINT = 5;

    private long mLastResync;

    private FastScrollingIndexCache mFastScrollingIndexCache;
    private ContactLocaleUtils mLocaleUtils;

    private DatabaseHelper dbHelper;
    private static final UriMatcher sUriMatcher;
    private static HashMap<String, String> usersProjectionMap;
    private static HashMap<String, String> keysProjectionMap;

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    static class DatabaseHelper extends SQLiteOpenHelper {
        private static final String CREATE_TABLE_USERS = "(" + "_id INTEGER PRIMARY KEY,"
                + "jid TEXT NOT NULL UNIQUE," + "number TEXT NOT NULL UNIQUE," + "display_name TEXT,"
                + "lookup_key TEXT," + "contact_id INTEGER," + "registered INTEGER NOT NULL DEFAULT 0,"
                + "status TEXT," + "last_seen INTEGER," + "blocked INTEGER NOT NULL DEFAULT 0" + ")";

        /** This table will contain all the users in contact list .*/
        private static final String SCHEMA_USERS = "CREATE TABLE " + TABLE_USERS + " " + CREATE_TABLE_USERS;

        private static final String SCHEMA_USERS_OFFLINE = "CREATE TABLE " + TABLE_USERS_OFFLINE
                + CREATE_TABLE_USERS;

        private static final String CREATE_TABLE_KEYS = "(" + "jid TEXT NOT NULL," + "fingerprint TEXT NOT NULL,"
                + "trust_level INTEGER NOT NULL DEFAULT 0," + "timestamp INTEGER NOT NULL," + // key creation timestamp
                "public_key BLOB," + "PRIMARY KEY (jid, fingerprint)" + ")";

        /** This table will contain keys verified (and trusted) by the user. */
        private static final String SCHEMA_KEYS = "CREATE TABLE " + TABLE_KEYS + " " + CREATE_TABLE_KEYS;

        private static final String[] SCHEMA_UPGRADE_V9 = {
                // online table
                "CREATE TABLE users_backup " + CREATE_TABLE_USERS,
                "INSERT INTO users_backup SELECT _id, jid, number, display_name, lookup_key, contact_id, registered, status, last_seen, blocked FROM "
                        + TABLE_USERS,
                "DROP TABLE " + TABLE_USERS, "ALTER TABLE users_backup RENAME TO " + TABLE_USERS,
                // offline table
                "CREATE TABLE users_backup " + CREATE_TABLE_USERS,
                "INSERT INTO users_backup SELECT _id, jid, number, display_name, lookup_key, contact_id, registered, status, last_seen, blocked FROM "
                        + TABLE_USERS_OFFLINE,
                "DROP TABLE " + TABLE_USERS_OFFLINE, "ALTER TABLE users_backup RENAME TO " + TABLE_USERS_OFFLINE,
                // keys table
                "CREATE TABLE keys_backup " + CREATE_TABLE_KEYS,
                "INSERT INTO keys_backup SELECT jid, fingerprint, " + Keys.TRUST_VERIFIED
                        + ", strftime('%s')*1000, public_key FROM " + TABLE_KEYS + " WHERE fingerprint IS NOT NULL",
                "DROP TABLE " + TABLE_KEYS, "ALTER TABLE keys_backup RENAME TO " + TABLE_KEYS, };

        // any upgrade - just re-create all tables
        private static final String[] SCHEMA_UPGRADE = { "DROP TABLE IF EXISTS " + TABLE_USERS, SCHEMA_USERS,
                "DROP TABLE IF EXISTS " + TABLE_USERS_OFFLINE, SCHEMA_USERS_OFFLINE,
                "DROP TABLE IF EXISTS " + TABLE_KEYS, SCHEMA_KEYS, };

        private Context mContext;

        /** This will be set to true when database is new. */
        private boolean mNew;
        /** A read-only connection to the database. */
        private SQLiteDatabase dbReader;

        protected DatabaseHelper(Context context) {
            super(context, DATABASE_NAME, null, DATABASE_VERSION);
            mContext = context;
        }

        @Override
        public void onCreate(SQLiteDatabase db) {
            db.execSQL(SCHEMA_USERS);
            db.execSQL(SCHEMA_USERS_OFFLINE);
            db.execSQL(SCHEMA_KEYS);
            mNew = true;
        }

        @Override
        public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
            switch (oldVersion) {
            case 9:
                // new keys management
                for (String sql : SCHEMA_UPGRADE_V9)
                    db.execSQL(sql);
                break;
            default:
                for (String sql : SCHEMA_UPGRADE)
                    db.execSQL(sql);
                mNew = true;
            }
        }

        @Override
        public void onOpen(SQLiteDatabase db) {
            String path = mContext.getDatabasePath(DATABASE_NAME).getPath();
            dbReader = SQLiteDatabase.openDatabase(path, null, 0);
        }

        public boolean isNew() {
            return mNew;
        }

        @Override
        public synchronized void close() {
            try {
                dbReader.close();
            } catch (Exception e) {
                // ignored
            }
            dbReader = null;
            super.close();
        }

        @Override
        public synchronized SQLiteDatabase getReadableDatabase() {
            return (dbReader != null) ? dbReader : super.getReadableDatabase();
        }
    }

    @Override
    public boolean onCreate() {
        dbHelper = new DatabaseHelper(getContext());
        mFastScrollingIndexCache = FastScrollingIndexCache.getInstance(getContext());
        mLocaleUtils = ContactLocaleUtils.getInstance();
        return true;
    }

    @Override
    public String getType(@NonNull Uri uri) {
        switch (sUriMatcher.match(uri)) {
        case USERS:
            return Users.CONTENT_TYPE;
        case USERS_JID:
            return Users.CONTENT_ITEM_TYPE;

        default:
            throw new IllegalArgumentException("Unknown URI " + uri);
        }
    }

    private void invalidateFastScrollingIndexCache() {
        mFastScrollingIndexCache.invalidate();
    }

    private static final class Counter {
        private int value;

        public Counter(int start) {
            this.value = start;
        }

        public void inc() {
            value++;
        }
    }

    /**
     * Computes counts by the address book index labels and returns it as {@link Bundle} which
     * will be appended to a {@link Cursor} as extras.
     */
    private Bundle getFastScrollingIndexExtras(Cursor cursor) {
        try {
            LinkedHashMap<String, Counter> groups = new LinkedHashMap<>();
            int count = cursor.getCount();

            for (int i = 0; i < count; i++) {
                cursor.moveToNext();
                String source = cursor.getString(Contact.COLUMN_DISPLAY_NAME);
                // use phone number if we don't have a display name
                if (source == null)
                    source = cursor.getString(Contact.COLUMN_NUMBER);
                String label = mLocaleUtils.getLabel(source);
                Counter counter = groups.get(label);
                if (counter == null) {
                    counter = new Counter(1);
                    groups.put(label, counter);
                } else {
                    counter.inc();
                }
            }

            int numLabels = groups.size();
            String labels[] = new String[numLabels];
            int counts[] = new int[numLabels];
            int i = 0;
            for (Map.Entry<String, Counter> entry : groups.entrySet()) {
                labels[i] = entry.getKey();
                counts[i] = entry.getValue().value;
                i++;
            }

            return FastScrollingIndexCache.buildExtraBundle(labels, counts);
        } finally {
            // reset the cursor
            cursor.move(-1);
        }
    }

    /**
     * Add the "fast scrolling index" bundle, generated by {@link #getFastScrollingIndexExtras},
     * to a cursor as extras.  It first checks {@link FastScrollingIndexCache} to see if we
     * already have a cached result.
     */
    @SuppressLint("NewApi")
    private void bundleFastScrollingIndexExtras(UsersCursor cursor, Uri queryUri, final SQLiteDatabase db,
            SQLiteQueryBuilder qb, String selection, String[] selectionArgs, String sortOrder,
            String countExpression) {

        Bundle b;
        // Note even though FastScrollingIndexCache is thread-safe, we really need to put the
        // put-get pair in a single synchronized block, so that even if multiple-threads request the
        // same index at the same time (which actually happens on the phone app) we only execute
        // the query once.
        //
        // This doesn't cause deadlock, because only reader threads get here but not writer
        // threads.  (Writer threads may call invalidateFastScrollingIndexCache(), but it doesn't
        // synchronize on mFastScrollingIndexCache)
        //
        // All reader and writer threads share the single lock object internally in
        // FastScrollingIndexCache, but the lock scope is limited within each put(), get() and
        // invalidate() call, so it won't deadlock.

        // Synchronizing on a non-static field is generally not a good idea, but nobody should
        // modify mFastScrollingIndexCache once initialized, and it shouldn't be null at this point.
        synchronized (mFastScrollingIndexCache) {
            b = mFastScrollingIndexCache.get(queryUri, selection, selectionArgs, sortOrder, countExpression);

            if (b == null) {
                // Not in the cache.  Generate and put.
                b = getFastScrollingIndexExtras(cursor);

                mFastScrollingIndexCache.put(queryUri, selection, selectionArgs, sortOrder, countExpression, b);
            }
        }
        cursor.setExtras(b);
    }

    @Override
    public Cursor query(@NonNull Uri uri, String[] projection, String selection, String[] selectionArgs,
            String sortOrder) {
        SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
        boolean offline = Boolean.parseBoolean(uri.getQueryParameter(Users.OFFLINE));

        int match = sUriMatcher.match(uri);
        if (match == USERS || match == USERS_JID) {
            // use the same table name as an alias
            String table = offline ? (TABLE_USERS_OFFLINE + " " + TABLE_USERS) : TABLE_USERS;
            qb.setTables(table);
            qb.setProjectionMap(usersProjectionMap);
        } else if (match == KEYS || match == KEYS_JID || match == KEYS_JID_FINGERPRINT) {
            qb.setTables(TABLE_KEYS);
            qb.setProjectionMap(keysProjectionMap);
        }

        switch (match) {
        case USERS:
            // nothing to do
            break;

        case USERS_JID: {
            // TODO append to selection
            String userId = uri.getPathSegments().get(1);
            selection = TABLE_USERS + "." + Users.JID + " = ?";
            selectionArgs = new String[] { userId };
            break;
        }

        case KEYS:
            // nothing to do
            break;

        case KEYS_JID:
        case KEYS_JID_FINGERPRINT:
            String userId = uri.getPathSegments().get(1);
            selection = DatabaseUtilsCompat.concatenateWhere(selection, Keys.JID + "=?");
            selectionArgs = DatabaseUtilsCompat.appendSelectionArgs(selectionArgs, new String[] { userId });
            // TODO support for fingerprint in Uri
            break;

        default:
            throw new IllegalArgumentException("Unknown URI " + uri);
        }

        SQLiteDatabase db = dbHelper.getReadableDatabase();
        Cursor c = qb.query(db, projection, selection, selectionArgs, null, null, sortOrder);
        if ((match == USERS || match == USERS_JID) && c.getCount() == 0
                && (match != USERS_JID || !XMPPUtils.isDomainJID(uri.getPathSegments().get(1)))) {
            // empty result set and sync requested
            SyncAdapter.requestSync(getContext(), false);
        }
        if (Boolean.parseBoolean(uri.getQueryParameter(Users.EXTRA_INDEX)) && c.getCount() > 0) {
            UsersCursor uc = new UsersCursor(c);
            bundleFastScrollingIndexExtras(uc, uri, db, qb, selection, selectionArgs, sortOrder, null);
            c = uc;
        }

        c.setNotificationUri(getContext().getContentResolver(), uri);
        return c;
    }

    /** Reverse-lookup a userId hash to insert a new record to users table.
     * FIXME this method could take a very long time to complete.
    private void newRecord(SQLiteDatabase db, String matchHash) {
    // lookup all phone numbers until our hash matches
    Context context = getContext();
    final Cursor phones = context.getContentResolver().query(Phone.CONTENT_URI,
        new String[] { Phone.NUMBER, Phone.DISPLAY_NAME, Phone.LOOKUP_KEY, Phone.CONTACT_ID },
        null, null, null);
        
    try {
        while (phones.moveToNext()) {
            String number = phones.getString(0);
        
            // a phone number with less than 4 digits???
            if (number.length() < 4)
                continue;
        
            // fix number
            try {
                number = NumberValidator.fixNumber(context, number,
                        Authenticator.getDefaultAccountName(context), null);
            }
            catch (Exception e) {
                Log.e(TAG, "unable to normalize number: " + number + " - skipping", e);
                // skip number
                continue;
            }
        
            try {
                String hash = MessageUtils.sha1(number);
                if (hash.equalsIgnoreCase(matchHash)) {
                    ContentValues values = new ContentValues();
                    values.put(Users.HASH, matchHash);
                    values.put(Users.NUMBER, number);
                    values.put(Users.DISPLAY_NAME, phones.getString(1));
                    values.put(Users.LOOKUP_KEY, phones.getString(2));
                    values.put(Users.CONTACT_ID, phones.getLong(3));
                    db.insert(TABLE_USERS, null, values);
                    break;
                }
            }
            catch (NoSuchAlgorithmException e) {
                Log.e(TAG, "unable to generate SHA-1 hash for " + number + " - skipping", e);
            }
            catch (SQLiteConstraintException sqe) {
                // skip duplicate number
                break;
            }
        }
    }
    finally {
        phones.close();
    }
        
    }
    */

    @Override
    public int update(@NonNull Uri uri, ContentValues values, String selection, String[] selectionArgs) {
        try {
            boolean isResync = Boolean.parseBoolean(uri.getQueryParameter(Users.RESYNC));
            boolean bootstrap = Boolean.parseBoolean(uri.getQueryParameter(Users.BOOTSTRAP));
            boolean commit = Boolean.parseBoolean(uri.getQueryParameter(Users.COMMIT));

            if (isResync) {
                // we keep this synchronized to allow for the initial resync by the
                // registration activity
                synchronized (this) {
                    long diff = System.currentTimeMillis() - mLastResync;
                    if (diff > 1000 && (!bootstrap || dbHelper.isNew())) {
                        if (commit) {
                            commit();
                            return 0;
                        } else {
                            return resync();
                        }
                    }

                    mLastResync = System.currentTimeMillis();
                    return 0;
                }
            }

            // simple update
            int match = sUriMatcher.match(uri);
            switch (match) {
            case USERS:
            case USERS_JID:
                return updateUser(values, Boolean.parseBoolean(uri.getQueryParameter(Users.OFFLINE)), selection,
                        selectionArgs);

            case KEYS:
            case KEYS_JID:
            case KEYS_JID_FINGERPRINT:
                throw new IllegalArgumentException("use insert for keys");

            default:
                throw new IllegalArgumentException("Unknown URI " + uri);
            }
        } finally {
            invalidateFastScrollingIndexCache();
        }
    }

    private int updateUser(ContentValues values, boolean offline, String selection, String[] selectionArgs) {
        SQLiteDatabase db = dbHelper.getWritableDatabase();

        int rc = db.update(offline ? TABLE_USERS_OFFLINE : TABLE_USERS, values, selection, selectionArgs);
        if (rc == 0) {
            ContentValues insertValues = new ContentValues(values);
            // insert new record
            insertValues.put(Users.JID, selectionArgs[0]);
            insertValues.put(Users.NUMBER, selectionArgs[0]);
            /*
            if (!values.containsKey(Users.DISPLAY_NAME))
            insertValues.put(Users.DISPLAY_NAME, selectionArgs[0]);
             */
            insertValues.put(Users.REGISTERED, true);

            try {
                db.insert(offline ? TABLE_USERS_OFFLINE : TABLE_USERS, null, insertValues);
                return 1;
            } catch (SQLiteConstraintException e) {
                // nothing was updated but the row exists
                return 0;
            }
        }

        return rc;
    }

    /** Commits the offline table to the online table. */
    private void commit() {
        SQLiteDatabase db = dbHelper.getWritableDatabase();

        // begin transaction
        beginTransaction(db);
        boolean success = false;

        try {
            // copy contents from offline
            db.execSQL("DELETE FROM " + TABLE_USERS);
            db.execSQL("INSERT INTO " + TABLE_USERS + " SELECT * FROM " + TABLE_USERS_OFFLINE);
            success = setTransactionSuccessful(db);
        } catch (SQLException e) {
            // ops :)
            Log.i(SyncAdapter.TAG, "users table commit failed - already committed?", e);
        } finally {
            endTransaction(db, success);
            // time to invalidate contacts cache
            Contact.invalidate();
        }
    }

    /** Triggers a complete resync of the users database. */
    private int resync() {
        Context context = getContext();
        ContentResolver cr = context.getContentResolver();
        SQLiteDatabase db = dbHelper.getWritableDatabase();

        // begin transaction
        beginTransaction(db);
        boolean success = false;

        int count = 0;

        // delete old users content
        try {
            db.execSQL("DELETE FROM " + TABLE_USERS_OFFLINE);
        } catch (SQLException e) {
            // table might not exist - create it! (shouldn't happen since version 4)
            db.execSQL(DatabaseHelper.SCHEMA_USERS_OFFLINE);
        }

        // we are trying to be fast here
        SQLiteStatement stm = db.compileStatement("INSERT INTO " + TABLE_USERS_OFFLINE
                + " (number, jid, display_name, lookup_key, contact_id, registered)" + " VALUES(?, ?, ?, ?, ?, ?)");

        // these two statements are used to immediately update data in the online table
        // even if the data is dummy, it will be soon replaced by sync or by manual request
        SQLiteStatement onlineUpd = db.compileStatement("UPDATE " + TABLE_USERS
                + " SET number = ?, display_name = ?, lookup_key = ?, contact_id = ? WHERE jid = ?");
        SQLiteStatement onlineIns = db.compileStatement("INSERT INTO " + TABLE_USERS
                + " (number, jid, display_name, lookup_key, contact_id, registered)" + " VALUES(?, ?, ?, ?, ?, ?)");

        Cursor phones = null;
        String dialPrefix = Preferences.getDialPrefix();
        int dialPrefixLen = dialPrefix != null ? dialPrefix.length() : 0;

        try {
            String where = !Preferences.getSyncInvisibleContacts(context)
                    ? ContactsContract.Contacts.IN_VISIBLE_GROUP + "=1 AND "
                    : "";

            // query for phone numbers
            phones = cr.query(Phone.CONTENT_URI, new String[] { Phone.NUMBER, Phone.DISPLAY_NAME, Phone.LOOKUP_KEY,
                    Phone.CONTACT_ID, RawContacts.ACCOUNT_TYPE }, where + " (" +
            // this will filter out RawContacts from Kontalk
                            RawContacts.ACCOUNT_TYPE + " IS NULL OR " + RawContacts.ACCOUNT_TYPE
                            + " NOT IN (?, ?))",
                    new String[] { Authenticator.ACCOUNT_TYPE, Authenticator.ACCOUNT_TYPE_LEGACY }, null);

            if (phones != null) {
                while (phones.moveToNext()) {
                    String number = phones.getString(0);
                    String name = phones.getString(1);

                    // buggy provider - skip entry
                    if (name == null || number == null)
                        continue;

                    // remove dial prefix first
                    if (dialPrefix != null && number.startsWith(dialPrefix))
                        number = number.substring(dialPrefixLen);

                    // a phone number with less than 4 digits???
                    if (number.length() < 4)
                        continue;

                    // fix number
                    try {
                        number = NumberValidator.fixNumber(context, number,
                                Authenticator.getDefaultAccountName(context), 0);
                    } catch (Exception e) {
                        Log.e(SyncAdapter.TAG, "unable to normalize number: " + number + " - skipping", e);
                        // skip number
                        continue;
                    }

                    try {
                        String hash = MessageUtils.sha1(number);
                        String lookupKey = phones.getString(2);
                        long contactId = phones.getLong(3);
                        String jid = XMPPUtils.createLocalJID(getContext(), hash);

                        addResyncContact(db, stm, onlineUpd, onlineIns, number, jid, name, lookupKey, contactId,
                                false);
                        count++;
                    } catch (IllegalArgumentException iae) {
                        Log.w(SyncAdapter.TAG, "doing sync with no server?");
                    } catch (SQLiteConstraintException sqe) {
                        // skip duplicate number
                    }
                }

                phones.close();
            } else {
                Log.e(SyncAdapter.TAG, "query to contacts failed!");
            }

            if (Preferences.getSyncSIMContacts(getContext())) {
                // query for SIM contacts
                // column selection doesn't work because of a bug in Android
                // TODO this is a bit unclear...
                try {
                    phones = cr.query(Uri.parse("content://icc/adn/"), null, null, null, null);
                } catch (Exception e) {
                    /*
                    On some phones:
                    java.lang.NullPointerException
                    at android.os.Parcel.readException(Parcel.java:1431)
                    at android.database.DatabaseUtils.readExceptionFromParcel(DatabaseUtils.java:185)
                    at android.database.DatabaseUtils.readExceptionFromParcel(DatabaseUtils.java:137)
                    at android.content.ContentProviderProxy.query(ContentProviderNative.java:366)
                    at android.content.ContentResolver.query(ContentResolver.java:372)
                    at android.content.ContentResolver.query(ContentResolver.java:315)
                     */
                    Log.w(SyncAdapter.TAG, "unable to retrieve SIM contacts", e);
                    phones = null;
                }

                if (phones != null) {
                    while (phones.moveToNext()) {
                        String name = phones.getString(phones.getColumnIndex("name"));
                        String number = phones.getString(phones.getColumnIndex("number"));
                        // buggy firmware - skip entry
                        if (name == null || number == null)
                            continue;

                        // remove dial prefix first
                        if (dialPrefix != null && number.startsWith(dialPrefix))
                            number = number.substring(dialPrefixLen);

                        // a phone number with less than 4 digits???
                        if (number.length() < 4)
                            continue;

                        // fix number
                        try {
                            number = NumberValidator.fixNumber(context, number,
                                    Authenticator.getDefaultAccountName(context), 0);
                        } catch (Exception e) {
                            Log.e(SyncAdapter.TAG, "unable to normalize number: " + number + " - skipping", e);
                            // skip number
                            continue;
                        }

                        try {
                            String hash = MessageUtils.sha1(number);
                            String jid = XMPPUtils.createLocalJID(getContext(), hash);
                            long contactId = phones.getLong(phones.getColumnIndex(BaseColumns._ID));

                            addResyncContact(db, stm, onlineUpd, onlineIns, number, jid, name, null, contactId,
                                    false);
                            count++;
                        } catch (IllegalArgumentException iae) {
                            Log.w(SyncAdapter.TAG, "doing sync with no server?");
                        } catch (SQLiteConstraintException sqe) {
                            // skip duplicate number
                        }
                    }
                }
            }

            // try to add account number with display name
            String ownNumber = Authenticator.getDefaultAccountName(getContext());
            if (ownNumber != null) {
                String ownName = Authenticator.getDefaultDisplayName(getContext());
                String fingerprint = null;
                byte[] publicKeyData = null;
                try {
                    PersonalKey myKey = Kontalk.get(getContext()).getPersonalKey();
                    if (myKey != null) {
                        fingerprint = myKey.getFingerprint();
                        publicKeyData = myKey.getEncodedPublicKeyRing();
                    }
                } catch (Exception e) {
                    Log.w(SyncAdapter.TAG, "unable to load personal key", e);
                }
                try {
                    String hash = MessageUtils.sha1(ownNumber);
                    String jid = XMPPUtils.createLocalJID(getContext(), hash);

                    addResyncContact(db, stm, onlineUpd, onlineIns, ownNumber, jid, ownName, null, null, true);
                    insertOrUpdateKey(jid, fingerprint, publicKeyData, false);
                    count++;
                } catch (IllegalArgumentException iae) {
                    Log.w(SyncAdapter.TAG, "doing sync with no server?");
                } catch (SQLiteConstraintException sqe) {
                    // skip duplicate number
                }
            }

            success = setTransactionSuccessful(db);
        } finally {
            endTransaction(db, success);
            if (phones != null)
                phones.close();
            stm.close();

            // time to invalidate contacts cache (because of updates to online)
            Contact.invalidate();
        }
        return count;
    }

    private void addResyncContact(SQLiteDatabase db, SQLiteStatement stm, SQLiteStatement onlineUpd,
            SQLiteStatement onlineIns, String number, String jid, String displayName, String lookupKey,
            Long contactId, boolean registered) {

        int i = 0;

        stm.clearBindings();
        stm.bindString(++i, number);
        stm.bindString(++i, jid);
        if (displayName != null)
            stm.bindString(++i, displayName);
        else
            stm.bindNull(++i);
        if (lookupKey != null)
            stm.bindString(++i, lookupKey);
        else
            stm.bindNull(++i);
        if (contactId != null)
            stm.bindLong(++i, contactId);
        else
            stm.bindNull(++i);
        stm.bindLong(++i, registered ? 1 : 0);
        stm.executeInsert();

        // update online entry
        i = 0;
        onlineUpd.clearBindings();
        onlineUpd.bindString(++i, number);
        if (displayName != null)
            onlineUpd.bindString(++i, displayName);
        else
            onlineUpd.bindNull(++i);
        if (lookupKey != null)
            onlineUpd.bindString(++i, lookupKey);
        else
            onlineUpd.bindNull(++i);
        if (contactId != null)
            onlineUpd.bindLong(++i, contactId);
        else
            onlineUpd.bindNull(++i);
        onlineUpd.bindString(++i, jid);
        int rows = executeUpdateDelete(db, onlineUpd);

        // no contact found, insert a new dummy one
        if (rows <= 0) {
            i = 0;
            onlineIns.clearBindings();
            onlineIns.bindString(++i, number);
            onlineIns.bindString(++i, jid);
            if (displayName != null)
                onlineIns.bindString(++i, displayName);
            else
                onlineIns.bindNull(++i);
            if (lookupKey != null)
                onlineIns.bindString(++i, lookupKey);
            else
                onlineIns.bindNull(++i);
            if (contactId != null)
                onlineIns.bindLong(++i, contactId);
            else
                onlineIns.bindNull(++i);
            onlineIns.bindLong(++i, registered ? 1 : 0);
            onlineIns.executeInsert();
        }
    }

    @Override
    public Uri insert(@NonNull Uri uri, ContentValues values) {
        try {
            int match = sUriMatcher.match(uri);
            switch (match) {
            case USERS:
            case USERS_JID:
                return insertUser(values, Boolean.parseBoolean(uri.getQueryParameter(Users.OFFLINE)),
                        Boolean.parseBoolean(uri.getQueryParameter(Users.DISCARD_NAME)));

            case KEYS:
            case KEYS_JID:
            case KEYS_JID_FINGERPRINT:
                List<String> segs = uri.getPathSegments();
                String jid, fingerprint;
                if (segs.size() >= 2) {
                    // Uri-based insert/update
                    jid = segs.get(1);
                    fingerprint = segs.get(2);
                } else {
                    // take jid and fingerprint from values
                    jid = values.getAsString(Keys.JID);
                    fingerprint = values.getAsString(Keys.FINGERPRINT);
                }

                return insertOrUpdateKey(jid, fingerprint, values,
                        Boolean.parseBoolean(uri.getQueryParameter(Keys.INSERT_ONLY)));

            default:
                throw new IllegalArgumentException("Unknown URI " + uri);
            }
        } finally {
            invalidateFastScrollingIndexCache();
        }
    }

    private Uri insertUser(ContentValues values, boolean offline, boolean discardName) {
        SQLiteDatabase db = dbHelper.getWritableDatabase();

        String table = offline ? TABLE_USERS_OFFLINE : TABLE_USERS;
        long id = 0;

        try {
            id = db.insertOrThrow(table, null, values);
        } catch (SQLException e) {
            String jid = values.getAsString(Users.JID);
            if (jid != null) {
                // discard display_name if requested
                if (discardName) {
                    values.remove(Users.DISPLAY_NAME);
                    values.remove(Users.NUMBER);
                }

                db.update(table, values, Users.JID + "=?", new String[] { jid });
            }
        }

        if (id >= 0)
            return ContentUris.withAppendedId(Users.CONTENT_URI, id);
        return null;
    }

    private Uri insertOrUpdateKey(String jid, String fingerprint, byte[] keyData, boolean insertOnly) {
        if (jid == null || fingerprint == null)
            throw new IllegalArgumentException("either JID or fingerprint not provided");

        ContentValues values = new ContentValues(1);
        values.put(Keys.PUBLIC_KEY, keyData);
        return insertOrUpdateKey(jid, fingerprint, values, insertOnly);
    }

    private Uri insertOrUpdateKey(String jid, String fingerprint, ContentValues values, boolean insertOnly) {
        SQLiteDatabase db = dbHelper.getWritableDatabase();
        if (jid == null || fingerprint == null)
            throw new IllegalArgumentException("either JID or fingerprint not provided");

        int rows = 0;

        try {
            // try to insert the key with the provided values
            ContentValues insertValues = new ContentValues(values);
            insertValues.put(Keys.JID, jid);
            insertValues.put(Keys.FINGERPRINT, fingerprint);
            // use current timestamp if the caller didn't provide any
            long timestamp = values.containsKey(Keys.TIMESTAMP) ? values.getAsLong(Keys.TIMESTAMP)
                    : System.currentTimeMillis();
            insertValues.put(Keys.TIMESTAMP, timestamp);
            db.insertOrThrow(TABLE_KEYS, null, insertValues);
            rows = 1;
        } catch (SQLiteConstraintException e) {
            if (!insertOnly) {
                // we got a duplicated key, update the requested values
                rows = db.update(TABLE_KEYS, values, Keys.JID + "=? AND " + Keys.FINGERPRINT + "=?",
                        new String[] { jid, fingerprint });
            }
        }

        if (rows >= 0)
            return Keys.CONTENT_URI.buildUpon().appendPath(jid).appendPath(fingerprint).build();
        return null;
    }

    private int insertKeys(ContentValues[] values) {
        SQLiteDatabase db = dbHelper.getWritableDatabase();

        int rows = 0;
        SQLiteStatement stm = db.compileStatement("INSERT OR REPLACE INTO " + TABLE_KEYS + " (" + Keys.JID + ", "
                + Keys.FINGERPRINT + ") VALUES(?, ?)");

        for (ContentValues v : values) {
            try {
                stm.bindString(1, v.getAsString(Keys.JID));
                stm.bindString(2, v.getAsString(Keys.FINGERPRINT));
                stm.executeInsert();
                rows++;
            } catch (SQLException e) {
                Log.w(SyncAdapter.TAG, "error inserting trusted key [" + v + "]", e);
            }
        }

        return rows;
    }

    @Override
    public int bulkInsert(@NonNull Uri uri, @NonNull ContentValues[] values) {
        int match = sUriMatcher.match(uri);
        switch (match) {
        case KEYS:
            return insertKeys(values);

        default:
            throw new IllegalArgumentException("Unknown URI " + uri);
        }
    }

    @Override
    public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) {
        throw new SQLException("delete not supported.");
    }

    // avoid recreating the same object over and over
    private static ContentValues registeredValues;

    /** Marks a user as registered. */
    public static void markRegistered(Context context, String jid) {
        if (registeredValues == null) {
            registeredValues = new ContentValues(1);
            registeredValues.put(Users.REGISTERED, 1);
        }
        // TODO Uri.withAppendedPath(Users.CONTENT_URI, msg.getSender(true))
        context.getContentResolver().update(Users.CONTENT_URI, registeredValues, Users.JID + "=?",
                new String[] { jid });
    }

    /** Retrieves the last seen timestamp for a user. */
    public static long getLastSeen(Context context, String jid) {
        long timestamp = -1;
        ContentResolver res = context.getContentResolver();
        Cursor c = res.query(Users.CONTENT_URI.buildUpon().appendPath(jid).build(),
                new String[] { Users.LAST_SEEN }, null, null, null);

        if (c.moveToFirst())
            timestamp = c.getLong(0);

        c.close();

        return timestamp;
    }

    /** Sets the last seen timestamp for a user. */
    public static void setLastSeen(Context context, String jid, long time) {
        ContentValues values = new ContentValues(1);
        values.put(Users.LAST_SEEN, time);
        context.getContentResolver().update(Users.CONTENT_URI, values, Users.JID + "=?", new String[] { jid });
    }

    public static void setBlockStatus(Context context, String jid, boolean blocked) {
        ContentValues values = new ContentValues(1);
        values.put(Users.BLOCKED, blocked);
        context.getContentResolver().update(Users.CONTENT_URI, values, Users.JID + "=?", new String[] { jid });
    }

    // FIXME what is this doing here? Using Messages Uri
    public static int setRequestStatus(Context context, String jid, int status) {
        ContentValues values = new ContentValues(1);
        values.put(MyMessages.Threads.REQUEST_STATUS, status);

        // FIXME this won't work on new threads
        return context.getContentResolver().update(MyMessages.Threads.Requests.CONTENT_URI, values,
                MyMessages.CommonColumns.PEER + "=?", new String[] { jid });
    }

    public static int updateDisplayNameIfEmpty(Context context, String jid, String displayName) {
        ContentValues values = new ContentValues(1);
        values.put(Users.DISPLAY_NAME, displayName);
        return context.getContentResolver().update(Users.CONTENT_URI, values, Users.JID + " = ? AND ("
                + Users.DISPLAY_NAME + " IS NULL OR LENGTH(" + Users.DISPLAY_NAME + ") = 0)", new String[] { jid });
    }

    public static int resync(Context context) {
        // update users database
        Uri uri = Users.CONTENT_URI.buildUpon().appendQueryParameter(Users.RESYNC, "true").build();
        return context.getContentResolver().update(uri, new ContentValues(), null, null);
    }

    /* Transactions compatibility layer */

    @TargetApi(android.os.Build.VERSION_CODES.HONEYCOMB)
    private void beginTransaction(SQLiteDatabase db) {
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.HONEYCOMB)
            db.beginTransactionNonExclusive();
        else
            // this is because API < 11 doesn't have beginTransactionNonExclusive()
            db.execSQL("BEGIN IMMEDIATE");
    }

    private boolean setTransactionSuccessful(SQLiteDatabase db) {
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.HONEYCOMB)
            db.setTransactionSuccessful();
        return true;
    }

    private void endTransaction(SQLiteDatabase db, boolean success) {
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.HONEYCOMB)
            db.endTransaction();
        else
            db.execSQL(success ? "COMMIT" : "ROLLBACK");
    }

    private int executeUpdateDelete(SQLiteDatabase db, SQLiteStatement stm) {
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.HONEYCOMB) {
            return stm.executeUpdateDelete();
        } else {
            stm.execute();
            SQLiteStatement changes = db.compileStatement("SELECT changes()");
            try {
                return (int) changes.simpleQueryForLong();
            } finally {
                changes.close();
            }
        }
    }

    static {
        sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
        sUriMatcher.addURI(AUTHORITY, TABLE_USERS, USERS);
        sUriMatcher.addURI(AUTHORITY, TABLE_USERS + "/*", USERS_JID);
        sUriMatcher.addURI(AUTHORITY, TABLE_KEYS, KEYS);
        sUriMatcher.addURI(AUTHORITY, TABLE_KEYS + "/*", KEYS_JID);
        sUriMatcher.addURI(AUTHORITY, TABLE_KEYS + "/*/*", KEYS_JID_FINGERPRINT);

        usersProjectionMap = new HashMap<>();
        usersProjectionMap.put(Users._ID, Users._ID);
        usersProjectionMap.put(Users.NUMBER, Users.NUMBER);
        usersProjectionMap.put(Users.DISPLAY_NAME, Users.DISPLAY_NAME);
        usersProjectionMap.put(Users.JID, Users.JID);
        usersProjectionMap.put(Users.LOOKUP_KEY, Users.LOOKUP_KEY);
        usersProjectionMap.put(Users.CONTACT_ID, Users.CONTACT_ID);
        usersProjectionMap.put(Users.REGISTERED, Users.REGISTERED);
        usersProjectionMap.put(Users.STATUS, Users.STATUS);
        usersProjectionMap.put(Users.LAST_SEEN, Users.LAST_SEEN);
        usersProjectionMap.put(Users.BLOCKED, Users.BLOCKED);

        // only for direct access to the keys table (for optimization)
        keysProjectionMap = new HashMap<>();
        keysProjectionMap.put(Keys.JID, Keys.JID);
        keysProjectionMap.put(Keys.FINGERPRINT, Keys.FINGERPRINT);
        keysProjectionMap.put(Keys.PUBLIC_KEY, Keys.PUBLIC_KEY);
        keysProjectionMap.put(Keys.TIMESTAMP, Keys.TIMESTAMP);
        keysProjectionMap.put(Keys.TRUST_LEVEL, Keys.TRUST_LEVEL);
    }

}