blob: 4211fcf16c0f7442b737150260c3c2ecf667953b [file] [log] [blame]
/*
* 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);
}
}
}