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