1/*
2 * Copyright (C) 2009 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License
15 */
16
17package com.android.providers.contacts;
18
19import com.android.providers.contacts.ContactsDatabaseHelper.ActivitiesColumns;
20import com.android.providers.contacts.ContactsDatabaseHelper.ContactsColumns;
21import com.android.providers.contacts.ContactsDatabaseHelper.PackagesColumns;
22import com.android.providers.contacts.ContactsDatabaseHelper.Tables;
23
24import android.content.ContentProvider;
25import android.content.ContentUris;
26import android.content.ContentValues;
27import android.content.Context;
28import android.content.UriMatcher;
29import android.database.Cursor;
30import android.database.sqlite.SQLiteDatabase;
31import android.database.sqlite.SQLiteQueryBuilder;
32import android.provider.BaseColumns;
33import android.provider.ContactsContract;
34import android.provider.ContactsContract.Contacts;
35import android.provider.ContactsContract.RawContacts;
36import android.provider.SocialContract;
37import android.provider.SocialContract.Activities;
38
39import android.net.Uri;
40
41import java.util.ArrayList;
42import java.util.HashMap;
43
44/**
45 * Social activity content provider. The contract between this provider and
46 * applications is defined in {@link SocialContract}.
47 */
48public class SocialProvider extends ContentProvider {
49    // TODO: clean up debug tag
50    private static final String TAG = "SocialProvider ~~~~";
51
52    private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
53
54    private static final int ACTIVITIES = 1000;
55    private static final int ACTIVITIES_ID = 1001;
56    private static final int ACTIVITIES_AUTHORED_BY = 1002;
57
58    private static final int CONTACT_STATUS_ID = 3000;
59
60    private static final String DEFAULT_SORT_ORDER = Activities.THREAD_PUBLISHED + " DESC, "
61            + Activities.PUBLISHED + " ASC";
62
63    /** Contains just the contacts columns */
64    private static final HashMap<String, String> sContactsProjectionMap;
65    /** Contains just the contacts columns */
66    private static final HashMap<String, String> sRawContactsProjectionMap;
67    /** Contains just the activities columns */
68    private static final HashMap<String, String> sActivitiesProjectionMap;
69
70    /** Contains the activities, raw contacts, and contacts columns, for joined tables */
71    private static final HashMap<String, String> sActivitiesContactsProjectionMap;
72
73    static {
74        // Contacts URI matching table
75        final UriMatcher matcher = sUriMatcher;
76
77        matcher.addURI(SocialContract.AUTHORITY, "activities", ACTIVITIES);
78        matcher.addURI(SocialContract.AUTHORITY, "activities/#", ACTIVITIES_ID);
79        matcher.addURI(SocialContract.AUTHORITY, "activities/authored_by/#", ACTIVITIES_AUTHORED_BY);
80
81        matcher.addURI(SocialContract.AUTHORITY, "contact_status/#", CONTACT_STATUS_ID);
82
83        HashMap<String, String> columns;
84
85        // Contacts projection map
86        columns = new HashMap<String, String>();
87        // TODO: fix display name reference (in fact, use the contacts view instead of the table)
88        columns.put(Contacts.DISPLAY_NAME, "contact." + Contacts.DISPLAY_NAME + " AS "
89                + Contacts.DISPLAY_NAME);
90        sContactsProjectionMap = columns;
91
92        // Contacts projection map
93        columns = new HashMap<String, String>();
94        columns.put(RawContacts._ID, Tables.RAW_CONTACTS + "." + RawContacts._ID + " AS _id");
95        columns.put(RawContacts.CONTACT_ID, RawContacts.CONTACT_ID);
96        sRawContactsProjectionMap = columns;
97
98        // Activities projection map
99        columns = new HashMap<String, String>();
100        columns.put(Activities._ID, "activities._id AS _id");
101        columns.put(Activities.RES_PACKAGE, PackagesColumns.PACKAGE + " AS "
102                + Activities.RES_PACKAGE);
103        columns.put(Activities.MIMETYPE, Activities.MIMETYPE);
104        columns.put(Activities.RAW_ID, Activities.RAW_ID);
105        columns.put(Activities.IN_REPLY_TO, Activities.IN_REPLY_TO);
106        columns.put(Activities.AUTHOR_CONTACT_ID, Activities.AUTHOR_CONTACT_ID);
107        columns.put(Activities.TARGET_CONTACT_ID, Activities.TARGET_CONTACT_ID);
108        columns.put(Activities.PUBLISHED, Activities.PUBLISHED);
109        columns.put(Activities.THREAD_PUBLISHED, Activities.THREAD_PUBLISHED);
110        columns.put(Activities.TITLE, Activities.TITLE);
111        columns.put(Activities.SUMMARY, Activities.SUMMARY);
112        columns.put(Activities.LINK, Activities.LINK);
113        columns.put(Activities.THUMBNAIL, Activities.THUMBNAIL);
114        sActivitiesProjectionMap = columns;
115
116        // Activities, raw contacts, and contacts projection map for joins
117        columns = new HashMap<String, String>();
118        columns.putAll(sContactsProjectionMap);
119        columns.putAll(sRawContactsProjectionMap);
120        columns.putAll(sActivitiesProjectionMap); // Final _id will be from Activities
121        sActivitiesContactsProjectionMap = columns;
122
123    }
124
125    private ContactsDatabaseHelper mDbHelper;
126
127    /** {@inheritDoc} */
128    @Override
129    public boolean onCreate() {
130        final Context context = getContext();
131        mDbHelper = ContactsDatabaseHelper.getInstance(context);
132        return true;
133    }
134
135    /**
136     * Called when a change has been made.
137     *
138     * @param uri the uri that the change was made to
139     */
140    private void onChange(Uri uri) {
141        getContext().getContentResolver().notifyChange(ContactsContract.AUTHORITY_URI, null);
142    }
143
144    /** {@inheritDoc} */
145    @Override
146    public boolean isTemporary() {
147        return false;
148    }
149
150    /** {@inheritDoc} */
151    @Override
152    public Uri insert(Uri uri, ContentValues values) {
153        final int match = sUriMatcher.match(uri);
154        long id = 0;
155        switch (match) {
156            case ACTIVITIES: {
157                id = insertActivity(values);
158                break;
159            }
160
161            default:
162                throw new UnsupportedOperationException("Unknown uri: " + uri);
163        }
164
165        final Uri result = ContentUris.withAppendedId(Activities.CONTENT_URI, id);
166        onChange(result);
167        return result;
168    }
169
170    /**
171     * Inserts an item into the {@link Tables#ACTIVITIES} table.
172     *
173     * @param values the values for the new row
174     * @return the row ID of the newly created row
175     */
176    private long insertActivity(ContentValues values) {
177
178        // TODO verify that IN_REPLY_TO != RAW_ID
179
180        final SQLiteDatabase db = mDbHelper.getWritableDatabase();
181        long id = 0;
182        db.beginTransaction();
183        try {
184            // TODO: Consider enforcing Binder.getCallingUid() for package name
185            // requested by this insert.
186
187            // Replace package name and mime-type with internal mappings
188            final String packageName = values.getAsString(Activities.RES_PACKAGE);
189            if (packageName != null) {
190                values.put(ActivitiesColumns.PACKAGE_ID, mDbHelper.getPackageId(packageName));
191            }
192            values.remove(Activities.RES_PACKAGE);
193
194            final String mimeType = values.getAsString(Activities.MIMETYPE);
195            values.put(ActivitiesColumns.MIMETYPE_ID, mDbHelper.getMimeTypeId(mimeType));
196            values.remove(Activities.MIMETYPE);
197
198            long published = values.getAsLong(Activities.PUBLISHED);
199            long threadPublished = published;
200
201            String inReplyTo = values.getAsString(Activities.IN_REPLY_TO);
202            if (inReplyTo != null) {
203                threadPublished = getThreadPublished(db, inReplyTo, published);
204            }
205
206            values.put(Activities.THREAD_PUBLISHED, threadPublished);
207
208            // Insert the data row itself
209            id = db.insert(Tables.ACTIVITIES, Activities.RAW_ID, values);
210
211            // Adjust thread timestamps on replies that have already been inserted
212            if (values.containsKey(Activities.RAW_ID)) {
213                adjustReplyTimestamps(db, values.getAsString(Activities.RAW_ID), published);
214            }
215
216            db.setTransactionSuccessful();
217        } finally {
218            db.endTransaction();
219        }
220        return id;
221    }
222
223    /**
224     * Finds the timestamp of the original message in the thread. If not found, returns
225     * {@code defaultValue}.
226     */
227    private long getThreadPublished(SQLiteDatabase db, String rawId, long defaultValue) {
228        String inReplyTo = null;
229        long threadPublished = defaultValue;
230
231        final Cursor c = db.query(Tables.ACTIVITIES,
232                new String[]{Activities.IN_REPLY_TO, Activities.PUBLISHED},
233                Activities.RAW_ID + " = ?", new String[]{rawId}, null, null, null);
234        try {
235            if (c.moveToFirst()) {
236                inReplyTo = c.getString(0);
237                threadPublished = c.getLong(1);
238            }
239        } finally {
240            c.close();
241        }
242
243        if (inReplyTo != null) {
244
245            // Call recursively to obtain the original timestamp of the entire thread
246            return getThreadPublished(db, inReplyTo, threadPublished);
247        }
248
249        return threadPublished;
250    }
251
252    /**
253     * In case the original message of a thread arrives after its reply messages, we need
254     * to check if there are any replies in the database and if so adjust their thread_published.
255     */
256    private void adjustReplyTimestamps(SQLiteDatabase db, String inReplyTo, long threadPublished) {
257
258        ContentValues values = new ContentValues();
259        values.put(Activities.THREAD_PUBLISHED, threadPublished);
260
261        /*
262         * Issuing an exploratory update. If it updates nothing, we are done.  Otherwise,
263         * we will run a query to find the updated records again and repeat recursively.
264         */
265        int replies = db.update(Tables.ACTIVITIES, values,
266                Activities.IN_REPLY_TO + "= ?", new String[] {inReplyTo});
267
268        if (replies == 0) {
269            return;
270        }
271
272        /*
273         * Presumably this code will be executed very infrequently since messages tend to arrive
274         * in the order they get sent.
275         */
276        ArrayList<String> rawIds = new ArrayList<String>(replies);
277        final Cursor c = db.query(Tables.ACTIVITIES,
278                new String[]{Activities.RAW_ID},
279                Activities.IN_REPLY_TO + " = ?", new String[] {inReplyTo}, null, null, null);
280        try {
281            while (c.moveToNext()) {
282                rawIds.add(c.getString(0));
283            }
284        } finally {
285            c.close();
286        }
287
288        for (String rawId : rawIds) {
289            adjustReplyTimestamps(db, rawId, threadPublished);
290        }
291    }
292
293    /** {@inheritDoc} */
294    @Override
295    public int delete(Uri uri, String selection, String[] selectionArgs) {
296        final SQLiteDatabase db = mDbHelper.getWritableDatabase();
297
298        final int match = sUriMatcher.match(uri);
299        switch (match) {
300            case ACTIVITIES_ID: {
301                final long activityId = ContentUris.parseId(uri);
302                return db.delete(Tables.ACTIVITIES, Activities._ID + "=" + activityId, null);
303            }
304
305            case ACTIVITIES_AUTHORED_BY: {
306                final long contactId = ContentUris.parseId(uri);
307                return db.delete(Tables.ACTIVITIES, Activities.AUTHOR_CONTACT_ID + "=" + contactId, null);
308            }
309
310            default:
311                throw new UnsupportedOperationException("Unknown uri: " + uri);
312        }
313    }
314
315    /** {@inheritDoc} */
316    @Override
317    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
318        throw new UnsupportedOperationException();
319    }
320
321    /** {@inheritDoc} */
322    @Override
323    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
324            String sortOrder) {
325        final SQLiteDatabase db = mDbHelper.getReadableDatabase();
326        final SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
327        String limit = null;
328
329        final int match = sUriMatcher.match(uri);
330        switch (match) {
331            case ACTIVITIES: {
332                qb.setTables(Tables.ACTIVITIES_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS_CONTACTS);
333                qb.setProjectionMap(sActivitiesContactsProjectionMap);
334                break;
335            }
336
337            case ACTIVITIES_ID: {
338                // TODO: enforce that caller has read access to this data
339                long activityId = ContentUris.parseId(uri);
340                qb.setTables(Tables.ACTIVITIES_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS_CONTACTS);
341                qb.setProjectionMap(sActivitiesContactsProjectionMap);
342                qb.appendWhere(Activities._ID + "=" + activityId);
343                break;
344            }
345
346            case ACTIVITIES_AUTHORED_BY: {
347                long contactId = ContentUris.parseId(uri);
348                qb.setTables(Tables.ACTIVITIES_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS_CONTACTS);
349                qb.setProjectionMap(sActivitiesContactsProjectionMap);
350                qb.appendWhere(Activities.AUTHOR_CONTACT_ID + "=" + contactId);
351                break;
352            }
353
354            case CONTACT_STATUS_ID: {
355                long aggId = ContentUris.parseId(uri);
356                qb.setTables(Tables.ACTIVITIES_JOIN_PACKAGES_MIMETYPES_RAW_CONTACTS_CONTACTS);
357                qb.setProjectionMap(sActivitiesContactsProjectionMap);
358
359                // Latest status of a contact is any top-level status
360                // authored by one of its children contacts.
361                qb.appendWhere(Activities.IN_REPLY_TO + " IS NULL AND ");
362                qb.appendWhere(Activities.AUTHOR_CONTACT_ID + " IN (SELECT " + BaseColumns._ID
363                        + " FROM " + Tables.RAW_CONTACTS + " WHERE " + RawContacts.CONTACT_ID + "="
364                        + aggId + ")");
365                sortOrder = Activities.PUBLISHED + " DESC";
366                limit = "1";
367                break;
368            }
369
370            default:
371                throw new UnsupportedOperationException("Unknown uri: " + uri);
372        }
373
374        // Default to reverse-chronological sort if nothing requested
375        if (sortOrder == null) {
376            sortOrder = DEFAULT_SORT_ORDER;
377        }
378
379        // Perform the query and set the notification uri
380        final Cursor c = qb.query(db, projection, selection, selectionArgs, null, null, sortOrder, limit);
381        if (c != null) {
382            c.setNotificationUri(getContext().getContentResolver(), ContactsContract.AUTHORITY_URI);
383        }
384        return c;
385    }
386
387    @Override
388    public String getType(Uri uri) {
389        final int match = sUriMatcher.match(uri);
390        switch (match) {
391            case ACTIVITIES:
392            case ACTIVITIES_AUTHORED_BY:
393                return Activities.CONTENT_TYPE;
394            case ACTIVITIES_ID:
395                final SQLiteDatabase db = mDbHelper.getReadableDatabase();
396                long activityId = ContentUris.parseId(uri);
397                return mDbHelper.getActivityMimeType(activityId);
398            case CONTACT_STATUS_ID:
399                return Contacts.CONTENT_ITEM_TYPE;
400        }
401        throw new UnsupportedOperationException("Unknown uri: " + uri);
402    }
403}
404