/*
* @copyright 2011 Philip Warner
* @license GNU General Public License
*
* This file is part of Book Catalogue.
*
* Book Catalogue is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Book Catalogue is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Book Catalogue. If not, see <http://www.gnu.org/licenses/>.
*/
package com.eleybourn.bookcatalogue;
import java.io.File;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
import android.database.Cursor;
import android.os.Bundle;
import com.eleybourn.bookcatalogue.UpdateFromInternet.FieldUsage;
import com.eleybourn.bookcatalogue.UpdateFromInternet.FieldUsages;
import com.eleybourn.bookcatalogue.UpdateFromInternet.FieldUsages.Usages;
import com.eleybourn.bookcatalogue.booklist.DatabaseDefinitions;
import com.eleybourn.bookcatalogue.utils.StorageUtils;
import com.eleybourn.bookcatalogue.utils.Utils;
/**
* Class to update all thumbnails (and some other data) in a background thread.
*
* @author Philip Warner
*/
public class UpdateThumbnailsThread extends ManagedTask {
// The fields that the user requested to update
private FieldUsages mRequestedFields;
// Lock help by pop and by push when an item was added to an empty stack.
private final ReentrantLock mSearchLock = new ReentrantLock();
// Signal for available items
private final Condition mSearchDone = mSearchLock.newCondition();
private String mFinalMessage;
// Data related to current row being processed
// - Original row data
private Bundle mOrigData = null;
// - current book ID
private long mCurrId = 0;
// - current book UUID
private String mCurrUuid = null;
// - The (subset) of fields relevant to the current book
private FieldUsages mCurrFieldUsages;
// Active search manager
private SearchManager mSearchManager = null;
// DB connection
protected CatalogueDBAdapter mDbHelper;
private SearchManager.SearchListener mSearchListener = new SearchManager.SearchListener() {
@Override
public boolean onSearchFinished(Bundle bookData, boolean cancelled) {
return handleSearchFinished(bookData, cancelled);
}};
/**
* Constructor.
*
* @param manager Object to manage background tasks
* @param requestedFields fields to update
* @param lookupHandler Interface object to handle events in this thread.
*/
public UpdateThumbnailsThread(TaskManager manager, FieldUsages requestedFields, TaskListener listener) {
super(manager);
mDbHelper = new CatalogueDBAdapter(BookCatalogueApp.context);
mDbHelper.open();
mRequestedFields = requestedFields;
mSearchManager = new SearchManager(mManager, mSearchListener);
mManager.doProgress(BookCatalogueApp.getResourceString(R.string.starting_search));
getMessageSwitch().addListener(getSenderId(), listener, false);
}
@Override
public void onRun() throws InterruptedException {
int counter = 0;
/* Test write to the SDCard; abort if not writable */
if (!StorageUtils.sdCardWritable()) {
mFinalMessage = getString(R.string.thumbnail_failed_sdcard);
return;
}
// ENHANCE: Allow caller to pass cursor (again) so that specific books can be updated (eg. just one book)
Cursor books = mDbHelper.fetchAllBooks("b." + CatalogueDBAdapter.KEY_ROWID, "", "", "", "", "", "");
mManager.setMax(this, books.getCount());
try {
while (books.moveToNext() && !isCancelled()) {
// Increment the progress counter
counter++;
// Copy the fields from the cursor and build a complete set of data for this book.
// This only needs to include data that we can fetch (so, for example, bookshelves are ignored).
mOrigData = new Bundle();
for(int i = 0; i < books.getColumnCount(); i++) {
mOrigData.putString(books.getColumnName(i), books.getString(i));
}
// Get the book ID
mCurrId = Utils.getAsLong(mOrigData, CatalogueDBAdapter.KEY_ROWID);
// Get the book UUID
mCurrUuid = mOrigData.getString( DatabaseDefinitions.DOM_BOOK_UUID.name );
// Get the extra data about the book
mOrigData.putSerializable(CatalogueDBAdapter.KEY_AUTHOR_ARRAY, mDbHelper.getBookAuthorList(mCurrId));
mOrigData.putSerializable(CatalogueDBAdapter.KEY_SERIES_ARRAY, mDbHelper.getBookSeriesList(mCurrId));
// Grab the searchable fields. Ideally we will have an ISBN but we may not.
String isbn = mOrigData.getString(CatalogueDBAdapter.KEY_ISBN);
// Make sure ISBN is not NULL (legacy data, and possibly set to null when adding new book)
if (isbn == null)
isbn = "";
String author = mOrigData.getString(CatalogueDBAdapter.KEY_AUTHOR_FORMATTED);
String title = mOrigData.getString(CatalogueDBAdapter.KEY_TITLE);
// Reset the fields we want for THIS book
mCurrFieldUsages = new FieldUsages();
// See if there is a reason to fetch ANY data by checking which fields this book needs.
for(FieldUsage usage : mRequestedFields.values()) {
// Not selected, we dont want it
if (usage.selected) {
switch(usage.usage) {
case ADD_EXTRA:
case OVERWRITE:
// Add and Overwrite mean we always get the data
mCurrFieldUsages.put(usage);
break;
case COPY_IF_BLANK:
// Handle special cases
// - If it's a thumbnail, then see if it's missing or empty.
if (usage.fieldName.equals(CatalogueDBAdapter.KEY_THUMBNAIL)) {
File file = CatalogueDBAdapter.fetchThumbnailByUuid(mCurrUuid);
if (!file.exists() || file.length() == 0)
mCurrFieldUsages.put(usage);
} else if (usage.fieldName.equals(CatalogueDBAdapter.KEY_AUTHOR_ARRAY)) {
// We should never have a book with no authors, but lets be paranoid
if (mOrigData.containsKey(usage.fieldName)) {
ArrayList<Author> origAuthors = Utils.getAuthorsFromBundle(mOrigData);
if (origAuthors == null || origAuthors.size() == 0)
mCurrFieldUsages.put(usage);
}
} else if (usage.fieldName.equals(CatalogueDBAdapter.KEY_SERIES_ARRAY)) {
if (mOrigData.containsKey(usage.fieldName)) {
ArrayList<Series> origSeries = Utils.getSeriesFromBundle(mOrigData);
if (origSeries == null || origSeries.size() == 0)
mCurrFieldUsages.put(usage);
}
} else {
// If the original was blank, add to list
if (!mOrigData.containsKey(usage.fieldName) || mOrigData.getString(usage.fieldName) == null || mOrigData.getString(usage.fieldName).length() == 0 )
mCurrFieldUsages.put(usage);
}
break;
}
}
}
// Cache the value to indicate we need thumbnails (or not).
boolean tmpThumbWanted = mCurrFieldUsages.containsKey(CatalogueDBAdapter.KEY_THUMBNAIL);
if (tmpThumbWanted) {
// delete any temporary thumbnails //
try {
File delthumb = CatalogueDBAdapter.getTempThumbnail();
delthumb.delete();
} catch (Exception e) {
// do nothing - this is the expected behaviour
}
}
// Use this to flag if we actually need a search.
boolean wantSearch = false;
// Update the progress appropriately
if (mCurrFieldUsages.size() == 0 || isbn.equals("") && (author.equals("") || title.equals(""))) {
mManager.doProgress(String.format(getString(R.string.skip_title), title));
} else {
wantSearch = true;
if (title.length() > 0)
mManager.doProgress(title);
else
mManager.doProgress(isbn);
}
mManager.doProgress(this, null, counter);
// Start searching if we need it, then wait...
if (wantSearch) {
// TODO: Allow user-selection of search sources
mSearchManager.search(author, title, isbn, tmpThumbWanted, SearchManager.SEARCH_ALL);
// Wait for the search to complete; when the search has completed it uses class-level state
// data when processing the results. It will signal this lock when it no longer needs any class
// level state data (eg. mOrigData).
mSearchLock.lock();
try {
mSearchDone.await();
} finally {
mSearchLock.unlock();
}
}
}
} finally {
// Clean up the cursor
if (books != null && !books.isClosed())
books.close();
// Empty the progress.
mManager.doProgress(null);
// Make the final message
mFinalMessage = String.format(getString(R.string.num_books_searched), "" + counter);
if (isCancelled())
mFinalMessage = String.format(BookCatalogueApp.getResourceString(R.string.cancelled_info), mFinalMessage);
}
}
@Override
public void onThreadFinish() {
try {
mManager.doToast(mFinalMessage);
} finally {
cleanup();
}
}
/**
* Called in the main thread for this object when a search has completed.
*
* @param bookData
* @param cancelled
*/
private boolean handleSearchFinished(Bundle bookData, boolean cancelled) {
System.out.println("onSearchFinished (cancel = " + cancelled + ")");
// Set cancelled flag if the task was cancelled
if (cancelled) {
cancelTask();
} else if (bookData == null) {
mManager.doToast("Unable to find book details");
}
// Save the local data from the context so we can start a new search
long rowId = mCurrId;
Bundle origData = mOrigData;
FieldUsages requestedFields = mCurrFieldUsages;
if (!isCancelled() && bookData != null)
processSearchResults(rowId, mCurrUuid, requestedFields, bookData, origData);
// Done! This need to go after processSearchResults() because doSearchDone() frees
// main thread which may disconnect database connection if on last book.
doSearchDone();
return true;
}
/**
* Passed the old & new data, construct the update data and perform the update.
*
* @param rowId Book ID
* @param newData Data gathered from internet
* @param origData Original data
*/
private void processSearchResults(long bookId, String bookUuid, FieldUsages requestedFields, Bundle newData, Bundle origData) {
// First, filter the data to remove keys we don't care about
ArrayList<String> toRemove = new ArrayList<String>();
for(String key : newData.keySet()) {
if (!requestedFields.containsKey(key) || !requestedFields.get(key).selected)
toRemove.add(key);
}
for(String key : toRemove) {
newData.remove(key);
}
// For each field, process it according the the usage.
for(FieldUsage usage : requestedFields.values()) {
if (newData.containsKey(usage.fieldName)) {
// Handle thumbnail specially
if (usage.fieldName.equals(CatalogueDBAdapter.KEY_THUMBNAIL)) {
File downloadedFile = CatalogueDBAdapter.getTempThumbnail();
boolean copyThumb = false;
if (usage.usage == Usages.COPY_IF_BLANK) {
File file = CatalogueDBAdapter.fetchThumbnailByUuid(bookUuid);
copyThumb = (!file.exists() || file.length() == 0);
} else if (usage.usage == Usages.OVERWRITE) {
copyThumb = true;
}
if (copyThumb) {
File file = CatalogueDBAdapter.fetchThumbnailByUuid(bookUuid);
downloadedFile.renameTo(file);
} else {
downloadedFile.delete();
}
} else {
switch(usage.usage) {
case OVERWRITE:
// Nothing to do; just use new data
break;
case COPY_IF_BLANK:
// Handle special cases
if (usage.fieldName.equals(CatalogueDBAdapter.KEY_AUTHOR_ARRAY)) {
if (origData.containsKey(usage.fieldName)) {
ArrayList<Author> origAuthors = Utils.getAuthorsFromBundle(origData);
if (origAuthors != null && origAuthors.size() > 0)
newData.remove(usage.fieldName);
}
} else if (usage.fieldName.equals(CatalogueDBAdapter.KEY_SERIES_ARRAY)) {
if (origData.containsKey(usage.fieldName)) {
ArrayList<Series> origSeries = Utils.getSeriesFromBundle(origData);
if (origSeries != null && origSeries.size() > 0)
newData.remove(usage.fieldName);
}
} else {
// If the original was non-blank, erase from list
if (origData.containsKey(usage.fieldName) && origData.getString(usage.fieldName) != null && origData.getString(usage.fieldName).length() > 0 )
newData.remove(usage.fieldName);
}
break;
case ADD_EXTRA:
// Handle arrays
if (usage.fieldName.equals(CatalogueDBAdapter.KEY_AUTHOR_ARRAY)) {
UpdateThumbnailsThread.<Author>combineArrays(usage.fieldName, origData, newData);
} else if (usage.fieldName.equals(CatalogueDBAdapter.KEY_SERIES_ARRAY)) {
UpdateThumbnailsThread.<Series>combineArrays(usage.fieldName, origData, newData);
} else {
// No idea how to handle this for non-arrays
throw new RuntimeException("Illegal usage '" + usage.usage + "' specified for field '" + usage.fieldName + "'");
}
break;
}
}
}
}
// Update
if (newData.size() > 0) {
mDbHelper.updateBook(bookId, new BookData(newData), 0);
}
}
private static<T extends Serializable> void combineArrays(String key, Bundle origData, Bundle newData) {
// Each of the lists to combine
ArrayList<T> origList = null;
ArrayList<T> newList = null;
// Get the list from the original, if present.
if (origData.containsKey(key)) {
origList = Utils.getListFromBundle(origData, key);
}
// Otherwise an empty list
if (origList == null)
origList = new ArrayList<T>();
// Get from the new data
if (newData.containsKey(key)) {
newList = Utils.getListFromBundle(newData, key);
}
if (newList == null)
newList = new ArrayList<T>();
origList.addAll(newList);
// Save combined version to the new data
newData.putSerializable(key, origList);
}
/**
* Called to signal that the search is complete AND the class-level data has
* been cached by the processing thread, so that a new search can begin.
*/
private void doSearchDone() {
// Let another search begin
mSearchLock.lock();
try {
mSearchDone.signal();
} finally {
mSearchLock.unlock();
}
}
/**
* Cleanup any DB connection etc after main task has run.
*/
private void cleanup() {
if (mDbHelper != null) {
mDbHelper.close();
mDbHelper = null;
}
}
@Override
protected void finalize() throws Throwable {
cleanup();
super.finalize();
}
}