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