1 package com.cooliris.picasa;
3 import java.util.ArrayList;
4 import java.util.Arrays;
7 import android.accounts.Account;
8 import android.accounts.AccountManager;
9 import android.content.ContentResolver;
10 import android.content.Context;
11 import android.content.SyncResult;
12 import android.content.pm.ProviderInfo;
13 import android.database.Cursor;
14 import android.database.sqlite.SQLiteDatabase;
15 import android.database.sqlite.SQLiteOpenHelper;
16 import android.net.Uri;
17 import android.util.Log;
19 public final class PicasaContentProvider extends TableContentProvider {
20 public static final String AUTHORITY = "com.cooliris.picasa.contentprovider";
21 public static final Uri BASE_URI = Uri.parse("content://" + AUTHORITY);
22 public static final Uri PHOTOS_URI = Uri.withAppendedPath(BASE_URI, "photos");
23 public static final Uri ALBUMS_URI = Uri.withAppendedPath(BASE_URI, "albums");
25 private static final String TAG = "PicasaContentProvider";
26 private static final String[] ID_EDITED_PROJECTION = { "_id", "date_edited" };
27 private static final String[] ID_EDITED_INDEX_PROJECTION = { "_id", "date_edited", "display_index" };
28 private static final String WHERE_ACCOUNT = "sync_account=?";
29 private static final String WHERE_ALBUM_ID = "album_id=?";
31 private final PhotoEntry mPhotoInstance = new PhotoEntry();
32 private final AlbumEntry mAlbumInstance = new AlbumEntry();
33 private SyncContext mSyncContext = null;
34 private Account mActiveAccount;
37 public void attachInfo(Context context, ProviderInfo info) {
38 // Initialize the provider and set the database.
39 super.attachInfo(context, info);
40 setDatabase(new Database(context));
42 // Add mappings for each of the exposed tables.
43 addMapping(AUTHORITY, "photos", "vnd.cooliris.picasa.photo", PhotoEntry.SCHEMA);
44 addMapping(AUTHORITY, "albums", "vnd.cooliris.picasa.album", AlbumEntry.SCHEMA);
46 // Create the sync context.
47 mSyncContext = new SyncContext();
50 public static final class Database extends SQLiteOpenHelper {
51 public static final String DATABASE_NAME = "picasa.db";
52 public static final int DATABASE_VERSION = 83;
53 public Database(Context context) {
54 super(context, DATABASE_NAME, null, DATABASE_VERSION);
58 public void onCreate(SQLiteDatabase db) {
59 PhotoEntry.SCHEMA.createTables(db);
60 AlbumEntry.SCHEMA.createTables(db);
61 UserEntry.SCHEMA.createTables(db);
65 public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
66 // No new versions yet, if we are asked to upgrade we just reset everything.
67 PhotoEntry.SCHEMA.dropTables(db);
68 AlbumEntry.SCHEMA.dropTables(db);
69 UserEntry.SCHEMA.dropTables(db);
75 public int delete(Uri uri, String selection, String[] selectionArgs) {
76 // Ensure that the URI is well-formed. We currently do not allow WHERE clauses.
77 List<String> path = uri.getPathSegments();
78 if (path.size() != 2 || !uri.getAuthority().equals(AUTHORITY) || selection != null) {
82 // Get the sync context.
83 SyncContext context = mSyncContext;
85 // Determine if the URI refers to an album or photo.
86 String type = path.get(0);
87 long id = Long.parseLong(path.get(1));
88 SQLiteDatabase db = context.db;
89 if (type.equals("photos")) {
90 // Retrieve the photo from the database to get the edit URI.
91 PhotoEntry photo = mPhotoInstance;
92 if (PhotoEntry.SCHEMA.queryWithId(db, id, photo)) {
93 // Send a DELETE request to the API.
94 if (context.login(photo.syncAccount)) {
95 if (context.api.deleteEntry(photo.editUri) == PicasaApi.RESULT_OK) {
97 context.photosChanged = true;
102 } else if (type.equals("albums")) {
103 // Retrieve the album from the database to get the edit URI.
104 AlbumEntry album = mAlbumInstance;
105 if (AlbumEntry.SCHEMA.queryWithId(db, id, album)) {
106 // Send a DELETE request to the API.
107 if (context.login(album.syncAccount)) {
108 if (context.api.deleteEntry(album.editUri) == PicasaApi.RESULT_OK) {
110 context.albumsChanged = true;
120 public void reloadAccounts() {
121 mSyncContext.reloadAccounts();
124 public void setActiveSyncAccount(Account account) {
125 mActiveAccount = account;
128 public void syncUsers(SyncResult syncResult) {
129 syncUsers(mSyncContext, syncResult);
132 public void syncUsersAndAlbums(final boolean syncAlbumPhotos, SyncResult syncResult) {
133 SyncContext context = mSyncContext;
135 // Synchronize users authenticated on the device.
136 UserEntry[] users = syncUsers(context, syncResult);
138 // Synchronize albums for each user.
139 String activeUsername = null;
140 if (mActiveAccount != null) {
141 String username = mActiveAccount.name;
142 if (username.contains("@gmail.")) {
143 username = username.substring(0, username.indexOf('@'));
145 activeUsername = username;
147 boolean didSyncActiveUserName = false;
148 for (int i = 0, numUsers = users.length; i != numUsers; ++i) {
149 if (activeUsername != null && !context.accounts[i].user.equals(activeUsername))
151 if (!ContentResolver.getSyncAutomatically(context.accounts[i].account, AUTHORITY)) continue;
152 didSyncActiveUserName = true;
153 context.api.setAuth(context.accounts[i]);
154 syncUserAlbums(context, users[i], syncResult);
155 if (syncAlbumPhotos) {
156 syncUserPhotos(context, users[i].account, syncResult);
158 // // Always sync added albums.
159 // for (Long albumId : context.albumsAdded) {
160 // syncAlbumPhotos(albumId, false);
164 if (!didSyncActiveUserName) {
165 ++syncResult.stats.numAuthExceptions;
170 public void syncAlbumPhotos(final long albumId, final boolean forceRefresh, SyncResult syncResult) {
171 SyncContext context = mSyncContext;
172 AlbumEntry album = new AlbumEntry();
173 if (AlbumEntry.SCHEMA.queryWithId(context.db, albumId, album)) {
174 if ((album.photosDirty || forceRefresh) && context.login(album.syncAccount)) {
175 if (isSyncEnabled(album.syncAccount, context)) {
176 syncAlbumPhotos(context, album.syncAccount, album, syncResult);
183 public static boolean isSyncEnabled(String accountName, SyncContext context) {
184 PicasaApi.AuthAccount[] accounts = context.accounts;
185 int numAccounts = accounts.length;
186 for (int i = 0; i < numAccounts; ++i) {
187 PicasaApi.AuthAccount account = accounts[i];
188 if (account.user.equals(accountName)) {
189 return ContentResolver.getSyncAutomatically(account.account, AUTHORITY);
195 private UserEntry[] syncUsers(SyncContext context, SyncResult syncResult) {
196 Log.i(TAG, "syncUsers");
198 // Get authorized accounts.
199 context.reloadAccounts();
200 PicasaApi.AuthAccount[] accounts = context.accounts;
201 int numUsers = accounts.length;
202 UserEntry[] users = new UserEntry[numUsers];
204 // Scan existing accounts.
205 EntrySchema schema = UserEntry.SCHEMA;
206 SQLiteDatabase db = context.db;
207 Cursor cursor = schema.queryAll(db);
208 Log.i(TAG, "#users: " + cursor.getCount());
209 if (cursor.moveToFirst()) {
211 // Read the current account.
212 UserEntry entry = new UserEntry();
213 schema.cursorToObject(cursor, entry);
215 // Find the corresponding account, or delete the row if it does not exist.
217 for (i = 0; i != numUsers; ++i) {
218 Log.i(TAG, "Check " + accounts[i].user + " == " + entry.account);
219 if (accounts[i].user.equals(entry.account)) {
221 Log.e(TAG, "Updating user " + entry.account);
226 Log.e(TAG, "Deleting user " + entry.account);
227 entry.albumsEtag = null;
228 deleteUser(db, entry.account);
230 } while (cursor.moveToNext());
232 // Log.i(TAG, "No users in database yet");
236 // Add new accounts and synchronize user albums if recursive.
237 for (int i = 0; i != numUsers; ++i) {
238 UserEntry entry = users[i];
239 PicasaApi.AuthAccount account = accounts[i];
241 entry = new UserEntry();
242 entry.account = account.user;
243 Log.d(TAG, "insert/replace: " + schema.insertOrReplace(db, entry));
245 Log.e(TAG, "Inserting user " + entry.account);
251 private void syncUserAlbums(final SyncContext context, final UserEntry user, final SyncResult syncResult) {
252 // Query existing album entry (id, dateEdited) sorted by ID.
253 final SQLiteDatabase db = context.db;
254 Cursor cursor = db.query(AlbumEntry.SCHEMA.getTableName(), ID_EDITED_PROJECTION, WHERE_ACCOUNT,
255 new String[] { user.account }, null, null, AlbumEntry.Columns.DATE_EDITED);
256 int localCount = cursor.getCount();
258 // Build a sorted index with existing entry timestamps.
259 final EntryMetadata local[] = new EntryMetadata[localCount];
260 for (int i = 0; i != localCount; ++i) {
261 cursor.moveToPosition(i); // TODO: throw exception here if returns false?
262 local[i] = new EntryMetadata(cursor.getLong(0), cursor.getLong(1), 0);
267 // Merge the truth from the API into the local database.
268 final EntrySchema albumSchema = AlbumEntry.SCHEMA;
269 final EntryMetadata key = new EntryMetadata();
270 final AccountManager accountManager = AccountManager.get(getContext());
271 int result = context.api.getAlbums(accountManager, syncResult, user, new GDataParser.EntryHandler() {
272 public void handleEntry(Entry entry) {
273 AlbumEntry album = (AlbumEntry) entry;
274 long albumId = album.id;
276 int index = Arrays.binarySearch(local, key);
277 EntryMetadata metadata = index >= 0 ? local[index] : null;
278 if (metadata == null || metadata.dateEdited < album.dateEdited) {
280 Log.i(TAG, "insert / update album " + album.title);
281 album.syncAccount = user.account;
282 album.photosDirty = true;
283 albumSchema.insertOrReplace(db, album);
284 if (metadata == null) {
285 context.albumsAdded.add(albumId);
287 ++syncResult.stats.numUpdates;
290 // Log.i(TAG, "up-to-date album " + album.title);
293 // Mark item as surviving so it is not deleted.
294 if (metadata != null) {
295 metadata.survived = true;
300 // Return if not modified or on error.
302 case PicasaApi.RESULT_ERROR:
303 ++syncResult.stats.numParseExceptions;
304 case PicasaApi.RESULT_NOT_MODIFIED:
308 // Update the user entry with the new ETag.
309 UserEntry.SCHEMA.insertOrReplace(db, user);
311 // Delete all entries not present in the API response.
312 for (int i = 0; i != localCount; ++i) {
313 EntryMetadata metadata = local[i];
314 if (!metadata.survived) {
315 deleteAlbum(db, metadata.id);
316 ++syncResult.stats.numDeletes;
317 Log.i(TAG, "delete album " + metadata.id);
321 // Note that albums changed.
322 context.albumsChanged = true;
325 private void syncUserPhotos(SyncContext context, String account, SyncResult syncResult) {
326 // Synchronize albums with out-of-date photos.
327 SQLiteDatabase db = context.db;
328 Cursor cursor = db.query(AlbumEntry.SCHEMA.getTableName(), Entry.ID_PROJECTION, "sync_account=? AND photos_dirty=1",
329 new String[] { account }, null, null, null);
330 AlbumEntry album = new AlbumEntry();
331 for (int i = 0, count = cursor.getCount(); i != count; ++i) {
332 cursor.moveToPosition(i);
333 if (AlbumEntry.SCHEMA.queryWithId(db, cursor.getLong(0), album)) {
334 syncAlbumPhotos(context, account, album, syncResult);
337 // Abort if interrupted.
338 if (Thread.interrupted()) {
339 ++syncResult.stats.numIoExceptions;
340 Log.e(TAG, "syncUserPhotos interrupted");
346 private void syncAlbumPhotos(SyncContext context, final String account, AlbumEntry album, final SyncResult syncResult) {
347 Log.i(TAG, "Syncing Picasa album: " + album.title);
349 // Query existing album entry (id, dateEdited) sorted by ID.
350 final SQLiteDatabase db = context.db;
351 long albumId = album.id;
352 String[] albumIdArgs = { Long.toString(albumId) };
353 Cursor cursor = db.query(PhotoEntry.SCHEMA.getTableName(), ID_EDITED_INDEX_PROJECTION, WHERE_ALBUM_ID, albumIdArgs, null,
354 null, "date_edited");
355 int localCount = cursor.getCount();
357 // Build a sorted index with existing entry timestamps and display indexes.
358 final EntryMetadata local[] = new EntryMetadata[localCount];
359 final EntryMetadata key = new EntryMetadata();
360 for (int i = 0; i != localCount; ++i) {
361 cursor.moveToPosition(i); // TODO: throw exception here if returns false?
362 local[i] = new EntryMetadata(cursor.getLong(0), cursor.getLong(1), cursor.getInt(2));
367 // Merge the truth from the API into the local database.
368 final EntrySchema photoSchema = PhotoEntry.SCHEMA;
369 final int[] displayIndex = { 0 };
370 final AccountManager accountManager = AccountManager.get(getContext());
371 int result = context.api.getAlbumPhotos(accountManager, syncResult, album, new GDataParser.EntryHandler() {
372 public void handleEntry(Entry entry) {
373 PhotoEntry photo = (PhotoEntry) entry;
374 long photoId = photo.id;
375 int newDisplayIndex = displayIndex[0];
377 int index = Arrays.binarySearch(local, key);
378 EntryMetadata metadata = index >= 0 ? local[index] : null;
379 if (metadata == null || metadata.dateEdited < photo.dateEdited || metadata.displayIndex != newDisplayIndex) {
382 // Log.i(TAG, "insert / update photo " + photo.title);
383 photo.syncAccount = account;
384 photo.displayIndex = newDisplayIndex;
385 photoSchema.insertOrReplace(db, photo);
386 ++syncResult.stats.numUpdates;
389 // Log.i(TAG, "up-to-date photo " + photo.title);
392 // Mark item as surviving so it is not deleted.
393 if (metadata != null) {
394 metadata.survived = true;
397 // Increment the display index.
398 displayIndex[0] = newDisplayIndex + 1;
402 // Return if not modified or on error.
404 case PicasaApi.RESULT_ERROR:
405 ++syncResult.stats.numParseExceptions;
406 Log.e(TAG, "syncAlbumPhotos error");
407 case PicasaApi.RESULT_NOT_MODIFIED:
408 // Log.e(TAG, "result not modified");
412 // Delete all entries not present in the API response.
413 for (int i = 0; i != localCount; ++i) {
414 EntryMetadata metadata = local[i];
415 if (!metadata.survived) {
416 deletePhoto(db, metadata.id);
417 ++syncResult.stats.numDeletes;
418 // Log.i(TAG, "delete photo " + metadata.id);
422 // Mark album as no longer dirty and store the new ETag.
423 album.photosDirty = false;
424 AlbumEntry.SCHEMA.insertOrReplace(db, album);
425 // Log.i(TAG, "Clearing dirty bit on album " + albumId);
427 // Mark that photos changed.
428 // context.photosChanged = true;
429 getContext().getContentResolver().notifyChange(ALBUMS_URI, null);
430 getContext().getContentResolver().notifyChange(PHOTOS_URI, null);
433 private void deleteUser(SQLiteDatabase db, String account) {
434 Log.w(TAG, "deleteUser(" + account + ")");
436 // Select albums owned by the user.
437 String albumTableName = AlbumEntry.SCHEMA.getTableName();
438 String[] whereArgs = { account };
439 Cursor cursor = db.query(AlbumEntry.SCHEMA.getTableName(), Entry.ID_PROJECTION, WHERE_ACCOUNT, whereArgs, null, null, null);
441 // Delete contained photos for each album.
442 if (cursor.moveToFirst()) {
444 deleteAlbumPhotos(db, cursor.getLong(0));
445 } while (cursor.moveToNext());
449 // Delete all albums.
450 db.delete(albumTableName, WHERE_ACCOUNT, whereArgs);
452 // Delete the user entry.
453 db.delete(UserEntry.SCHEMA.getTableName(), "account=?", whereArgs);
456 private void deleteAlbum(SQLiteDatabase db, long albumId) {
457 // Delete contained photos.
458 deleteAlbumPhotos(db, albumId);
461 AlbumEntry.SCHEMA.deleteWithId(db, albumId);
464 private void deleteAlbumPhotos(SQLiteDatabase db, long albumId) {
465 Log.w(TAG, "deleteAlbumPhotos(" + albumId + ")");
466 String photoTableName = PhotoEntry.SCHEMA.getTableName();
467 String[] whereArgs = { Long.toString(albumId) };
468 Cursor cursor = db.query(photoTableName, Entry.ID_PROJECTION, WHERE_ALBUM_ID, whereArgs, null, null, null);
470 // Delete cache entry for each photo.
471 if (cursor.moveToFirst()) {
473 deletePhotoCache(cursor.getLong(0));
474 } while (cursor.moveToNext());
478 // Delete all photos.
479 db.delete(photoTableName, WHERE_ALBUM_ID, whereArgs);
482 private void deletePhoto(SQLiteDatabase db, long photoId) {
483 PhotoEntry.SCHEMA.deleteWithId(db, photoId);
484 deletePhotoCache(photoId);
487 private void deletePhotoCache(long photoId) {
489 Log.w(TAG, "deletePhotoCache(" + photoId + ")");
492 private final class SyncContext {
493 // List of all authenticated user accounts.
494 public PicasaApi.AuthAccount[] accounts;
496 // A connection to the Picasa API for a specific user account. Initially null.
497 public PicasaApi api = new PicasaApi();
499 // A handle to the Picasa databse.
500 public SQLiteDatabase db;
502 // List of album IDs that were added during the sync.
503 public final ArrayList<Long> albumsAdded = new ArrayList<Long>();
505 // Set to true if albums were changed.
506 public boolean albumsChanged = false;
508 // Set to true if photos were changed.
509 public boolean photosChanged = false;
511 public SyncContext() {
512 db = mDatabase.getWritableDatabase();
516 public void reloadAccounts() {
517 accounts = PicasaApi.getAuthenticatedAccounts(getContext());
520 public void finish() {
521 // Send notifications if needed and reset state.
522 ContentResolver cr = getContext().getContentResolver();
524 cr.notifyChange(ALBUMS_URI, null);
527 cr.notifyChange(PHOTOS_URI, null);
529 albumsChanged = false;
530 photosChanged = false;
533 public boolean login(String user) {
534 for (PicasaApi.AuthAccount auth : accounts) {
535 if (auth.user.equals(user)) {
545 * Minimal metadata gathered during sync.
547 private static final class EntryMetadata implements Comparable<EntryMetadata> {
549 public long dateEdited;
550 public int displayIndex;
551 public boolean survived = false;
553 public EntryMetadata() {
556 public EntryMetadata(long id, long dateEdited, int displayIndex) {
558 this.dateEdited = dateEdited;
559 this.displayIndex = displayIndex;
562 public int compareTo(EntryMetadata other) {
563 return Long.signum(id - other.id);