/* * Copyright 2010 John R. Hicks * * 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.determinato.feeddroid.provider; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.util.HashMap; import android.content.ContentProvider; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.UriMatcher; import android.content.res.Resources; import android.database.Cursor; import android.database.SQLException; import android.database.sqlite.SQLiteConstraintException; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteException; import android.database.sqlite.SQLiteOpenHelper; import android.database.sqlite.SQLiteQueryBuilder; import android.net.Uri; import android.os.ParcelFileDescriptor; import android.text.TextUtils; import android.util.Log; import com.determinato.feeddroid.R; import com.determinato.feeddroid.util.FeedDroidUtils; /** * Content provider to provide database access. * @author John R. Hicks <john@determinato.com> * */ public class FeedDroidProvider extends ContentProvider { private static final String TAG = "FeedDroidProvider"; private static final String DB_NAME = "feeddroid.db"; // ======== IMPORTANT ======================================== // Increment this when table schema changes! private static final int DB_VERSION = 9; // ======== IMPORTANT ========================================= private static HashMap<String, String> CHANNEL_LIST_PROJECTION; private static HashMap<String, String> POST_LIST_PROJECTION; private static HashMap<String, String> FOLDER_LIST_PROJECTION; private static final int CHANNELS = 1; private static final int CHANNEL_ID = 2; private static final int POSTS = 3; private static final int POST_ID = 4; private static final int CHANNEL_POSTS = 5; private static final int CHANNELICON_ID = 6; private static final int FOLDERS = 7; private static final int FOLDER_ID = 8; private static final int UNREAD = 9; private static final UriMatcher URL_MATCHER; private SQLiteDatabase mDb; /** * Helper class to create/update database. * @author john.hicks * */ private static class DbHelper extends SQLiteOpenHelper { /** * Constructor. * @param ctx application context */ DbHelper(Context ctx) { super(ctx, DB_NAME, null, DB_VERSION); } /** * Creates Channels table. * @param db database */ protected void onCreateChannels(SQLiteDatabase db) { String query = "CREATE TABLE channels (_id INTEGER PRIMARY KEY AUTOINCREMENT ," + "title TEXT UNIQUE, url TEXT UNIQUE, " + "icon TEXT, icon_url TEXT, logo TEXT, folder_id INTEGER(1) DEFAULT '1');"; db.execSQL(query); db.execSQL("CREATE INDEX idx_folders ON channels (folder_id);"); } /** * Creates Posts table. * @param db database */ protected void onCreatePosts(SQLiteDatabase db) { String query = "CREATE TABLE posts (_id INTEGER PRIMARY KEY AUTOINCREMENT ," + "channel_id INTEGER, title TEXT UNIQUE, url TEXT UNIQUE, " + "posted_on DATETIME, body TEXT, author TEXT, read INTEGER(1) DEFAULT '0', " + "starred INTEGER(1) DEFAULT '0', podcast_url TEXT, podcast_mime_type TEXT);"; db.execSQL(query); // Create indexes db.execSQL("CREATE UNIQUE INDEX idx_post ON posts (title, url);"); db.execSQL("CREATE INDEX idx_channel ON posts (channel_id);"); } /** * Create Folders table. * @param db database */ protected void onCreateFolders(SQLiteDatabase db) { String query = "CREATE TABLE folders (_id INTEGER PRIMARY KEY AUTOINCREMENT ," + "name TEXT NOT NULL, parent_id INTEGER DEFAULT '0');"; db.execSQL(query); db.execSQL("insert into folders(name) values('HOME');"); } /** * {@inheritDoc} */ @Override public void onCreate(SQLiteDatabase db) { onCreateChannels(db); onCreatePosts(db); onCreateFolders(db); } /** * {@inheritDoc} */ @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { Log.w(TAG, "Upgrading database from version " + oldVersion + " to " + newVersion + "..."); String query = ""; // IMPORTANT: This switch provides a way to migrate from one version // of the schema to another. Make sure the numbers match the current schema version! switch(oldVersion) { case 8: query = "ALTER TABLE posts ADD podcast_mime_type TEXT"; db.execSQL(query); break; default: Log.w(TAG, "Version too old, wiping out database contents..."); db.execSQL("DROP TABLE IF EXISTS channels"); db.execSQL("DROP TABLE IF EXISTS posts"); onCreate(db); break; } } } /** * {@inheritDoc} */ @Override public int delete(Uri uri, String selection, String[] selectionArgs) { int count; String where; switch(URL_MATCHER.match(uri)) { case CHANNELS: count = mDb.delete("channels", selection, selectionArgs); break; case CHANNEL_ID: where = "_id=" + uri.getPathSegments().get(1) + (!TextUtils.isEmpty(selection) ? " AND (" + selection + ")" : "" ); count = mDb.delete("posts", where, selectionArgs); break; case POSTS: count = mDb.delete("posts", selection, selectionArgs); break; case POST_ID: where = "_id=" + uri.getPathSegments().get(1) + (!TextUtils.isEmpty(selection) ? " AND (" + selection + ")" : ""); count = mDb.delete("posts", where, selectionArgs); break; case FOLDERS: count = mDb.delete("folders", selection, selectionArgs); break; case FOLDER_ID: where = "_id=" + uri.getPathSegments().get(1) + (!TextUtils.isEmpty(selection) ? " AND (" + selection + ")" : ""); count = mDb.delete("folders", where, selectionArgs); break; default: throw new IllegalArgumentException("Unknown URL: " + uri); } getContext().getContentResolver().notifyChange(uri, null); return count; } /** * {@inheritDoc} */ @Override public String getType(Uri uri) { switch(URL_MATCHER.match(uri)) { case CHANNELS: return "vnd.android.cursor.dir/vnd.feeddroid.channel"; case CHANNEL_ID: return "vnd.android.cursor.item/vnd.feeddroid.channel"; case CHANNELICON_ID: return "image/x-icon"; case POSTS: case CHANNEL_POSTS: return "vnd.android.cursor.dir/vnd.feeddroid.post"; case POST_ID: return "vnd.android.cursor.item/vnd.feeddroid.post"; case FOLDERS: return "vnd.android.cursor.dir/vnd.feeddroid.folder"; case FOLDER_ID: return "vnd.android.cursor.item/vnd.feeddroid.folder"; case UNREAD: return "vnd.android.cursor.item/vnd.feeddroid.post"; default: throw new IllegalArgumentException("Unknown URL: " + uri); } } /** * Returns icon filename. * @param channelId ID of the channel to retreive the icon for. * @return String containing filename */ private String getIconFilename(long channelId) { return "channel" + channelId + ".ico"; } /** * Returns icon path * @param channelId * @return */ private String getIconPath(long channelId) { return getContext().getFileStreamPath(getIconFilename(channelId)).getAbsolutePath(); } /** * Copys default RSS icon * @param path * @throws FileNotFoundException * @throws IOException */ private void copyDefaultIcon(String path) throws FileNotFoundException, IOException{ FileOutputStream out = new FileOutputStream(path); InputStream ico = getContext().getResources().openRawResource(R.drawable.rssorange); byte[] buf = new byte[1024]; int n; while ((n = ico.read(buf)) != -1) out.write(buf, 0, n); ico.close(); out.close(); } public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { switch(URL_MATCHER.match(uri)) { case CHANNELICON_ID: long id = Long.valueOf(uri.getPathSegments().get(1)); String path = getIconPath(id); if (mode.equals("rw") == true) { FileOutputStream foo = getContext().openFileOutput(getIconFilename(id), 0); try { foo.write(new byte[] {'t'}); foo.close(); } catch (Exception e) {} } File file = new File(path); int modeInt; if (mode.equals("rw") == true) { modeInt = ParcelFileDescriptor.MODE_READ_WRITE | ParcelFileDescriptor.MODE_TRUNCATE; } else { modeInt = ParcelFileDescriptor.MODE_READ_ONLY; if (file.exists() == false) { try { copyDefaultIcon(path); } catch (IOException e) { Log.d(TAG, "Unable to create default feed icon", e); return null; } } } return ParcelFileDescriptor.open(file, modeInt); default: throw new IllegalArgumentException("Unknown URL: " + uri); } } /** * Inserts channel into the database. * @param values ContentValues containing channel details * @return ID of new channel */ private long insertChannels(ContentValues values) { Resources r = Resources.getSystem(); if (values.containsKey(FeedDroid.Channels.TITLE) == false) values.put(FeedDroid.Channels.TITLE, r.getString(android.R.string.untitled)); long folderId = values.getAsLong(FeedDroid.Channels.FOLDER_ID); long id = -1; mDb.beginTransaction(); try { id = mDb.insert("channels", FeedDroid.Channels.TITLE, values); if (values.containsKey(FeedDroid.Channels.ICON) == false) { Uri iconUri; iconUri = FeedDroid.Channels.CONTENT_URI.buildUpon() .appendPath(String.valueOf(id)) .appendPath("icon") .build(); ContentValues update = new ContentValues(); update.put(FeedDroid.Channels.ICON, iconUri.toString()); mDb.update("channels", update, "_id=" + id, null); update = new ContentValues(); update.put(FeedDroid.Channels.FOLDER_ID, folderId); mDb.update("channels", update, "_id=" + id, null); mDb.setTransactionSuccessful(); } } catch (SQLiteConstraintException e) { Log.d(TAG, "Ignoring duplicate channel: " + values.getAsString(FeedDroid.Channels.URL)); return id; } finally { mDb.endTransaction(); } return id; } /** * Inserts post into the database. * @param values ContentValues containing post details * @return ID of new post */ private long insertPosts(ContentValues values) { long id = -1; try { if (!checkForDuplicatePost(values.getAsString("url"))) { mDb.insert("posts", "title", values); FeedDroidUtils.setNewUpdates(true); } } catch (SQLiteConstraintException e) { Log.d(TAG, "Cannot insert post: " + values.getAsString("url")); // Eating this exception } return id; } /** * Checks the database to ensure a URL doesn't already exist. * @return true if exists, false otherwise */ private boolean checkForDuplicatePost(String url) { boolean dup = false; String[] projection = {FeedDroid.Posts._ID}; Cursor c = mDb.query("posts", projection, "url like '%" + url + "%'", null, null, null, null); if (c.getCount() > 0) dup = true; c.close(); return dup; } /** * Inserts folder into database * @param values ContentValues containing folder data * @return ID of new folder */ private long insertFolders(ContentValues values) { long id = -1; try { if (!checkForDuplicateFolder(values.getAsString(FeedDroid.Folders.NAME), values.getAsLong(FeedDroid.Folders.PARENT_ID))) id = mDb.insert("folders", FeedDroid.Folders.NAME, values); } catch (SQLiteConstraintException e) { } return id; } /** * Checks database for duplicate folder * @param folderName name of folder * @param parentFolder ID of parent folder * @return true if exists, false otherwise */ private boolean checkForDuplicateFolder(String folderName, long parentFolder) { boolean dup = false; String[] projection = {FeedDroid.Folders._ID}; Cursor c = mDb.query("folders", projection, "name like '%" + folderName + "%' and parent_id=" + parentFolder, null, null, null, null); if (c.getCount() > 0) { Log.d(TAG, "Folder " + folderName + " in parent " + parentFolder + " is a duplicate. Ignoring."); dup = true; } c.close(); return dup; } /** * @{inheritDoc} */ @Override public Uri insert(Uri url, ContentValues initialValues) { long rowId; ContentValues values; if (initialValues != null) values = new ContentValues(initialValues); else values = new ContentValues(); Uri uri; switch(URL_MATCHER.match(url)) { case CHANNELS: rowId = insertChannels(values); uri = ContentUris.withAppendedId(FeedDroid.Channels.CONTENT_URI, rowId); break; case POSTS: rowId = insertPosts(values); uri = ContentUris.withAppendedId(FeedDroid.Posts.CONTENT_URI, rowId); break; case FOLDERS: rowId = insertFolders(values); uri = ContentUris.withAppendedId(FeedDroid.Folders.CONTENT_URI, rowId); break; default: throw new IllegalArgumentException("Unknown URL: " + url); } if (rowId > 0) getContext().getContentResolver().notifyChange(uri, null); return uri; } /** * {@inheritDoc} */ @Override public boolean onCreate() { DbHelper helper = new DbHelper(getContext()); try { mDb = helper.getWritableDatabase(); } catch (SQLiteException e) { mDb = helper.getReadableDatabase(); } return (mDb == null) ? false : true; } /** * {@inheritDoc} */ @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); String defaultSort = null; String groupBy = null; String having = null; switch(URL_MATCHER.match(uri)) { case CHANNELS: qb.setTables("channels"); qb.setProjectionMap(CHANNEL_LIST_PROJECTION); defaultSort = FeedDroid.Channels.DEFAULT_SORT_ORDER; break; case CHANNEL_ID: qb.setTables("channels"); qb.appendWhere("_id=" + uri.getPathSegments().get(1)); break; case POSTS: qb.setTables("posts"); qb.setProjectionMap(POST_LIST_PROJECTION); groupBy = "_id"; having = "COUNT(title) = 1"; defaultSort = FeedDroid.Posts.DEFAULT_SORT_ORDER; break; case CHANNEL_POSTS: qb.setTables("posts"); qb.appendWhere("channel_id=" + uri.getPathSegments().get(1)); qb.setProjectionMap(POST_LIST_PROJECTION); defaultSort = FeedDroid.Posts.DEFAULT_SORT_ORDER; break; case POST_ID: qb.setTables("posts"); groupBy = "_id"; having = "COUNT(title) = 1"; qb.appendWhere("_id=" + uri.getPathSegments().get(1)); break; case FOLDERS: qb.setTables("folders"); qb.setProjectionMap(FOLDER_LIST_PROJECTION); break; case FOLDER_ID: qb.setTables("folders"); qb.appendWhere("_id=" + uri.getPathSegments().get(1)); break; case UNREAD: qb.setTables("posts"); qb.appendWhere("channel_id=" + uri.getPathSegments().get(1) + " and read=0"); break; default: throw new IllegalArgumentException("Unknown URL: " + uri); } String orderBy; if (TextUtils.isEmpty(sortOrder)) orderBy = defaultSort; else orderBy = sortOrder; Cursor c = qb.query(mDb, projection, selection, selectionArgs, groupBy, having, orderBy); //Log.d(TAG, DatabaseUtils.dumpCursorToString(c)); c.setNotificationUri(getContext().getContentResolver(), uri); return c; } /** * {@inheritDoc} */ @Override public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { int count; String where; switch(URL_MATCHER.match(uri)) { case CHANNELS: count = mDb.update("channels", values, selection, selectionArgs); break; case CHANNEL_ID: where = "_id=" + uri.getPathSegments().get(1) + (!TextUtils.isEmpty(selection) ? " AND (" + selection + ")" : ""); count = mDb.update("channels", values, where, selectionArgs); break; case POSTS: count = mDb.update("posts", values, selection, selectionArgs); break; case POST_ID: where = "_id=" + uri.getPathSegments().get(1) + (!TextUtils.isEmpty(selection) ? " AND (" + selection + ")" : ""); count = mDb.update("posts", values, where, selectionArgs); break; case FOLDERS: count = mDb.update("folders", values, selection, selectionArgs); break; case FOLDER_ID: where = "_id=" + uri.getPathSegments().get(1) + (!TextUtils.isEmpty(selection) ? " AND (" + selection + ")" : ""); count = mDb.update("folders", values, where, selectionArgs); break; default: throw new IllegalArgumentException("Unknown URL: " + uri); } getContext().getContentResolver().notifyChange(uri, null); return count; } static { URL_MATCHER = new UriMatcher(UriMatcher.NO_MATCH); URL_MATCHER.addURI(FeedDroid.AUTHORITY, "channels", CHANNELS); URL_MATCHER.addURI(FeedDroid.AUTHORITY, "channels/#", CHANNEL_ID); URL_MATCHER.addURI(FeedDroid.AUTHORITY, "channels/#/icon", CHANNELICON_ID); URL_MATCHER.addURI(FeedDroid.AUTHORITY, "posts", POSTS); URL_MATCHER.addURI(FeedDroid.AUTHORITY, "posts/#", POST_ID); URL_MATCHER.addURI(FeedDroid.AUTHORITY, "postlist/#", CHANNEL_POSTS); URL_MATCHER.addURI(FeedDroid.AUTHORITY, "folders", FOLDERS); URL_MATCHER.addURI(FeedDroid.AUTHORITY, "folders/#", FOLDER_ID); URL_MATCHER.addURI(FeedDroid.AUTHORITY, "unread/#", UNREAD); CHANNEL_LIST_PROJECTION = new HashMap<String, String>(); CHANNEL_LIST_PROJECTION.put(FeedDroid.Channels._ID, "_id"); CHANNEL_LIST_PROJECTION.put(FeedDroid.Channels.TITLE, "title"); CHANNEL_LIST_PROJECTION.put(FeedDroid.Channels.URL, "url"); CHANNEL_LIST_PROJECTION.put(FeedDroid.Channels.ICON, "icon"); CHANNEL_LIST_PROJECTION.put(FeedDroid.Channels.LOGO, "logo"); CHANNEL_LIST_PROJECTION.put(FeedDroid.Channels.FOLDER_ID, "folder_id"); POST_LIST_PROJECTION = new HashMap<String, String>(); POST_LIST_PROJECTION.put(FeedDroid.Posts._ID, "_id"); POST_LIST_PROJECTION.put(FeedDroid.Posts.CHANNEL_ID, "channel_id"); POST_LIST_PROJECTION.put(FeedDroid.Posts.READ, "read"); POST_LIST_PROJECTION.put(FeedDroid.Posts.TITLE, "title"); POST_LIST_PROJECTION.put(FeedDroid.Posts.URL, "url"); POST_LIST_PROJECTION.put(FeedDroid.Posts.AUTHOR, "author"); POST_LIST_PROJECTION.put(FeedDroid.Posts.DATE, "posted_on"); POST_LIST_PROJECTION.put(FeedDroid.Posts.BODY, "body"); POST_LIST_PROJECTION.put(FeedDroid.Posts.STARRED, "starred"); POST_LIST_PROJECTION.put(FeedDroid.Posts.PODCAST_URL, "podcast_url"); POST_LIST_PROJECTION.put(FeedDroid.Posts.PODCAST_MIME_TYPE, "podcast_mime_type"); FOLDER_LIST_PROJECTION = new HashMap<String, String>(); FOLDER_LIST_PROJECTION.put(FeedDroid.Folders._ID, "_id"); FOLDER_LIST_PROJECTION.put(FeedDroid.Folders.NAME, "name"); FOLDER_LIST_PROJECTION.put(FeedDroid.Folders.PARENT_ID, "parent_id"); } }