/* * 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.cooliris.picasa; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import android.accounts.Account; import android.accounts.AccountManager; import android.content.ContentResolver; import android.content.Context; import android.content.SyncResult; import android.content.pm.ProviderInfo; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.database.sqlite.SQLiteDatabase.CursorFactory; import android.net.Uri; import android.util.Log; public final class PicasaContentProvider extends TableContentProvider { public static final String AUTHORITY = "com.cooliris.picasa.contentprovider"; public static final Uri BASE_URI = Uri.parse("content://" + AUTHORITY); public static final Uri PHOTOS_URI = Uri.withAppendedPath(BASE_URI, "photos"); public static final Uri ALBUMS_URI = Uri.withAppendedPath(BASE_URI, "albums"); private static final String TAG = "PicasaContentProvider"; private static final String[] ID_EDITED_PROJECTION = { "_id", "date_edited" }; private static final String[] ID_EDITED_INDEX_PROJECTION = { "_id", "date_edited", "display_index" }; private static final String WHERE_ACCOUNT = "sync_account=?"; private static final String WHERE_ALBUM_ID = "album_id=?"; private final PhotoEntry mPhotoInstance = new PhotoEntry(); private final AlbumEntry mAlbumInstance = new AlbumEntry(); private SyncContext mSyncContext = null; private Account mActiveAccount; @Override public void attachInfo(Context context, ProviderInfo info) { // Initialize the provider and set the database. super.attachInfo(context, info); setDatabase(new Database(context, Database.DATABASE_NAME)); // Add mappings for each of the exposed tables. addMapping(AUTHORITY, "photos", "vnd.cooliris.picasa.photo", PhotoEntry.SCHEMA); addMapping(AUTHORITY, "albums", "vnd.cooliris.picasa.album", AlbumEntry.SCHEMA); // Create the sync context. try { mSyncContext = new SyncContext(); } catch (Exception e) { // The database wasn't created successfully, we create a memory backed database. setDatabase(new Database(context, null)); } } public static final class Database extends SQLiteOpenHelper { public static final String DATABASE_NAME = "picasa.db"; public static final int DATABASE_VERSION = 83; public Database(Context context, String name) { super(context, name, null, DATABASE_VERSION); } @Override public void onCreate(SQLiteDatabase db) { PhotoEntry.SCHEMA.createTables(db); AlbumEntry.SCHEMA.createTables(db); UserEntry.SCHEMA.createTables(db); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { // No new versions yet, if we are asked to upgrade we just reset // everything. PhotoEntry.SCHEMA.dropTables(db); AlbumEntry.SCHEMA.dropTables(db); UserEntry.SCHEMA.dropTables(db); onCreate(db); } } @Override public int delete(Uri uri, String selection, String[] selectionArgs) { // Ensure that the URI is well-formed. We currently do not allow WHERE // clauses. List<String> path = uri.getPathSegments(); if (path.size() != 2 || !uri.getAuthority().equals(AUTHORITY) || selection != null) { return 0; } // Get the sync context. SyncContext context = mSyncContext; // Determine if the URI refers to an album or photo. String type = path.get(0); long id = Long.parseLong(path.get(1)); SQLiteDatabase db = context.db; if (type.equals("photos")) { // Retrieve the photo from the database to get the edit URI. PhotoEntry photo = mPhotoInstance; if (PhotoEntry.SCHEMA.queryWithId(db, id, photo)) { // Send a DELETE request to the API. if (context.login(photo.syncAccount)) { if (context.api.deleteEntry(photo.editUri) == PicasaApi.RESULT_OK) { deletePhoto(db, id); context.photosChanged = true; return 1; } } } } else if (type.equals("albums")) { // Retrieve the album from the database to get the edit URI. AlbumEntry album = mAlbumInstance; if (AlbumEntry.SCHEMA.queryWithId(db, id, album)) { // Send a DELETE request to the API. if (context.login(album.syncAccount)) { if (context.api.deleteEntry(album.editUri) == PicasaApi.RESULT_OK) { deleteAlbum(db, id); context.albumsChanged = true; return 1; } } } } context.finish(); return 0; } public void reloadAccounts() { mSyncContext.reloadAccounts(); } public void setActiveSyncAccount(Account account) { mActiveAccount = account; } public void syncUsers(SyncResult syncResult) { syncUsers(mSyncContext, syncResult); } public void syncUsersAndAlbums(final boolean syncAlbumPhotos, SyncResult syncResult) { SyncContext context = mSyncContext; // Synchronize users authenticated on the device. UserEntry[] users = syncUsers(context, syncResult); // Synchronize albums for each user. String activeUsername = null; if (mActiveAccount != null) { activeUsername = PicasaApi.canonicalizeUsername(mActiveAccount.name); } boolean didSyncActiveUserName = false; for (int i = 0, numUsers = users.length; i != numUsers; ++i) { if (activeUsername != null && !context.accounts[i].user.equals(activeUsername)) continue; if (!ContentResolver.getSyncAutomatically(context.accounts[i].account, AUTHORITY)) continue; didSyncActiveUserName = true; context.api.setAuth(context.accounts[i]); syncUserAlbums(context, users[i], syncResult); if (syncAlbumPhotos) { syncUserPhotos(context, users[i].account, syncResult); } else { // // Always sync added albums. // for (Long albumId : context.albumsAdded) { // syncAlbumPhotos(albumId, false); // } } } if (!didSyncActiveUserName) { ++syncResult.stats.numAuthExceptions; } context.finish(); } public void syncAlbumPhotos(final long albumId, final boolean forceRefresh, SyncResult syncResult) { SyncContext context = mSyncContext; AlbumEntry album = new AlbumEntry(); if (AlbumEntry.SCHEMA.queryWithId(context.db, albumId, album)) { if ((album.photosDirty || forceRefresh) && context.login(album.syncAccount)) { if (isSyncEnabled(album.syncAccount, context)) { syncAlbumPhotos(context, album.syncAccount, album, syncResult); } } } context.finish(); } public static boolean isSyncEnabled(String accountName, SyncContext context) { if (context.accounts == null) { context.reloadAccounts(); } PicasaApi.AuthAccount[] accounts = context.accounts; int numAccounts = accounts.length; for (int i = 0; i < numAccounts; ++i) { PicasaApi.AuthAccount account = accounts[i]; if (account.user.equals(accountName)) { return ContentResolver.getSyncAutomatically(account.account, AUTHORITY); } } return true; } private UserEntry[] syncUsers(SyncContext context, SyncResult syncResult) { // Get authorized accounts. context.reloadAccounts(); PicasaApi.AuthAccount[] accounts = context.accounts; int numUsers = accounts.length; UserEntry[] users = new UserEntry[numUsers]; // Scan existing accounts. EntrySchema schema = UserEntry.SCHEMA; SQLiteDatabase db = context.db; Cursor cursor = schema.queryAll(db); if (cursor.moveToFirst()) { do { // Read the current account. UserEntry entry = new UserEntry(); schema.cursorToObject(cursor, entry); // Find the corresponding account, or delete the row if it does // not exist. int i; for (i = 0; i != numUsers; ++i) { if (accounts[i].user.equals(entry.account)) { users[i] = entry; break; } } if (i == numUsers) { Log.e(TAG, "Deleting user " + entry.account); entry.albumsEtag = null; deleteUser(db, entry.account); } } while (cursor.moveToNext()); } else { // Log.i(TAG, "No users in database yet"); } cursor.close(); // Add new accounts and synchronize user albums if recursive. for (int i = 0; i != numUsers; ++i) { UserEntry entry = users[i]; PicasaApi.AuthAccount account = accounts[i]; if (entry == null) { entry = new UserEntry(); entry.account = account.user; users[i] = entry; Log.e(TAG, "Inserting user " + entry.account); } } return users; } private void syncUserAlbums(final SyncContext context, final UserEntry user, final SyncResult syncResult) { // Query existing album entry (id, dateEdited) sorted by ID. final SQLiteDatabase db = context.db; Cursor cursor = db.query(AlbumEntry.SCHEMA.getTableName(), ID_EDITED_PROJECTION, WHERE_ACCOUNT, new String[] { user.account }, null, null, AlbumEntry.Columns.DATE_EDITED); int localCount = cursor.getCount(); // Build a sorted index with existing entry timestamps. final EntryMetadata local[] = new EntryMetadata[localCount]; for (int i = 0; i != localCount; ++i) { cursor.moveToPosition(i); // TODO: throw exception here if returns // false? local[i] = new EntryMetadata(cursor.getLong(0), cursor.getLong(1), 0); } cursor.close(); Arrays.sort(local); // Merge the truth from the API into the local database. final EntrySchema albumSchema = AlbumEntry.SCHEMA; final EntryMetadata key = new EntryMetadata(); final AccountManager accountManager = AccountManager.get(getContext()); int result = context.api.getAlbums(accountManager, syncResult, user, new GDataParser.EntryHandler() { public void handleEntry(Entry entry) { AlbumEntry album = (AlbumEntry) entry; long albumId = album.id; key.id = albumId; int index = Arrays.binarySearch(local, key); EntryMetadata metadata = index >= 0 ? local[index] : null; if (metadata == null || metadata.dateEdited < album.dateEdited) { // Insert / update. Log.i(TAG, "insert / update album " + album.title); album.syncAccount = user.account; album.photosDirty = true; albumSchema.insertOrReplace(db, album); if (metadata == null) { context.albumsAdded.add(albumId); } ++syncResult.stats.numUpdates; } else { // Up-to-date. // Log.i(TAG, "up-to-date album " + album.title); } // Mark item as surviving so it is not deleted. if (metadata != null) { metadata.survived = true; } } }); // Return if not modified or on error. switch (result) { case PicasaApi.RESULT_ERROR: ++syncResult.stats.numParseExceptions; case PicasaApi.RESULT_NOT_MODIFIED: return; } // Update the user entry with the new ETag. UserEntry.SCHEMA.insertOrReplace(db, user); // Delete all entries not present in the API response. for (int i = 0; i != localCount; ++i) { EntryMetadata metadata = local[i]; if (!metadata.survived) { deleteAlbum(db, metadata.id); ++syncResult.stats.numDeletes; Log.i(TAG, "delete album " + metadata.id); } } // Note that albums changed. context.albumsChanged = true; } private void syncUserPhotos(SyncContext context, String account, SyncResult syncResult) { // Synchronize albums with out-of-date photos. SQLiteDatabase db = context.db; Cursor cursor = db.query(AlbumEntry.SCHEMA.getTableName(), Entry.ID_PROJECTION, "sync_account=? AND photos_dirty=1", new String[] { account }, null, null, null); AlbumEntry album = new AlbumEntry(); for (int i = 0, count = cursor.getCount(); i != count; ++i) { cursor.moveToPosition(i); if (AlbumEntry.SCHEMA.queryWithId(db, cursor.getLong(0), album)) { syncAlbumPhotos(context, account, album, syncResult); } // Abort if interrupted. if (Thread.interrupted()) { ++syncResult.stats.numIoExceptions; Log.e(TAG, "syncUserPhotos interrupted"); } } cursor.close(); } private void syncAlbumPhotos(SyncContext context, final String account, AlbumEntry album, final SyncResult syncResult) { Log.i(TAG, "Syncing Picasa album: " + album.title); // Query existing album entry (id, dateEdited) sorted by ID. final SQLiteDatabase db = context.db; long albumId = album.id; String[] albumIdArgs = { Long.toString(albumId) }; Cursor cursor = db.query(PhotoEntry.SCHEMA.getTableName(), ID_EDITED_INDEX_PROJECTION, WHERE_ALBUM_ID, albumIdArgs, null, null, "date_edited"); int localCount = cursor.getCount(); // Build a sorted index with existing entry timestamps and display // indexes. final EntryMetadata local[] = new EntryMetadata[localCount]; final EntryMetadata key = new EntryMetadata(); for (int i = 0; i != localCount; ++i) { cursor.moveToPosition(i); // TODO: throw exception here if returns // false? local[i] = new EntryMetadata(cursor.getLong(0), cursor.getLong(1), cursor.getInt(2)); } cursor.close(); Arrays.sort(local); // Merge the truth from the API into the local database. final EntrySchema photoSchema = PhotoEntry.SCHEMA; final int[] displayIndex = { 0 }; final AccountManager accountManager = AccountManager.get(getContext()); int result = context.api.getAlbumPhotos(accountManager, syncResult, album, new GDataParser.EntryHandler() { public void handleEntry(Entry entry) { PhotoEntry photo = (PhotoEntry) entry; long photoId = photo.id; int newDisplayIndex = displayIndex[0]; key.id = photoId; int index = Arrays.binarySearch(local, key); EntryMetadata metadata = index >= 0 ? local[index] : null; if (metadata == null || metadata.dateEdited < photo.dateEdited || metadata.displayIndex != newDisplayIndex) { // Insert / update. // Log.i(TAG, "insert / update photo " + photo.title); photo.syncAccount = account; photo.displayIndex = newDisplayIndex; photoSchema.insertOrReplace(db, photo); ++syncResult.stats.numUpdates; } else { // Up-to-date. // Log.i(TAG, "up-to-date photo " + photo.title); } // Mark item as surviving so it is not deleted. if (metadata != null) { metadata.survived = true; } // Increment the display index. displayIndex[0] = newDisplayIndex + 1; } }); // Return if not modified or on error. switch (result) { case PicasaApi.RESULT_ERROR: ++syncResult.stats.numParseExceptions; Log.e(TAG, "syncAlbumPhotos error"); case PicasaApi.RESULT_NOT_MODIFIED: // Log.e(TAG, "result not modified"); return; } // Delete all entries not present in the API response. for (int i = 0; i != localCount; ++i) { EntryMetadata metadata = local[i]; if (!metadata.survived) { deletePhoto(db, metadata.id); ++syncResult.stats.numDeletes; // Log.i(TAG, "delete photo " + metadata.id); } } // Mark album as no longer dirty and store the new ETag. album.photosDirty = false; AlbumEntry.SCHEMA.insertOrReplace(db, album); // Log.i(TAG, "Clearing dirty bit on album " + albumId); // Mark that photos changed. // context.photosChanged = true; getContext().getContentResolver().notifyChange(ALBUMS_URI, null, false); getContext().getContentResolver().notifyChange(PHOTOS_URI, null, false); } private void deleteUser(SQLiteDatabase db, String account) { Log.w(TAG, "deleteUser(" + account + ")"); // Select albums owned by the user. String albumTableName = AlbumEntry.SCHEMA.getTableName(); String[] whereArgs = { account }; Cursor cursor = db.query(AlbumEntry.SCHEMA.getTableName(), Entry.ID_PROJECTION, WHERE_ACCOUNT, whereArgs, null, null, null); // Delete contained photos for each album. if (cursor.moveToFirst()) { do { deleteAlbumPhotos(db, cursor.getLong(0)); } while (cursor.moveToNext()); } cursor.close(); // Delete all albums. db.delete(albumTableName, WHERE_ACCOUNT, whereArgs); // Delete the user entry. db.delete(UserEntry.SCHEMA.getTableName(), "account=?", whereArgs); } private void deleteAlbum(SQLiteDatabase db, long albumId) { // Delete contained photos. deleteAlbumPhotos(db, albumId); // Delete the album. AlbumEntry.SCHEMA.deleteWithId(db, albumId); } private void deleteAlbumPhotos(SQLiteDatabase db, long albumId) { Log.v(TAG, "deleteAlbumPhotos(" + albumId + ")"); String photoTableName = PhotoEntry.SCHEMA.getTableName(); String[] whereArgs = { Long.toString(albumId) }; Cursor cursor = db.query(photoTableName, Entry.ID_PROJECTION, WHERE_ALBUM_ID, whereArgs, null, null, null); // Delete cache entry for each photo. if (cursor.moveToFirst()) { do { deletePhotoCache(cursor.getLong(0)); } while (cursor.moveToNext()); } cursor.close(); // Delete all photos. db.delete(photoTableName, WHERE_ALBUM_ID, whereArgs); } private void deletePhoto(SQLiteDatabase db, long photoId) { PhotoEntry.SCHEMA.deleteWithId(db, photoId); deletePhotoCache(photoId); } private void deletePhotoCache(long photoId) { // TODO: implement it. } private final class SyncContext { // List of all authenticated user accounts. public PicasaApi.AuthAccount[] accounts; // A connection to the Picasa API for a specific user account. Initially // null. public PicasaApi api = new PicasaApi(getContext().getContentResolver()); // A handle to the Picasa databse. public SQLiteDatabase db; // List of album IDs that were added during the sync. public final ArrayList<Long> albumsAdded = new ArrayList<Long>(); // Set to true if albums were changed. public boolean albumsChanged = false; // Set to true if photos were changed. public boolean photosChanged = false; public SyncContext() { db = mDatabase.getWritableDatabase(); } public void reloadAccounts() { accounts = PicasaApi.getAuthenticatedAccounts(getContext()); } public void finish() { // Send notifications if needed and reset state. ContentResolver cr = getContext().getContentResolver(); if (albumsChanged) { cr.notifyChange(ALBUMS_URI, null, false); } if (photosChanged) { cr.notifyChange(PHOTOS_URI, null, false); } albumsChanged = false; photosChanged = false; } public boolean login(String user) { if (accounts == null) { reloadAccounts(); } final PicasaApi.AuthAccount[] authAccounts = accounts; for (PicasaApi.AuthAccount auth : authAccounts) { if (auth.user.equals(user)) { api.setAuth(auth); return true; } } return false; } } /** * Minimal metadata gathered during sync. */ private static final class EntryMetadata implements Comparable<EntryMetadata> { public long id; public long dateEdited; public int displayIndex; public boolean survived = false; public EntryMetadata() { } public EntryMetadata(long id, long dateEdited, int displayIndex) { this.id = id; this.dateEdited = dateEdited; this.displayIndex = displayIndex; } public int compareTo(EntryMetadata other) { return Long.signum(id - other.id); } } }