public class

SocialProvider

extends ContentProvider
/*
 * Copyright (C) 2009 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License
 */

package com.android.providers.contacts;

import com.android.providers.contacts.ContactsDatabaseHelper.ActivitiesColumns;
import com.android.providers.contacts.ContactsDatabaseHelper.ContactsColumns;
import com.android.providers.contacts.ContactsDatabaseHelper.PackagesColumns;
import com.android.providers.contacts.ContactsDatabaseHelper.Tables;

import android.content.ContentProvider;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteQueryBuilder;
import android.provider.BaseColumns;
import android.provider.ContactsContract;
import android.provider.ContactsContract.Contacts;
import android.provider.ContactsContract.RawContacts;
import android.provider.SocialContract;
import android.provider.SocialContract.Activities;

import android.net.Uri;

import java.util.ArrayList;
import java.util.HashMap;

/**
 * Social activity content provider. The contract between this provider and
 * applications is defined in {@link SocialContract}.
 */
public class SocialProvider extends ContentProvider {
    // TODO: clean up debug tag
    private static final String TAG = "SocialProvider ~~~~";

    private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);

    private static final int ACTIVITIES = 1000;
    private static final int ACTIVITIES_ID = 1001;
    private static final int ACTIVITIES_AUTHORED_BY = 1002;

    private static final int CONTACT_STATUS_ID = 3000;

    private static final String DEFAULT_SORT_ORDER = Activities.THREAD_PUBLISHED + " DESC, "
            + Activities.PUBLISHED + " ASC";

    /** Contains just the contacts columns */
    private static final HashMap<String, String> sContactsProjectionMap;
    /** Contains just the contacts columns */
    private static final HashMap<String, String> sRawContactsProjectionMap;
    /** Contains just the activities columns */
    private static final HashMap<String, String> sActivitiesProjectionMap;

    /** Contains the activities, raw contacts, and contacts columns, for joined tables */
    private static final HashMap<String, String> sActivitiesContactsProjectionMap;

    static {
        // Contacts URI matching table
        final UriMatcher matcher = sUriMatcher;

        matcher.addURI(SocialContract.AUTHORITY, "activities", ACTIVITIES);
        matcher.addURI(SocialContract.AUTHORITY, "activities/#", ACTIVITIES_ID);
        matcher.addURI(SocialContract.AUTHORITY, "activities/authored_by/#", ACTIVITIES_AUTHORED_BY);

        matcher.addURI(SocialContract.AUTHORITY, "contact_status/#", CONTACT_STATUS_ID);

        HashMap<String, String> columns;

        // Contacts projection map
        columns = new HashMap<String, String>();
        // TODO: fix display name reference (in fact, use the contacts view instead of the table)
        columns.put(Contacts.DISPLAY_NAME, "contact." + Contacts.DISPLAY_NAME + " AS "
                + Contacts.DISPLAY_NAME);
        sContactsProjectionMap = columns;

        // Contacts projection map
        columns = new HashMap<String, String>();
        columns.put(RawContacts._ID, Tables.RAW_CONTACTS + "." + RawContacts._ID + " AS _id");
        columns.put(RawContacts.CONTACT_ID, RawContacts.CONTACT_ID);
        sRawContactsProjectionMap = columns;

        // Activities projection map
        columns = new HashMap<String, String>();
        columns.put(Activities._ID, "activities._id AS _id");
        columns.put(Activities.RES_PACKAGE, PackagesColumns.PACKAGE + " AS "
                + Activities.RES_PACKAGE);
        columns.put(Activities.MIMETYPE, Activities.MIMETYPE);
        columns.put(Activities.RAW_ID, Activities.RAW_ID);
        columns.put(Activities.IN_REPLY_TO, Activities.IN_REPLY_TO);
        columns.put(Activities.AUTHOR_CONTACT_ID, Activities.AUTHOR_CONTACT_ID);
        columns.put(Activities.TARGET_CONTACT_ID, Activities.TARGET_CONTACT_ID);
        columns.put(Activities.PUBLISHED, Activities.PUBLISHED);
        columns.put(Activities.THREAD_PUBLISHED, Activities.THREAD_PUBLISHED);
        columns.put(Activities.TITLE, Activities.TITLE);
        columns.put(Activities.SUMMARY, Activities.SUMMARY);
        columns.put(Activities.LINK, Activities.LINK);
        columns.put(Activities.THUMBNAIL, Activities.THUMBNAIL);
        sActivitiesProjectionMap = columns;

        // Activities, raw contacts, and contacts projection map for joins
        columns = new HashMap<String, String>();
        columns.putAll(sContactsProjectionMap);
        columns.putAll(sRawContactsProjectionMap);
        columns.putAll(sActivitiesProjectionMap); // Final _id will be from Activities
        sActivitiesContactsProjectionMap = columns;

    }

    private ContactsDatabaseHelper mDbHelper;

    /** {@inheritDoc} */
    @Override
    public boolean onCreate() {
        final Context context = getContext();
        mDbHelper = ContactsDatabaseHelper.getInstance(context);
        return true;
    }

    /**
     * Called when a change has been made.
     *
     * @param uri the uri that the change was made to
     */
    private void onChange(Uri uri) {
        getContext().getContentResolver().notifyChange(ContactsContract.AUTHORITY_URI, null);
    }

    /** {@inheritDoc} */
    @Override
    public boolean isTemporary() {
        return false;
    }

    /** {@inheritDoc} */
    @Override
    public Uri insert(Uri uri, ContentValues values) {
        final int match = sUriMatcher.match(uri);
        long id = 0;
        switch (match) {
            case ACTIVITIES: {
                id = insertActivity(values);
                break;
            }

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

        final Uri result = ContentUris.withAppendedId(Activities.CONTENT_URI, id);
        onChange(result);
        return result;
    }

    /**
     * Inserts an item into the {@link Tables#ACTIVITIES} table.
     *
     * @param values the values for the new row
     * @return the row ID of the newly created row
     */
    private long insertActivity(ContentValues values) {

        // TODO verify that IN_REPLY_TO != RAW_ID

        final SQLiteDatabase db = mDbHelper.getWritableDatabase();
        long id = 0;
        db.beginTransaction();
        try {
            // TODO: Consider enforcing Binder.getCallingUid() for package name
            // requested by this insert.

            // Replace package name and mime-type with internal mappings
            final String packageName = values.getAsString(Activities.RES_PACKAGE);
            if (packageName != null) {
                values.put(ActivitiesColumns.PACKAGE_ID, mDbHelper.getPackageId(packageName));
            }
            values.remove(Activities.RES_PACKAGE);

            final String mimeType = values.getAsString(Activities.MIMETYPE);
            values.put(ActivitiesColumns.MIMETYPE_ID, mDbHelper.getMimeTypeId(mimeType));
            values.remove(Activities.MIMETYPE);

            long published = values.getAsLong(Activities.PUBLISHED);
            long threadPublished = published;

            String inReplyTo = values.getAsString(Activities.IN_REPLY_TO);
            if (inReplyTo != null) {
                threadPublished = getThreadPublished(db, inReplyTo, published);
            }

            values.put(Activities.THREAD_PUBLISHED, threadPublished);

            // Insert the data row itself
            id = db.insert(Tables.ACTIVITIES, Activities.RAW_ID, values);

            // Adjust thread timestamps on replies that have already been inserted
            if (values.containsKey(Activities.RAW_ID)) {
                adjustReplyTimestamps(db, values.getAsString(Activities.RAW_ID), published);
            }

            db.setTransactionSuccessful();
        } finally {
            db.endTransaction();
        }
        return id;
    }

    /**
     * Finds the timestamp of the original message in the thread. If not found, returns
     * {@code defaultValue}.
     */
    private long getThreadPublished(SQLiteDatabase db, String rawId, long defaultValue) {
        String inReplyTo = null;
        long threadPublished = defaultValue;

        final Cursor c = db.query(Tables.ACTIVITIES,
                new String[]{Activities.IN_REPLY_TO, Activities.PUBLISHED},
                Activities.RAW_ID + " = ?", new String[]{rawId}, null, null, null);
        try {
            if (c.moveToFirst()) {
                inReplyTo = c.getString(0);
                threadPublished = c.getLong(1);
            }
        } finally {
            c.close();
        }

        if (inReplyTo != null) {

            // Call recursively to obtain the original timestamp of the entire thread
            return getThreadPublished(db, inReplyTo, threadPublished);
        }

        return threadPublished;
    }

    /**
     * In case the original message of a thread arrives after its reply messages, we need
     * to check if there are any replies in the database and if so adjust their thread_published.
     */
    private void adjustReplyTimestamps(SQLiteDatabase db, String inReplyTo, long threadPublished) {

        ContentValues values = new ContentValues();
        values.put(Activities.THREAD_PUBLISHED, threadPublished);

        /*
         * Issuing an exploratory update. If it updates nothing, we are done.  Otherwise,
         * we will run a query to find the updated records again and repeat recursively.
         */
        int replies = db.update(Tables.ACTIVITIES, values,
                Activities.IN_REPLY_TO + "= ?", new String[] {inReplyTo});

        if (replies == 0) {
            return;
        }

        /*
         * Presumably this code will be executed very infrequently since messages tend to arrive
         * in the order they get sent.
         */
        ArrayList<String> rawIds = new ArrayList<String>(replies);
        final Cursor c = db.query(Tables.ACTIVITIES,
                new String[]{Activities.RAW_ID},
                Activities.IN_REPLY_TO + " = ?", new String[] {inReplyTo}, null, null, null);
        try {
            while (c.moveToNext()) {
                rawIds.add(c.getString(0));
            }
        } finally {
            c.close();
        }

        for (String rawId : rawIds) {
            adjustReplyTimestamps(db, rawId, threadPublished);
        }
    }

    /** {@inheritDoc} */
    @Override
    public int delete(Uri uri, String selection, String[] selectionArgs) {
        final SQLiteDatabase db = mDbHelper.getWritableDatabase();

        final int match = sUriMatcher.match(uri);
        switch (match) {
            case ACTIVITIES_ID: {
                final long activityId = ContentUris.parseId(uri);
                return db.delete(Tables.ACTIVITIES, Activities._ID + "=" + activityId, null);
            }

            case ACTIVITIES_AUTHORED_BY: {
                final long contactId = ContentUris.parseId(uri);
                return db.delete(Tables.ACTIVITIES, Activities.AUTHOR_CONTACT_ID + "=" + contactId, null);
            }

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

    /** {@inheritDoc} */
    @Override
    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
        throw new UnsupportedOperationException();
    }

    /** {@inheritDoc} */
    @Override
    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
            String sortOrder) {
        final SQLiteDatabase db = mDbHelper.getReadableDatabase();
        final SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
        String limit = null;

        final int match = sUriMatcher.match(uri);
        switch (match) {
            case ACTIVITIES: {
                qb.setTables(Tables.ACTIVITIES_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS_CONTACTS);
                qb.setProjectionMap(sActivitiesContactsProjectionMap);
                break;
            }

            case ACTIVITIES_ID: {
                // TODO: enforce that caller has read access to this data
                long activityId = ContentUris.parseId(uri);
                qb.setTables(Tables.ACTIVITIES_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS_CONTACTS);
                qb.setProjectionMap(sActivitiesContactsProjectionMap);
                qb.appendWhere(Activities._ID + "=" + activityId);
                break;
            }

            case ACTIVITIES_AUTHORED_BY: {
                long contactId = ContentUris.parseId(uri);
                qb.setTables(Tables.ACTIVITIES_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS_CONTACTS);
                qb.setProjectionMap(sActivitiesContactsProjectionMap);
                qb.appendWhere(Activities.AUTHOR_CONTACT_ID + "=" + contactId);
                break;
            }

            case CONTACT_STATUS_ID: {
                long aggId = ContentUris.parseId(uri);
                qb.setTables(Tables.ACTIVITIES_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS_CONTACTS);
                qb.setProjectionMap(sActivitiesContactsProjectionMap);

                // Latest status of a contact is any top-level status
                // authored by one of its children contacts.
                qb.appendWhere(Activities.IN_REPLY_TO + " IS NULL AND ");
                qb.appendWhere(Activities.AUTHOR_CONTACT_ID + " IN (SELECT " + BaseColumns._ID
                        + " FROM " + Tables.RAW_CONTACTS + " WHERE " + RawContacts.CONTACT_ID + "="
                        + aggId + ")");
                sortOrder = Activities.PUBLISHED + " DESC";
                limit = "1";
                break;
            }

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

        // Default to reverse-chronological sort if nothing requested
        if (sortOrder == null) {
            sortOrder = DEFAULT_SORT_ORDER;
        }

        // Perform the query and set the notification uri
        final Cursor c = qb.query(db, projection, selection, selectionArgs, null, null, sortOrder, limit);
        if (c != null) {
            c.setNotificationUri(getContext().getContentResolver(), ContactsContract.AUTHORITY_URI);
        }
        return c;
    }

    @Override
    public String getType(Uri uri) {
        final int match = sUriMatcher.match(uri);
        switch (match) {
            case ACTIVITIES:
            case ACTIVITIES_AUTHORED_BY:
                return Activities.CONTENT_TYPE;
            case ACTIVITIES_ID:
                final SQLiteDatabase db = mDbHelper.getReadableDatabase();
                long activityId = ContentUris.parseId(uri);
                return mDbHelper.getActivityMimeType(activityId);
            case CONTACT_STATUS_ID:
                return Contacts.CONTENT_ITEM_TYPE;
        }
        throw new UnsupportedOperationException("Unknown uri: " + uri);
    }
}