/* * @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.util.ArrayList; import java.util.Date; import java.util.Hashtable; import android.os.Bundle; import com.eleybourn.bookcatalogue.TaskManager.TaskManagerListener; import com.eleybourn.bookcatalogue.messaging.MessageSwitch; import com.eleybourn.bookcatalogue.utils.IsbnUtils; import com.eleybourn.bookcatalogue.utils.Logger; import com.eleybourn.bookcatalogue.utils.Utils; /** * Class to co-ordinate multiple SearchThread objects using an existing TaskManager. * * It uses the task manager it is passed and listens to OnTaskEndedListener messages; * it maintain its own internal list of tasks and as tasks it knows about end, it * processes the data. Once all tasks are complete, it sends a message to its * creator via its SearchHandler. * * @author Philip Warner */ public class SearchManager implements TaskManagerListener { /** Flag indicating a search source to use */ public static final int SEARCH_GOOGLE = 1; /** Flag indicating a search source to use */ public static final int SEARCH_AMAZON = 2; /** Flag indicating a search source to use */ public static final int SEARCH_LIBRARY_THING = 4; /** Flag indicating a search source to use */ public static final int SEARCH_GOODREADS = 8; /** Mask including all search sources */ public static final int SEARCH_ALL = SEARCH_GOOGLE | SEARCH_AMAZON | SEARCH_LIBRARY_THING | SEARCH_GOODREADS; // ENHANCE: Allow user to change the default search data priority public static final int[] mDefaultSearchOrder = new int[] {SEARCH_AMAZON, SEARCH_GOODREADS, SEARCH_GOOGLE, SEARCH_LIBRARY_THING}; // ENHANCE: Allow user to change the default search data priority public static final int[] mDefaultReliabilityOrder = new int[] {SEARCH_GOODREADS, SEARCH_AMAZON, SEARCH_GOOGLE, SEARCH_LIBRARY_THING}; /** Flags applicable to *current* search */ int mSearchFlags; // TaskManager for threads; may have other threads tham the ones this object creates. TaskManager mTaskManager; // Accumulated book data private Bundle mBookData = null; // Flag indicating searches will be non-concurrent title/author found via ASIN private boolean mSearchingAsin = false; // Flag indicating searches will be non-concurrent until an ISBN is found private boolean mWaitingForIsbn = false; // Flag indicating a task was cancelled. private boolean mCancelledFlg = false; // Original author for search private String mAuthor; // Original title for search private String mTitle; // Original ISBN for search private String mIsbn; // Indicates original ISBN is really present private boolean mHasIsbn; // Whether of not to fetch thumbnails private boolean mFetchThumbnail; /** Output from search threads */ private Hashtable<Integer,Bundle> mSearchResults = new Hashtable<Integer,Bundle>(); // List of threads created by *this* object. private ArrayList<ManagedTask> mRunningTasks = new ArrayList<ManagedTask>(); // /** // * Task handler for thread management; caller MUST implement this to get // * search results. // * // * @author Philip Warner // */ // public interface SearchResultHandler extends ManagedTask.TaskListener { // void onSearchFinished(Bundle bookData, boolean cancelled); // } /** * Constructor. * * @param taskManager TaskManager to use * @param taskHandler SearchHandler to send results */ SearchManager(TaskManager taskManager, SearchListener taskHandler) { mTaskManager = taskManager; if (taskManager == null) throw new RuntimeException("TaskManager must be specified"); getMessageSwitch().addListener(getSenderId(), taskHandler, false); } /** * When a task has ended, see if we are finished (no more tasks running). * If so, finish. */ @Override public void onTaskEnded(TaskManager manager, ManagedTask task) { int size; //System.out.println(task.getClass().getSimpleName() + "(" + + task.getId() + ") FINISHED starting"); // Handle the result, and optionally queue another task if (task instanceof SearchThread) handleSearchTaskFinished((SearchThread)task); // Remove the finished task, and terminate if no more. synchronized(mRunningTasks) { mRunningTasks.remove(task); size = mRunningTasks.size(); //for(ManagedTask t: mRunningTasks) { // System.out.println(t.getClass().getSimpleName() + "(" + + t.getId() + ") still running"); //} } if (size == 0) { // Stop listening FIRST...otherwise, if sendResults() calls a listener that starts // a new task, we will stop listening for the new task. TaskManager.getMessageSwitch().removeListener(mTaskManager.getSenderId(), this); System.out.println("Not listening(1)"); // Notify the listeners. sendResults(); } //System.out.println(task.getClass().getSimpleName() + "(" + + task.getId() + ") FINISHED Exiting"); } /** * Other taskManager messages...we ignore them */ @Override public void onProgress(int count, int max, String message) { } @Override public void onToast(String message) { } @Override public void onFinished() { } /** * Utility routine to start a task * * @param thread Task to start */ private void startOne(SearchThread thread) { synchronized(mRunningTasks) { mRunningTasks.add(thread); mTaskManager.addTask(thread); //System.out.println(thread.getClass().getSimpleName() + "(" + + thread.getId() + ") STARTING"); } thread.start(); } /** * Start an Amazon search */ private boolean startAmazon() { if (!mCancelledFlg) { startOne( new SearchAmazonThread(mTaskManager, mAuthor, mTitle, mIsbn, mFetchThumbnail) ); return true; } else { return false; } } /** * Start a Google search */ private boolean startGoogle() { if (!mCancelledFlg) { startOne( new SearchGoogleThread(mTaskManager, mAuthor, mTitle, mIsbn, mFetchThumbnail) ); return true; } else { return false; } } /** * Start an Amazon search */ private boolean startLibraryThing(){ if (!mCancelledFlg && mHasIsbn) { startOne( new SearchLibraryThingThread(mTaskManager, mAuthor, mTitle, mIsbn, mFetchThumbnail)); return true; } else { return false; } } /** * Start an Goodreads search */ private boolean startGoodreads(){ if (!mCancelledFlg) { startOne( new SearchGoodreadsThread(mTaskManager, mAuthor, mTitle, mIsbn, mFetchThumbnail)); return true; } else { return false; } } /** * Start a search * * @param author Author to search for * @param title Title to search for * @param isbn ISBN to search for */ public void search(String author, String title, String isbn, boolean fetchThumbnail, int searchFlags) { if ( (searchFlags & SEARCH_ALL) == 0) throw new RuntimeException("Must specify at least one source to use"); if (mRunningTasks.size() > 0) { throw new RuntimeException("Attempting to start new search while previous search running"); } // Save the flags mSearchFlags = searchFlags; if (!Utils.USE_LT) { mSearchFlags &= ~SEARCH_LIBRARY_THING; } // Save the input and initialize mBookData = new Bundle(); mSearchResults = new Hashtable<Integer,Bundle>(); mWaitingForIsbn = false; mCancelledFlg = false; mAuthor = author; mTitle = title; mIsbn = isbn; mHasIsbn = mIsbn != null && mIsbn.trim().length() > 0 && IsbnUtils.isValid(mIsbn); mFetchThumbnail = fetchThumbnail; // XXXX: Not entirely sure why this code was targetted at the UI thread. doSearch(); //if (mTaskManager.runningInUiThread()) { // doSearch(); //} else { // mTaskManager.postToUiThread(new Runnable() { // @Override // public void run() { // doSearch(); // }}); //} } private void doSearch() { // List for task ends TaskManager.getMessageSwitch().addListener(mTaskManager.getSenderId(), this, false); //System.out.println("Listening"); // We really want to ensure we get the same book from each, so if isbn is not present, do // these in series. boolean tasksStarted = false; mSearchingAsin = false; try { if (mIsbn != null && mIsbn.length() > 0) { if (IsbnUtils.isValid(mIsbn)) { // We have an ISBN, just do the search mWaitingForIsbn = false; tasksStarted = this.startSearches(mSearchFlags); } else { // Assume it's an ASIN, and just search Amazon mSearchingAsin = true; mWaitingForIsbn = false; //mSearchFlags = SEARCH_AMAZON; tasksStarted = startOneSearch(SEARCH_AMAZON); //tasksStarted = this.startSearches(mSearchFlags); } } else { // Run one at a time, startNext() defined the order. mWaitingForIsbn = true; tasksStarted = startNext(); } } finally { if (!tasksStarted) { sendResults(); TaskManager.getMessageSwitch().removeListener(mTaskManager.getSenderId(), this); //System.out.println("Not listening(2)"); } } } /** * Utility routine to append text data from one Bundle to another * * @param key Key of data * @param source Source Bundle * @param dest Destination Bundle */ private void appendData(String key, Bundle source, Bundle dest) { String res = dest.getString(key) + "|" + source.getString(key); dest.putString(key, res); } /** * Copy data from passed Bundle to current accumulated data. Does some careful * processing of the data. * * @param bookData Source */ private void accumulateData(int searchId) { // See if we got data from this source if (!mSearchResults.containsKey(searchId)) return; Bundle bookData = mSearchResults.get(searchId); // See if we REALLY got data from this source if (bookData == null) return; for (String k : bookData.keySet()) { // If its not there, copy it. if (!mBookData.containsKey(k) || mBookData.getString(k) == null || mBookData.getString(k).trim().length() == 0) mBookData.putString(k, bookData.get(k).toString()); else { // Copy, append or update data as appropriate. if (k.equals(CatalogueDBAdapter.KEY_AUTHOR_DETAILS)) { appendData(k, bookData, mBookData); } else if (k.equals(CatalogueDBAdapter.KEY_SERIES_DETAILS)) { appendData(k, bookData, mBookData); } else if (k.equals(CatalogueDBAdapter.KEY_DATE_PUBLISHED)) { // Grab a different date if we can parse it. Date newDate = Utils.parseDate(bookData.getString(k)); if (newDate != null) { String curr = mBookData.getString(k); if (Utils.parseDate(curr) == null) { mBookData.putString(k, Utils.toSqlDateOnly(newDate)); } } } else if (k.equals("__thumbnail")) { appendData(k, bookData, mBookData); } } } } /** * Combine all the data and create a book or display an error. */ private void sendResults() { // This list will be the actual order of the result we apply, based on the // actual results and the default order. ArrayList<Integer> results = new ArrayList<Integer>(); if (mHasIsbn) { // If ISBN was passed, ignore entries with the wrong ISBN, and put entries with no ISBN at the end ArrayList<Integer> uncertain = new ArrayList<Integer>(); for(int i: mDefaultReliabilityOrder) { if (mSearchResults.containsKey(i)) { Bundle bookData = mSearchResults.get(i); if (bookData.containsKey(CatalogueDBAdapter.KEY_ISBN)) { String isbn = bookData.getString(CatalogueDBAdapter.KEY_ISBN); if (IsbnUtils.matches(mIsbn, isbn)) { results.add(i); } } else { uncertain.add(i); } } } for(Integer i: uncertain) { results.add(i); } // Add the passed ISBN first; avoid overwriting mBookData.putString(CatalogueDBAdapter.KEY_ISBN, mIsbn); } else { // If ISBN was not passed, then just used the default order for(int i: mDefaultReliabilityOrder) results.add(i); } // Merge the data we have. We do this in a fixed order rather than as the threads finish. for(int i: results) accumulateData(i); // If there are thumbnails present, pick the biggest, delete others and rename. Utils.cleanupThumbnails(mBookData); // Try to use/construct authors String authors = null; try { authors = mBookData.getString(CatalogueDBAdapter.KEY_AUTHOR_DETAILS); } catch (Exception e) {} if (authors == null || authors.equals("")) { authors = mAuthor; } if (authors != null && !authors.equals("")) { // Decode the collected author names and convert to an ArrayList ArrayList<Author> aa = Utils.getAuthorUtils().decodeList(authors, '|', false); mBookData.putSerializable(CatalogueDBAdapter.KEY_AUTHOR_ARRAY, aa); } // Try to use/construct title String title = null; try { title = mBookData.getString(CatalogueDBAdapter.KEY_TITLE); } catch (Exception e) {} if (title == null || title.equals("")) title = mTitle; if (title != null && !title.equals("")) { mBookData.putString(CatalogueDBAdapter.KEY_TITLE, title); } // Try to use/construct isbn String isbn = null; try { isbn = mBookData.getString(CatalogueDBAdapter.KEY_ISBN); } catch (Exception e) {} if (isbn == null || isbn.equals("")) isbn = mIsbn; if (isbn != null && !isbn.equals("")) { mBookData.putString(CatalogueDBAdapter.KEY_ISBN, isbn); } // Try to use/construct series String series = null; try { series = mBookData.getString(CatalogueDBAdapter.KEY_SERIES_DETAILS); } catch (Exception e) {} if (series != null && !series.equals("")) { // Decode the collected series names and convert to an ArrayList try { ArrayList<Series> sa = Utils.getSeriesUtils().decodeList(series, '|', false); mBookData.putSerializable(CatalogueDBAdapter.KEY_SERIES_ARRAY, sa); } catch (Exception e) { Logger.logError(e); } } else { //add series to stop crashing mBookData.putSerializable(CatalogueDBAdapter.KEY_SERIES_ARRAY, new ArrayList<Series>()); } // // TODO: this needs to be locale-specific. Currently we probably get good-enough data without // forcing a cleanup. // // Removed 20-Jan-2016 PJW; see Issue 717. // // Cleanup other fields //Utils.doProperCase(mBookData, CatalogueDBAdapter.KEY_TITLE); //Utils.doProperCase(mBookData, CatalogueDBAdapter.KEY_PUBLISHER); //Utils.doProperCase(mBookData, CatalogueDBAdapter.KEY_DATE_PUBLISHED); //Utils.doProperCase(mBookData, CatalogueDBAdapter.KEY_SERIES_NAME); // If book is not found or missing required data, warn the user if (authors == null || authors.length() == 0 || title == null || title.length() == 0) { mTaskManager.doToast(BookCatalogueApp.getResourceString(R.string.book_not_found)); } // Pass the data back sendSearchFinished(); } private void sendSearchFinished() { mMessageSwitch.send(mMessageSenderId, new MessageSwitch.Message<SearchListener>() { @Override public boolean deliver(SearchListener listener) { return listener.onSearchFinished(mBookData, mCancelledFlg); }} ); } /** * When running in single-stream mode, start the next thread that has no data. * While Google is reputedly most likely to succeed, it also produces garbage a lot. * So we search Amazon, Goodreads, Google and LT last as it REQUIRES an ISBN. */ private boolean startNext() { // Loop though in 'search-priority' order for (int source: mDefaultSearchOrder) { // If this search includes the source, check it if ( (mSearchFlags & source) != 0) { // If the source has not been search, search it if (!mSearchResults.containsKey(source)) { return startOneSearch(source); } } } return false; } /** * Start all searches listed in passed parameter that have not been run yet. * * @param sources */ private boolean startSearches(int sources) { // Scan searches in priority order boolean started = false; for(int source: mDefaultSearchOrder) { // If requested search contains this source... if ((sources & source) != 0) // If we have not run this search... if (!mSearchResults.containsKey(source)) { // Run it now if (startOneSearch(source)) started = true; } } return started; } /** * Start specific search listed in passed parameter. * * @param sources */ private boolean startOneSearch(int source) { switch(source) { case SEARCH_GOOGLE: return startGoogle(); case SEARCH_AMAZON: return startAmazon(); case SEARCH_LIBRARY_THING: return startLibraryThing(); case SEARCH_GOODREADS: return startGoodreads(); default: throw new RuntimeException("Unexpected search source: " + source); } } /** * Handle task search results; start another task if necessary. * * @param t */ private void handleSearchTaskFinished(SearchThread t) { SearchThread st = (SearchThread)t; mCancelledFlg = st.isCancelled(); Bundle bookData = st.getBookData(); mSearchResults.put(st.getSearchId(), bookData); if (mCancelledFlg) { mWaitingForIsbn = false; } else { if (mSearchingAsin) { // If we searched AMAZON for an Asin, then see what we found mSearchingAsin = false; // Clear the 'isbn' mIsbn = ""; if (Utils.isNonBlankString(bookData, CatalogueDBAdapter.KEY_ISBN)) { // We got an ISBN, so pretend we were searching for an ISBN mWaitingForIsbn = true; } else { // See if we got author/title mAuthor = bookData.getString(CatalogueDBAdapter.KEY_AUTHOR_NAME); mTitle = bookData.getString(CatalogueDBAdapter.KEY_TITLE); if (mAuthor != null && !mAuthor.equals("") && mTitle != null && !mTitle.equals("")) { // We got them, so pretend we are searching by author/title now, and waiting for an ASIN... mWaitingForIsbn = true; } } } if (mWaitingForIsbn) { if (Utils.isNonBlankString(bookData, CatalogueDBAdapter.KEY_ISBN)) { mWaitingForIsbn = false; // Start the other two...even if they have run before mIsbn = bookData.getString(CatalogueDBAdapter.KEY_ISBN); startSearches(mSearchFlags); } else { // Start next one that has not run. startNext(); } } } } /* ===================================================================== * Message Switchboard implementation * ===================================================================== */ /** * Allows other objects to know when a task completed. * * @author Philip Warner */ public interface SearchListener { boolean onSearchFinished(Bundle bookData, boolean cancelled); } public interface SearchController { void requestAbort(); SearchManager getSearchManager(); } private SearchController mController = new SearchController() { @Override public void requestAbort() { mTaskManager.cancelAllTasks(); } @Override public SearchManager getSearchManager() { return SearchManager.this; } }; /** * STATIC Object for passing messages from background tasks to activities that may be recreated * * This object handles all underlying OnTaskEndedListener messages for every instance of this class. */ protected static class TaskSwitch extends MessageSwitch<SearchListener, SearchController> {}; private static final TaskSwitch mMessageSwitch = new TaskSwitch(); protected static final TaskSwitch getMessageSwitch() { return mMessageSwitch; } private final long mMessageSenderId = mMessageSwitch.createSender(mController); public long getSenderId() { return mMessageSenderId; } }