Home | History | Annotate | Download | only in picasa
      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 
     17 package com.cooliris.picasa;
     18 
     19 import java.util.ArrayList;
     20 import java.util.Arrays;
     21 import java.util.List;
     22 
     23 import android.accounts.Account;
     24 import android.accounts.AccountManager;
     25 import android.content.ContentResolver;
     26 import android.content.Context;
     27 import android.content.SyncResult;
     28 import android.content.pm.ProviderInfo;
     29 import android.database.Cursor;
     30 import android.database.sqlite.SQLiteDatabase;
     31 import android.database.sqlite.SQLiteOpenHelper;
     32 import android.database.sqlite.SQLiteDatabase.CursorFactory;
     33 import android.net.Uri;
     34 import android.util.Log;
     35 
     36 public final class PicasaContentProvider extends TableContentProvider {
     37     public static final String AUTHORITY = "com.cooliris.picasa.contentprovider";
     38     public static final Uri BASE_URI = Uri.parse("content://" + AUTHORITY);
     39     public static final Uri PHOTOS_URI = Uri.withAppendedPath(BASE_URI, "photos");
     40     public static final Uri ALBUMS_URI = Uri.withAppendedPath(BASE_URI, "albums");
     41 
     42     private static final String TAG = "PicasaContentProvider";
     43     private static final String[] ID_EDITED_PROJECTION = { "_id", "date_edited" };
     44     private static final String[] ID_EDITED_INDEX_PROJECTION = { "_id", "date_edited", "display_index" };
     45     private static final String WHERE_ACCOUNT = "sync_account=?";
     46     private static final String WHERE_ALBUM_ID = "album_id=?";
     47 
     48     private final PhotoEntry mPhotoInstance = new PhotoEntry();
     49     private final AlbumEntry mAlbumInstance = new AlbumEntry();
     50     private SyncContext mSyncContext = null;
     51     private Account mActiveAccount;
     52 
     53     @Override
     54     public void attachInfo(Context context, ProviderInfo info) {
     55         // Initialize the provider and set the database.
     56         super.attachInfo(context, info);
     57         setDatabase(new Database(context, Database.DATABASE_NAME));
     58 
     59         // Add mappings for each of the exposed tables.
     60         addMapping(AUTHORITY, "photos", "vnd.cooliris.picasa.photo", PhotoEntry.SCHEMA);
     61         addMapping(AUTHORITY, "albums", "vnd.cooliris.picasa.album", AlbumEntry.SCHEMA);
     62 
     63         // Create the sync context.
     64         try {
     65             mSyncContext = new SyncContext();
     66         } catch (Exception e) {
     67             // The database wasn't created successfully, we create a memory backed database.
     68             setDatabase(new Database(context, null));
     69         }
     70     }
     71 
     72     public static final class Database extends SQLiteOpenHelper {
     73         public static final String DATABASE_NAME = "picasa.db";
     74         public static final int DATABASE_VERSION = 83;
     75 
     76         public Database(Context context, String name) {
     77             super(context, name, null, DATABASE_VERSION);
     78         }
     79 
     80         @Override
     81         public void onCreate(SQLiteDatabase db) {
     82             PhotoEntry.SCHEMA.createTables(db);
     83             AlbumEntry.SCHEMA.createTables(db);
     84             UserEntry.SCHEMA.createTables(db);
     85         }
     86 
     87         @Override
     88         public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
     89             // No new versions yet, if we are asked to upgrade we just reset
     90             // everything.
     91             PhotoEntry.SCHEMA.dropTables(db);
     92             AlbumEntry.SCHEMA.dropTables(db);
     93             UserEntry.SCHEMA.dropTables(db);
     94             onCreate(db);
     95         }
     96     }
     97 
     98     @Override
     99     public int delete(Uri uri, String selection, String[] selectionArgs) {
    100         // Ensure that the URI is well-formed. We currently do not allow WHERE
    101         // clauses.
    102         List<String> path = uri.getPathSegments();
    103         if (path.size() != 2 || !uri.getAuthority().equals(AUTHORITY) || selection != null) {
    104             return 0;
    105         }
    106 
    107         // Get the sync context.
    108         SyncContext context = mSyncContext;
    109 
    110         // Determine if the URI refers to an album or photo.
    111         String type = path.get(0);
    112         long id = Long.parseLong(path.get(1));
    113         SQLiteDatabase db = context.db;
    114         if (type.equals("photos")) {
    115             // Retrieve the photo from the database to get the edit URI.
    116             PhotoEntry photo = mPhotoInstance;
    117             if (PhotoEntry.SCHEMA.queryWithId(db, id, photo)) {
    118                 // Send a DELETE request to the API.
    119                 if (context.login(photo.syncAccount)) {
    120                     if (context.api.deleteEntry(photo.editUri) == PicasaApi.RESULT_OK) {
    121                         deletePhoto(db, id);
    122                         context.photosChanged = true;
    123                         return 1;
    124                     }
    125                 }
    126             }
    127         } else if (type.equals("albums")) {
    128             // Retrieve the album from the database to get the edit URI.
    129             AlbumEntry album = mAlbumInstance;
    130             if (AlbumEntry.SCHEMA.queryWithId(db, id, album)) {
    131                 // Send a DELETE request to the API.
    132                 if (context.login(album.syncAccount)) {
    133                     if (context.api.deleteEntry(album.editUri) == PicasaApi.RESULT_OK) {
    134                         deleteAlbum(db, id);
    135                         context.albumsChanged = true;
    136                         return 1;
    137                     }
    138                 }
    139             }
    140         }
    141         context.finish();
    142         return 0;
    143     }
    144 
    145     public void reloadAccounts() {
    146         mSyncContext.reloadAccounts();
    147     }
    148 
    149     public void setActiveSyncAccount(Account account) {
    150         mActiveAccount = account;
    151     }
    152 
    153     public void syncUsers(SyncResult syncResult) {
    154         syncUsers(mSyncContext, syncResult);
    155     }
    156 
    157     public void syncUsersAndAlbums(final boolean syncAlbumPhotos, SyncResult syncResult) {
    158         SyncContext context = mSyncContext;
    159 
    160         // Synchronize users authenticated on the device.
    161         UserEntry[] users = syncUsers(context, syncResult);
    162 
    163         // Synchronize albums for each user.
    164         String activeUsername = null;
    165         if (mActiveAccount != null) {
    166             activeUsername = PicasaApi.canonicalizeUsername(mActiveAccount.name);
    167         }
    168         boolean didSyncActiveUserName = false;
    169         for (int i = 0, numUsers = users.length; i != numUsers; ++i) {
    170             if (activeUsername != null && !context.accounts[i].user.equals(activeUsername))
    171                 continue;
    172             if (!ContentResolver.getSyncAutomatically(context.accounts[i].account, AUTHORITY))
    173                 continue;
    174             didSyncActiveUserName = true;
    175             context.api.setAuth(context.accounts[i]);
    176             syncUserAlbums(context, users[i], syncResult);
    177             if (syncAlbumPhotos) {
    178                 syncUserPhotos(context, users[i].account, syncResult);
    179             } else {
    180                 // // Always sync added albums.
    181                 // for (Long albumId : context.albumsAdded) {
    182                 // syncAlbumPhotos(albumId, false);
    183                 // }
    184             }
    185         }
    186         if (!didSyncActiveUserName) {
    187             ++syncResult.stats.numAuthExceptions;
    188         }
    189         context.finish();
    190     }
    191 
    192     public void syncAlbumPhotos(final long albumId, final boolean forceRefresh, SyncResult syncResult) {
    193         SyncContext context = mSyncContext;
    194         AlbumEntry album = new AlbumEntry();
    195         if (AlbumEntry.SCHEMA.queryWithId(context.db, albumId, album)) {
    196             if ((album.photosDirty || forceRefresh) && context.login(album.syncAccount)) {
    197                 if (isSyncEnabled(album.syncAccount, context)) {
    198                     syncAlbumPhotos(context, album.syncAccount, album, syncResult);
    199                 }
    200             }
    201         }
    202         context.finish();
    203     }
    204 
    205     public static boolean isSyncEnabled(String accountName, SyncContext context) {
    206         if (context.accounts == null) {
    207             context.reloadAccounts();
    208         }
    209         PicasaApi.AuthAccount[] accounts = context.accounts;
    210         int numAccounts = accounts.length;
    211         for (int i = 0; i < numAccounts; ++i) {
    212             PicasaApi.AuthAccount account = accounts[i];
    213             if (account.user.equals(accountName)) {
    214                 return ContentResolver.getSyncAutomatically(account.account, AUTHORITY);
    215             }
    216         }
    217         return true;
    218     }
    219 
    220     private UserEntry[] syncUsers(SyncContext context, SyncResult syncResult) {
    221         // Get authorized accounts.
    222         context.reloadAccounts();
    223         PicasaApi.AuthAccount[] accounts = context.accounts;
    224         int numUsers = accounts.length;
    225         UserEntry[] users = new UserEntry[numUsers];
    226 
    227         // Scan existing accounts.
    228         EntrySchema schema = UserEntry.SCHEMA;
    229         SQLiteDatabase db = context.db;
    230         Cursor cursor = schema.queryAll(db);
    231         if (cursor.moveToFirst()) {
    232             do {
    233                 // Read the current account.
    234                 UserEntry entry = new UserEntry();
    235                 schema.cursorToObject(cursor, entry);
    236 
    237                 // Find the corresponding account, or delete the row if it does
    238                 // not exist.
    239                 int i;
    240                 for (i = 0; i != numUsers; ++i) {
    241                     if (accounts[i].user.equals(entry.account)) {
    242                         users[i] = entry;
    243                         break;
    244                     }
    245                 }
    246                 if (i == numUsers) {
    247                     Log.e(TAG, "Deleting user " + entry.account);
    248                     entry.albumsEtag = null;
    249                     deleteUser(db, entry.account);
    250                 }
    251             } while (cursor.moveToNext());
    252         } else {
    253             // Log.i(TAG, "No users in database yet");
    254         }
    255         cursor.close();
    256 
    257         // Add new accounts and synchronize user albums if recursive.
    258         for (int i = 0; i != numUsers; ++i) {
    259             UserEntry entry = users[i];
    260             PicasaApi.AuthAccount account = accounts[i];
    261             if (entry == null) {
    262                 entry = new UserEntry();
    263                 entry.account = account.user;
    264                 users[i] = entry;
    265                 Log.e(TAG, "Inserting user " + entry.account);
    266             }
    267         }
    268         return users;
    269     }
    270 
    271     private void syncUserAlbums(final SyncContext context, final UserEntry user, final SyncResult syncResult) {
    272         // Query existing album entry (id, dateEdited) sorted by ID.
    273         final SQLiteDatabase db = context.db;
    274         Cursor cursor = db.query(AlbumEntry.SCHEMA.getTableName(), ID_EDITED_PROJECTION, WHERE_ACCOUNT,
    275                 new String[] { user.account }, null, null, AlbumEntry.Columns.DATE_EDITED);
    276         int localCount = cursor.getCount();
    277 
    278         // Build a sorted index with existing entry timestamps.
    279         final EntryMetadata local[] = new EntryMetadata[localCount];
    280         for (int i = 0; i != localCount; ++i) {
    281             cursor.moveToPosition(i); // TODO: throw exception here if returns
    282                                       // false?
    283             local[i] = new EntryMetadata(cursor.getLong(0), cursor.getLong(1), 0);
    284         }
    285         cursor.close();
    286         Arrays.sort(local);
    287 
    288         // Merge the truth from the API into the local database.
    289         final EntrySchema albumSchema = AlbumEntry.SCHEMA;
    290         final EntryMetadata key = new EntryMetadata();
    291         final AccountManager accountManager = AccountManager.get(getContext());
    292         int result = context.api.getAlbums(accountManager, syncResult, user, new GDataParser.EntryHandler() {
    293             public void handleEntry(Entry entry) {
    294                 AlbumEntry album = (AlbumEntry) entry;
    295                 long albumId = album.id;
    296                 key.id = albumId;
    297                 int index = Arrays.binarySearch(local, key);
    298                 EntryMetadata metadata = index >= 0 ? local[index] : null;
    299                 if (metadata == null || metadata.dateEdited < album.dateEdited) {
    300                     // Insert / update.
    301                     Log.i(TAG, "insert / update album " + album.title);
    302                     album.syncAccount = user.account;
    303                     album.photosDirty = true;
    304                     albumSchema.insertOrReplace(db, album);
    305                     if (metadata == null) {
    306                         context.albumsAdded.add(albumId);
    307                     }
    308                     ++syncResult.stats.numUpdates;
    309                 } else {
    310                     // Up-to-date.
    311                     // Log.i(TAG, "up-to-date album " + album.title);
    312                 }
    313 
    314                 // Mark item as surviving so it is not deleted.
    315                 if (metadata != null) {
    316                     metadata.survived = true;
    317                 }
    318             }
    319         });
    320 
    321         // Return if not modified or on error.
    322         switch (result) {
    323         case PicasaApi.RESULT_ERROR:
    324             ++syncResult.stats.numParseExceptions;
    325         case PicasaApi.RESULT_NOT_MODIFIED:
    326             return;
    327         }
    328 
    329         // Update the user entry with the new ETag.
    330         UserEntry.SCHEMA.insertOrReplace(db, user);
    331 
    332         // Delete all entries not present in the API response.
    333         for (int i = 0; i != localCount; ++i) {
    334             EntryMetadata metadata = local[i];
    335             if (!metadata.survived) {
    336                 deleteAlbum(db, metadata.id);
    337                 ++syncResult.stats.numDeletes;
    338                 Log.i(TAG, "delete album " + metadata.id);
    339             }
    340         }
    341 
    342         // Note that albums changed.
    343         context.albumsChanged = true;
    344     }
    345 
    346     private void syncUserPhotos(SyncContext context, String account, SyncResult syncResult) {
    347         // Synchronize albums with out-of-date photos.
    348         SQLiteDatabase db = context.db;
    349         Cursor cursor = db.query(AlbumEntry.SCHEMA.getTableName(), Entry.ID_PROJECTION, "sync_account=? AND photos_dirty=1",
    350                 new String[] { account }, null, null, null);
    351         AlbumEntry album = new AlbumEntry();
    352         for (int i = 0, count = cursor.getCount(); i != count; ++i) {
    353             cursor.moveToPosition(i);
    354             if (AlbumEntry.SCHEMA.queryWithId(db, cursor.getLong(0), album)) {
    355                 syncAlbumPhotos(context, account, album, syncResult);
    356             }
    357 
    358             // Abort if interrupted.
    359             if (Thread.interrupted()) {
    360                 ++syncResult.stats.numIoExceptions;
    361                 Log.e(TAG, "syncUserPhotos interrupted");
    362             }
    363         }
    364         cursor.close();
    365     }
    366 
    367     private void syncAlbumPhotos(SyncContext context, final String account, AlbumEntry album, final SyncResult syncResult) {
    368         Log.i(TAG, "Syncing Picasa album: " + album.title);
    369         // Query existing album entry (id, dateEdited) sorted by ID.
    370         final SQLiteDatabase db = context.db;
    371         long albumId = album.id;
    372         String[] albumIdArgs = { Long.toString(albumId) };
    373         Cursor cursor = db.query(PhotoEntry.SCHEMA.getTableName(), ID_EDITED_INDEX_PROJECTION, WHERE_ALBUM_ID, albumIdArgs, null,
    374                 null, "date_edited");
    375         int localCount = cursor.getCount();
    376 
    377         // Build a sorted index with existing entry timestamps and display
    378         // indexes.
    379         final EntryMetadata local[] = new EntryMetadata[localCount];
    380         final EntryMetadata key = new EntryMetadata();
    381         for (int i = 0; i != localCount; ++i) {
    382             cursor.moveToPosition(i); // TODO: throw exception here if returns
    383                                       // false?
    384             local[i] = new EntryMetadata(cursor.getLong(0), cursor.getLong(1), cursor.getInt(2));
    385         }
    386         cursor.close();
    387         Arrays.sort(local);
    388 
    389         // Merge the truth from the API into the local database.
    390         final EntrySchema photoSchema = PhotoEntry.SCHEMA;
    391         final int[] displayIndex = { 0 };
    392         final AccountManager accountManager = AccountManager.get(getContext());
    393         int result = context.api.getAlbumPhotos(accountManager, syncResult, album, new GDataParser.EntryHandler() {
    394             public void handleEntry(Entry entry) {
    395                 PhotoEntry photo = (PhotoEntry) entry;
    396                 long photoId = photo.id;
    397                 int newDisplayIndex = displayIndex[0];
    398                 key.id = photoId;
    399                 int index = Arrays.binarySearch(local, key);
    400                 EntryMetadata metadata = index >= 0 ? local[index] : null;
    401                 if (metadata == null || metadata.dateEdited < photo.dateEdited || metadata.displayIndex != newDisplayIndex) {
    402 
    403                     // Insert / update.
    404                     // Log.i(TAG, "insert / update photo " + photo.title);
    405                     photo.syncAccount = account;
    406                     photo.displayIndex = newDisplayIndex;
    407                     photoSchema.insertOrReplace(db, photo);
    408                     ++syncResult.stats.numUpdates;
    409                 } else {
    410                     // Up-to-date.
    411                     // Log.i(TAG, "up-to-date photo " + photo.title);
    412                 }
    413 
    414                 // Mark item as surviving so it is not deleted.
    415                 if (metadata != null) {
    416                     metadata.survived = true;
    417                 }
    418 
    419                 // Increment the display index.
    420                 displayIndex[0] = newDisplayIndex + 1;
    421             }
    422         });
    423 
    424         // Return if not modified or on error.
    425         switch (result) {
    426         case PicasaApi.RESULT_ERROR:
    427             ++syncResult.stats.numParseExceptions;
    428             Log.e(TAG, "syncAlbumPhotos error");
    429         case PicasaApi.RESULT_NOT_MODIFIED:
    430             // Log.e(TAG, "result not modified");
    431             return;
    432         }
    433 
    434         // Delete all entries not present in the API response.
    435         for (int i = 0; i != localCount; ++i) {
    436             EntryMetadata metadata = local[i];
    437             if (!metadata.survived) {
    438                 deletePhoto(db, metadata.id);
    439                 ++syncResult.stats.numDeletes;
    440                 // Log.i(TAG, "delete photo " + metadata.id);
    441             }
    442         }
    443 
    444         // Mark album as no longer dirty and store the new ETag.
    445         album.photosDirty = false;
    446         AlbumEntry.SCHEMA.insertOrReplace(db, album);
    447         // Log.i(TAG, "Clearing dirty bit on album " + albumId);
    448 
    449         // Mark that photos changed.
    450         // context.photosChanged = true;
    451         getContext().getContentResolver().notifyChange(ALBUMS_URI, null, false);
    452         getContext().getContentResolver().notifyChange(PHOTOS_URI, null, false);
    453     }
    454 
    455     private void deleteUser(SQLiteDatabase db, String account) {
    456         Log.w(TAG, "deleteUser(" + account + ")");
    457 
    458         // Select albums owned by the user.
    459         String albumTableName = AlbumEntry.SCHEMA.getTableName();
    460         String[] whereArgs = { account };
    461         Cursor cursor = db.query(AlbumEntry.SCHEMA.getTableName(), Entry.ID_PROJECTION, WHERE_ACCOUNT, whereArgs, null, null, null);
    462 
    463         // Delete contained photos for each album.
    464         if (cursor.moveToFirst()) {
    465             do {
    466                 deleteAlbumPhotos(db, cursor.getLong(0));
    467             } while (cursor.moveToNext());
    468         }
    469         cursor.close();
    470 
    471         // Delete all albums.
    472         db.delete(albumTableName, WHERE_ACCOUNT, whereArgs);
    473 
    474         // Delete the user entry.
    475         db.delete(UserEntry.SCHEMA.getTableName(), "account=?", whereArgs);
    476     }
    477 
    478     private void deleteAlbum(SQLiteDatabase db, long albumId) {
    479         // Delete contained photos.
    480         deleteAlbumPhotos(db, albumId);
    481 
    482         // Delete the album.
    483         AlbumEntry.SCHEMA.deleteWithId(db, albumId);
    484     }
    485 
    486     private void deleteAlbumPhotos(SQLiteDatabase db, long albumId) {
    487         Log.v(TAG, "deleteAlbumPhotos(" + albumId + ")");
    488         String photoTableName = PhotoEntry.SCHEMA.getTableName();
    489         String[] whereArgs = { Long.toString(albumId) };
    490         Cursor cursor = db.query(photoTableName, Entry.ID_PROJECTION, WHERE_ALBUM_ID, whereArgs, null, null, null);
    491 
    492         // Delete cache entry for each photo.
    493         if (cursor.moveToFirst()) {
    494             do {
    495                 deletePhotoCache(cursor.getLong(0));
    496             } while (cursor.moveToNext());
    497         }
    498         cursor.close();
    499 
    500         // Delete all photos.
    501         db.delete(photoTableName, WHERE_ALBUM_ID, whereArgs);
    502     }
    503 
    504     private void deletePhoto(SQLiteDatabase db, long photoId) {
    505         PhotoEntry.SCHEMA.deleteWithId(db, photoId);
    506         deletePhotoCache(photoId);
    507     }
    508 
    509     private void deletePhotoCache(long photoId) {
    510         // TODO: implement it.
    511     }
    512 
    513     private final class SyncContext {
    514         // List of all authenticated user accounts.
    515         public PicasaApi.AuthAccount[] accounts;
    516 
    517         // A connection to the Picasa API for a specific user account. Initially
    518         // null.
    519         public PicasaApi api = new PicasaApi();
    520 
    521         // A handle to the Picasa databse.
    522         public SQLiteDatabase db;
    523 
    524         // List of album IDs that were added during the sync.
    525         public final ArrayList<Long> albumsAdded = new ArrayList<Long>();
    526 
    527         // Set to true if albums were changed.
    528         public boolean albumsChanged = false;
    529 
    530         // Set to true if photos were changed.
    531         public boolean photosChanged = false;
    532 
    533         public SyncContext() {
    534             db = mDatabase.getWritableDatabase();
    535         }
    536 
    537         public void reloadAccounts() {
    538             accounts = PicasaApi.getAuthenticatedAccounts(getContext());
    539         }
    540 
    541         public void finish() {
    542             // Send notifications if needed and reset state.
    543             ContentResolver cr = getContext().getContentResolver();
    544             if (albumsChanged) {
    545                 cr.notifyChange(ALBUMS_URI, null, false);
    546             }
    547             if (photosChanged) {
    548                 cr.notifyChange(PHOTOS_URI, null, false);
    549             }
    550             albumsChanged = false;
    551             photosChanged = false;
    552         }
    553 
    554         public boolean login(String user) {
    555             if (accounts == null) {
    556                 reloadAccounts();
    557             }
    558             final PicasaApi.AuthAccount[] authAccounts = accounts;
    559             for (PicasaApi.AuthAccount auth : authAccounts) {
    560                 if (auth.user.equals(user)) {
    561                     api.setAuth(auth);
    562                     return true;
    563                 }
    564             }
    565             return false;
    566         }
    567     }
    568 
    569     /**
    570      * Minimal metadata gathered during sync.
    571      */
    572     private static final class EntryMetadata implements Comparable<EntryMetadata> {
    573         public long id;
    574         public long dateEdited;
    575         public int displayIndex;
    576         public boolean survived = false;
    577 
    578         public EntryMetadata() {
    579         }
    580 
    581         public EntryMetadata(long id, long dateEdited, int displayIndex) {
    582             this.id = id;
    583             this.dateEdited = dateEdited;
    584             this.displayIndex = displayIndex;
    585         }
    586 
    587         public int compareTo(EntryMetadata other) {
    588             return Long.signum(id - other.id);
    589         }
    590 
    591     }
    592 }
    593