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