/* * @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.utils; import java.io.BufferedInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; import java.net.InetAddress; import java.net.MalformedURLException; import java.net.URISyntaxException; import java.net.URL; import java.net.URLConnection; import java.net.UnknownHostException; import java.security.MessageDigest; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Calendar; import java.util.Date; import java.util.Hashtable; import java.util.Iterator; import java.util.Locale; import java.util.Map.Entry; import java.util.TimeZone; import javax.xml.parsers.ParserConfigurationException; import javax.xml.parsers.SAXParser; import javax.xml.parsers.SAXParserFactory; import org.apache.http.HttpEntity; import org.apache.http.HttpResponse; import org.apache.http.client.HttpClient; import org.apache.http.client.methods.HttpGet; import org.apache.http.entity.BufferedHttpEntity; import org.apache.http.impl.client.DefaultHttpClient; import org.xml.sax.SAXException; import org.xml.sax.helpers.DefaultHandler; import android.app.Activity; import android.content.Context; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.Signature; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Color; import android.graphics.Shader.TileMode; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.GradientDrawable; import android.graphics.drawable.LayerDrawable; import android.net.ConnectivityManager; import android.net.NetworkInfo; import android.os.Bundle; import android.text.Html; import android.text.Spannable; import android.text.SpannableString; import android.text.Spanned; import android.text.style.URLSpan; import android.text.util.Linkify; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.ListView; import android.widget.Toast; import com.actionbarsherlock.app.SherlockFragment; import com.eleybourn.bookcatalogue.Author; import com.eleybourn.bookcatalogue.BookCatalogueApp; import com.eleybourn.bookcatalogue.CatalogueDBAdapter; import com.eleybourn.bookcatalogue.GetThumbnailTask; import com.eleybourn.bookcatalogue.LibraryThingManager; import com.eleybourn.bookcatalogue.R; import com.eleybourn.bookcatalogue.Series; import com.eleybourn.bookcatalogue.ThumbnailCacheWriterTask; import com.eleybourn.bookcatalogue.amazon.AmazonUtils; import com.eleybourn.bookcatalogue.database.CoversDbHelper; import com.eleybourn.bookcatalogue.dialogs.PartialDatePickerFragment; import com.eleybourn.bookcatalogue.dialogs.StandardDialogs; public class Utils { // External DB for cover thumbnails private boolean mCoversDbCreateFail = false; /** Database is non-static member so we don't make it linger longer than necessary */ private CoversDbHelper mCoversDb = null; // Used for formatting dates for sql; everything is assumed to be UTC, or converted to UTC since // UTC is the default SQLite TZ. static TimeZone tzUtc = TimeZone.getTimeZone("UTC"); // Used for date parsing and display private static SimpleDateFormat mDateFullHMSSqlSdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); static { mDateFullHMSSqlSdf.setTimeZone(tzUtc); } private static SimpleDateFormat mDateFullHMSqlSdf = new SimpleDateFormat("yyyy-MM-dd HH:mm"); static { mDateFullHMSqlSdf.setTimeZone(tzUtc); } private static SimpleDateFormat mDateSqlSdf = new SimpleDateFormat("yyyy-MM-dd"); static { mDateSqlSdf.setTimeZone(tzUtc); } static DateFormat mDateDispSdf = DateFormat.getDateInstance(java.text.DateFormat.MEDIUM); private static SimpleDateFormat mLocalDateSqlSdf = new SimpleDateFormat("yyyy-MM-dd"); static { mLocalDateSqlSdf.setTimeZone(Calendar.getInstance().getTimeZone()); } private static final ArrayList<SimpleDateFormat> mParseDateFormats = new ArrayList<SimpleDateFormat>(); static { final boolean isEnglish = (Locale.getDefault().getLanguage().equals(Locale.ENGLISH.getLanguage())); addParseDateFormat(!isEnglish, "dd-MMM-yyyy HH:mm:ss"); addParseDateFormat(!isEnglish, "dd-MMM-yyyy HH:mm"); addParseDateFormat(!isEnglish, "dd-MMM-yyyy"); addParseDateFormat(!isEnglish, "dd-MMM-yy HH:mm:ss"); addParseDateFormat(!isEnglish, "dd-MMM-yy HH:mm"); addParseDateFormat(!isEnglish, "dd-MMM-yy"); addParseDateFormat(false, "MM-dd-yyyy HH:mm:ss"); addParseDateFormat(false, "MM-dd-yyyy HH:mm"); addParseDateFormat(false, "MM-dd-yyyy"); addParseDateFormat(false, "dd-MM-yyyy HH:mm:ss"); addParseDateFormat(false, "dd-MM-yyyy HH:mm"); addParseDateFormat(false, "dd-MM-yyyy"); // Dates of the form: 'Fri May 5 17:23:11 -0800 2012' addParseDateFormat(!isEnglish, "EEE MMM dd HH:mm:ss ZZZZ yyyy"); addParseDateFormat(!isEnglish, "EEE MMM dd HH:mm ZZZZ yyyy"); addParseDateFormat(!isEnglish, "EEE MMM dd ZZZZ yyyy"); mParseDateFormats.add(mDateFullHMSSqlSdf); mParseDateFormats.add(mDateFullHMSqlSdf); mParseDateFormats.add(mDateSqlSdf); } public static final String APP_NAME = "Book Catalogue"; public static final boolean USE_LT = true; public static final boolean USE_BARCODE = true; //public static final String APP_NAME = "DVD Catalogue"; //public static final String LOCATION = "dvdCatalogue"; //public static final String DATABASE_NAME = "dvd_catalogue"; //public static final boolean USE_LT = false; //public static final boolean USE_BARCODE = false; //public static final String APP_NAME = "CD Catalogue"; //public static final String LOCATION = "cdCatalogue"; //public static final String DATABASE_NAME = "cd_catalogue"; //public static final boolean USE_LT = true; //public static final boolean USE_BARCODE = false; /** * Add a format to the parser list; if nedEnglish is set, also add the localized english version * * @param needEnglish * @param format */ private static void addParseDateFormat(boolean needEnglish, String format) { mParseDateFormats.add(new SimpleDateFormat(format)); if (needEnglish) mParseDateFormats.add(new SimpleDateFormat(format, Locale.ENGLISH)); } public static String toLocalSqlDateOnly(Date d) { return mLocalDateSqlSdf.format(d); } public static String toSqlDateOnly(Date d) { return mDateSqlSdf.format(d); } public static String toSqlDateTime(Date d) { return mDateFullHMSSqlSdf.format(d); } public static String toPrettyDate(Date d) { return mDateDispSdf.format(d); } public static String toPrettyDateTime(Date d) { return DateFormat.getDateTimeInstance().format(d); } /** * Attempt to parse a date string based on a range of possible formats. * * @param s String to parse * @return Resulting date if parsed, otherwise null */ public static Date parseDate(String s) { Date d; // First try to parse using strict rules d = parseDate(s, false); // If we got a date, exit if (d != null) return d; // OK, be lenient return parseDate(s, true); } /** * Attempt to parse a date string based on a range of possible formats; allow * for caller to specify if the parsing should be strict or lenient. * * @param s String to parse * @param lenient True if parsing should be lenient * * @return Resulting date if parsed, otherwise null */ private static Date parseDate(String s, boolean lenient) { Date d; for ( SimpleDateFormat sdf : mParseDateFormats ) { try { sdf.setLenient(lenient); d = sdf.parse(s); return d; } catch (Exception e) { // Ignore } } // All SDFs failed, try locale-specific... try { java.text.DateFormat df = java.text.DateFormat.getDateInstance(java.text.DateFormat.SHORT); df.setLenient(lenient); d = df.parse(s); return d; } catch (Exception e) { // Ignore } return null; } private static ArrayUtils<Author> mAuthorUtils = null; private static ArrayUtils<Series> mSeriesUtils = null; static public ArrayUtils<Author> getAuthorUtils() { if (mAuthorUtils == null) { mAuthorUtils = new ArrayUtils<Author>(new Utils.Factory<Author>(){ @Override public Author get(String source) { return new Author(source); }}); } return mAuthorUtils; } static public ArrayUtils<Series> getSeriesUtils() { if (mSeriesUtils == null) { mSeriesUtils = new ArrayUtils<Series>(new Utils.Factory<Series>(){ @Override public Series get(String source) { return new Series(source); }}); } return mSeriesUtils; } /** * Encode a string by 'escaping' all instances of: '|', '\', \r, \n. The * escape char is '\'. * * This is used to build text lists separated by the passed delimiter. * * @param s String to convert * @param delim The list delimiter to encode (if found). * * @return Converted string */ public static String encodeListItem(String s, char delim) { StringBuilder ns = new StringBuilder(); for (int i = 0; i < s.length(); i++){ char c = s.charAt(i); switch (c) { case '\\': ns.append("\\\\"); break; case '\r': ns.append("\\r"); break; case '\n': ns.append("\\n"); break; default: if (c == delim) ns.append("\\"); ns.append(c); } } return ns.toString(); } /** * Encode a list of strings by 'escaping' all instances of: delim, '\', \r, \n. The * escape char is '\'. * * This is used to build text lists separated by 'delim'. * * @param s String to convert * @return Converted string */ static String encodeList(ArrayList<String> sa, char delim) { StringBuilder ns = new StringBuilder(); Iterator<String> si = sa.iterator(); if (si.hasNext()) { ns.append(encodeListItem(si.next(), delim)); while (si.hasNext()) { ns.append(delim); ns.append(encodeListItem(si.next(), delim)); } } return ns.toString(); } public interface Factory<T> { T get(String source); } static public class ArrayUtils<T> { Factory<T> mFactory; ArrayUtils(Factory<T> factory) { mFactory = factory; } private T get(String source) { return mFactory.get(source); } /** * Encode a list of strings by 'escaping' all instances of: delim, '\', \r, \n. The * escape char is '\'. * * This is used to build text lists separated by 'delim'. * * @param s String to convert * @return Converted string */ public String encodeList(ArrayList<T> sa, char delim) { Iterator<T> si = sa.iterator(); return encodeList(si, delim); } private String encodeList(Iterator<T> si, char delim) { StringBuilder ns = new StringBuilder(); if (si.hasNext()) { ns.append(encodeListItem(si.next().toString(), delim)); while (si.hasNext()) { ns.append(delim); ns.append(encodeListItem(si.next().toString(), delim)); } } return ns.toString(); } /** * Decode a text list separated by '|' and encoded by encodeListItem. * * @param s String representing the list * @return Array of strings resulting from list */ public ArrayList<T> decodeList(String s, char delim, boolean allowBlank) { StringBuilder ns = new StringBuilder(); ArrayList<T> list = new ArrayList<T>(); if (s == null) return list; boolean inEsc = false; for (int i = 0; i < s.length(); i++){ char c = s.charAt(i); if (inEsc) { switch(c) { case '\\': ns.append(c); break; case 'r': ns.append('\r'); break; case 't': ns.append('\t'); break; case 'n': ns.append('\n'); break; default: ns.append(c); break; } inEsc = false; } else { switch (c) { case '\\': inEsc = true; break; default: if (c == delim) { String source = ns.toString(); if (allowBlank || source.length() > 0) list.add(get(source)); ns.setLength(0); break; } else { ns.append(c); break; } } } } // It's important to send back even an empty item. String source = ns.toString(); if (allowBlank || source.length() > 0) list.add(get(source)); return list; } } /** * Decode a text list separated by '|' and encoded by encodeListItem. * * @param s String representing the list * @return Array of strings resulting from list */ public static ArrayList<String> decodeList(String s, char delim) { StringBuilder ns = new StringBuilder(); ArrayList<String> list = new java.util.ArrayList<String>(); boolean inEsc = false; for (int i = 0; i < s.length(); i++){ char c = s.charAt(i); if (inEsc) { switch(c) { case '\\': ns.append(c); break; case 'r': ns.append('\r'); break; case 't': ns.append('\t'); break; case 'n': ns.append('\n'); break; default: ns.append(c); break; } inEsc = false; } else { switch (c) { case '\\': inEsc = true; break; default: if (c == delim) { list.add(ns.toString()); ns.setLength(0); break; } else { ns.append(c); break; } } } } // It's important to send back even an empty item. list.add(ns.toString()); return list; } /** * Add the current text data to the collection if not present, otherwise * append the data as a list. * * @param key Key for data to add */ static public void appendOrAdd(Bundle values, String key, String value) { String s = Utils.encodeListItem(value, '|'); if (!values.containsKey(key) || values.getString(key).length() == 0) { values.putString(key, s); } else { String curr = values.getString(key); values.putString(key, curr + "|" + s); } } /** * Given a URL, get an image and save to a file, optionally appending a suffic to the file. * * @param urlText Image file URL * @param filenameSuffix Suffix to add * * @return Downloaded filespec */ static public String saveThumbnailFromUrl(String urlText, String filenameSuffix) { // Get the URL URL u; try { u = new URL(urlText); } catch (MalformedURLException e) { Logger.logError(e); return ""; } // Turn the URL into an InputStream InputStream in = null; try { HttpGet httpRequest = null; httpRequest = new HttpGet(u.toURI()); HttpClient httpclient = new DefaultHttpClient(); HttpResponse response = (HttpResponse) httpclient.execute(httpRequest); HttpEntity entity = response.getEntity(); BufferedHttpEntity bufHttpEntity = new BufferedHttpEntity(entity); in = bufHttpEntity.getContent(); // The defaut URL fetcher does not cope well with pages that have not content // header (including goodreads images!). So use the more advanced one. //c = (HttpURLConnection) u.openConnection(); //c.setConnectTimeout(30000); //c.setRequestMethod("GET"); //c.setDoOutput(true); //c.connect(); //in = c.getInputStream(); } catch (IOException e) { Logger.logError(e); return ""; } catch (URISyntaxException e) { Logger.logError(e); return ""; } // Get the output file File file = CatalogueDBAdapter.getTempThumbnail(filenameSuffix); // Save to file saveInputToFile(in, file); // Return new file path return file.getAbsolutePath(); } /** * Given a InputStream, save it to a file. * * @param in InputStream to read * @param out File to save * @return true if successful */ static public boolean saveInputToFile(InputStream in, File out) { File temp = null; boolean isOk = false; try { // Get a temp file to avoid overwriting output unless copy works temp = File.createTempFile("temp_", null, StorageUtils.getSharedStorage()); FileOutputStream f = new FileOutputStream(temp); // Copy from input to temp file byte[] buffer = new byte[65536]; int len1 = 0; while ( (len1 = in.read(buffer)) > 0 ) { f.write(buffer,0, len1); } f.close(); // All OK, so rename to real output file temp.renameTo(out); isOk = true; } catch (FileNotFoundException e) { Logger.logError(e); } catch (IOException e) { Logger.logError(e); } finally { // Delete temp file if it still exists if (temp != null && temp.exists()) try { temp.delete(); } catch (Exception e) {}; } return isOk; } /** * Given a URL, get an image and return as a bitmap. * * @param urlText Image file URL * * @return Downloaded bitmap */ static public Bitmap getBitmapFromUrl(String urlText) { return getBitmapFromBytes( getBytesFromUrl(urlText) ); } /** * Given byte array that represents an image (jpg, png etc), return as a bitmap. * * @param bytes Raw byte data * * @return bitmap */ static public Bitmap getBitmapFromBytes(byte[] bytes) { if (bytes == null || bytes.length == 0) return null; BitmapFactory.Options options = new BitmapFactory.Options(); Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length,options); String s = "Array " + bytes.length + " bytes, bitmap " + bitmap.getHeight() + "x" + bitmap.getWidth(); System.out.println(s); return bitmap; } /** * Given a URL, get an image and return as a byte array. * * @param urlText Image file URL * * @return Downloaded byte[] */ static public byte[] getBytesFromUrl(String urlText) { // Get the URL URL u; try { u = new URL(urlText); } catch (MalformedURLException e) { Logger.logError(e); return null; } // Request it from the network HttpURLConnection c; InputStream in = null; try { c = (HttpURLConnection) u.openConnection(); c.setConnectTimeout(30000); c.setRequestMethod("GET"); c.setDoOutput(true); c.connect(); in = c.getInputStream(); } catch (IOException e) { Logger.logError(e); return null; } // Save the output to a byte output stream ByteArrayOutputStream f = new ByteArrayOutputStream(); try { byte[] buffer = new byte[1024]; int len1 = 0; while ( (len1 = in.read(buffer)) > 0 ) { f.write(buffer,0, len1); } f.close(); } catch (IOException e) { Logger.logError(e); return null; } // Return it as a byte[] return f.toByteArray(); } private static class ConnectionInfo { URLConnection conn = null; StatefulBufferedInputStream is = null; } public static class StatefulBufferedInputStream extends BufferedInputStream { private boolean mIsClosed = false; public StatefulBufferedInputStream(InputStream in) { super(in); } public StatefulBufferedInputStream(InputStream in, int i) { super(in, i); } @Override public void close() throws IOException { try { super.close(); } finally { mIsClosed = true; } } public boolean isClosed() { return mIsClosed; } } /** * Utility routine to get the data from a URL. Makes sure timeout is set to avoid application * stalling. * * @param url URL to retrieve * @return * @throws UnknownHostException */ static public InputStream getInputStream(URL url) throws UnknownHostException { synchronized (url) { int retries = 3; while (true) { try { /* * This is quite nasty; there seems to be a bug with URL.openConnection * * It CAN be reduced by doing the following: * * ((HttpURLConnection)conn).setRequestMethod("GET"); * * but I worry about future-proofing and the assumption that URL.openConnection * will always return a HttpURLConnection. OFC, it probably will...until it doesn't. * * Using HttpClient and HttpGet explicitly seems to bypass the casting * problem but still does not allow the timeouts to work, or only works intermittently. * * Finally, there is another problem with faild timeouts: * * http://thushw.blogspot.hu/2010/10/java-urlconnection-provides-no-fail.html * * So...we are forced to use a background thread to kill it. */ // If at some stage in the future the casting code breaks...use the Apache one. //final HttpClient client = new DefaultHttpClient(); //final HttpParams httpParameters = client.getParams(); // //HttpConnectionParams.setConnectionTimeout(httpParameters, 30 * 1000); //HttpConnectionParams.setSoTimeout (httpParameters, 30 * 1000); // //final HttpGet conn = new HttpGet(url.toString()); // //HttpResponse response = client.execute(conn); //InputStream is = response.getEntity().getContent(); //return new BufferedInputStream(is); final ConnectionInfo connInfo = new ConnectionInfo(); connInfo.conn = url.openConnection(); connInfo.conn.setUseCaches(false); connInfo.conn.setDoInput(true); connInfo.conn.setDoOutput(false); if (connInfo.conn instanceof HttpURLConnection) ((HttpURLConnection)connInfo.conn).setRequestMethod("GET"); connInfo.conn.setConnectTimeout(30000); connInfo.conn.setReadTimeout(30000); Terminator.enqueue(new Runnable() { @Override public void run() { if (connInfo.is != null) { if (!connInfo.is.isClosed()) { try { connInfo.is.close(); ((HttpURLConnection)connInfo.conn).disconnect(); } catch (IOException e) { Logger.logError(e); } } } else { ((HttpURLConnection)connInfo.conn).disconnect(); } }}, 30000); connInfo.is = new StatefulBufferedInputStream(connInfo.conn.getInputStream()); return connInfo.is; } catch (java.net.UnknownHostException e) { Logger.logError(e); retries--; if (retries-- == 0) throw e; try { Thread.sleep(500); } catch(Exception junk) {}; } catch (Exception e) { Logger.logError(e); throw new RuntimeException(e); } } } } /* *@return boolean return true if the application can access the internet */ public static boolean isNetworkAvailable(Context context) { ConnectivityManager connectivity = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); if (connectivity != null) { NetworkInfo[] info = connectivity.getAllNetworkInfo(); if (info != null) { for (int i = 0; i < info.length; i++) { if (info[i].getState() == NetworkInfo.State.CONNECTED) { return true; } } } } return false; } /** * If there is a '__thumbnails' key, pick the largest image, rename it * and delete the others. Finally, remove the key. * * @param result Book data */ static public void cleanupThumbnails(Bundle result) { if (result.containsKey("__thumbnail")) { long best = -1; int bestFile = -1; // Parse the list ArrayList<String> files = Utils.decodeList(result.getString("__thumbnail"), '|'); // Just read the image files to get file size BitmapFactory.Options opt = new BitmapFactory.Options(); opt.inJustDecodeBounds = true; // Scan, finding biggest for(int i = 0; i < files.size(); i++) { String filespec = files.get(i); File file = new File(filespec); if (file.exists()) { BitmapFactory.decodeFile( filespec, opt ); // If no size info, assume file bad and skip if ( opt.outHeight > 0 && opt.outWidth > 0 ) { long size = opt.outHeight * opt.outWidth; if (size > best) { best = size; bestFile = i; } } } } // Delete all but the best one. Note there *may* be no best one, // so all would be deleted. We do this first in case the list // contains a file with the same name as the target of our // rename. for(int i = 0; i < files.size(); i++) { if (i != bestFile) { File file = new File(files.get(i)); file.delete(); } } // Get the best file (if present) and rename it. if (bestFile >= 0) { File file = new File(files.get(bestFile)); file.renameTo(CatalogueDBAdapter.getTempThumbnail()); } // Finally, cleanup the data result.remove("__thumbnail"); result.putBoolean(CatalogueDBAdapter.KEY_THUMBNAIL, true); } } // Code removed in order to remove the temptation to USE it; proper-casing is very locale-specific. // /** // * Convert text at specified key to proper case. // * // * @param values // * @param key // */ // public static void doProperCase(Bundle values, String key) { // if (!values.containsKey(key)) // return; // values.putString(key, properCase(values.getString(key))); // } // // public static String properCase(String inputString) { // StringBuilder ff = new StringBuilder(); // String outputString; // int wordnum = 0; // // try { // for(String f: inputString.split(" ")) { // if(ff.length() > 0) { // ff.append(" "); // } // wordnum++; // String word = f.toLowerCase(); // // if (word.substring(0,1).matches("[\"\\(\\./\\\\,]")) { // wordnum = 1; // ff.append(word.substring(0,1)); // word = word.substring(1,word.length()); // } // // /* Do not convert 1st char to uppercase in the following situations */ // if (wordnum > 1 && word.matches("a|to|at|the|in|and|is|von|de|le")) { // ff.append(word); // continue; // } // try { // if (word.substring(0,2).equals("mc")) { // ff.append(word.substring(0,1).toUpperCase()); // ff.append(word.substring(1,2)); // ff.append(word.substring(2,3).toUpperCase()); // ff.append(word.substring(3,word.length())); // continue; // } // } catch (StringIndexOutOfBoundsException e) { // // do nothing and continue; // } // // try { // if (word.substring(0,3).equals("mac")) { // ff.append(word.substring(0,1).toUpperCase()); // ff.append(word.substring(1,3)); // ff.append(word.substring(3,4).toUpperCase()); // ff.append(word.substring(4,word.length())); // continue; // } // } catch (StringIndexOutOfBoundsException e) { // // do nothing and continue; // } // // try { // ff.append(word.substring(0,1).toUpperCase()); // ff.append(word.substring(1,word.length())); // } catch (StringIndexOutOfBoundsException e) { // ff.append(word); // } // } // // /* output */ // outputString = ff.toString(); // } catch (StringIndexOutOfBoundsException e) { // //empty string - do nothing // outputString = inputString; // } // return outputString; // } /** * Check if passed bundle contains a non-blank string at key k. * * @param b Bundle to check * @param key Key to check for * @return Present/absent */ public static boolean isNonBlankString(Bundle b, String key) { try { if (b.containsKey(key)) { String s = b.getString(key); return (s != null && s.length() > 0); } else { return false; } } catch (Exception e) { return false; } } /** * Join the passed array of strings, with 'delim' between them. * * @param sa Array of strings to join * @param delim Delimiter to place between entries * * @return The joined strings */ public static String join(String[] sa, String delim) { // Simple case, return empty string if (sa.length <= 0) return ""; // Initialize with first StringBuilder buf = new StringBuilder(sa[0]); if (sa.length > 1) { // If more than one, loop appending delim then string. for(int i = 1; i < sa.length; i++) { buf.append(delim); buf.append(sa[i]); } } // Return result return buf.toString(); } /** * Get a value from a bundle and convert to a long. * * @param b Bundle * @param key Key in bundle * * @return Result */ public static long getAsLong(Bundle b, String key) { Object o = b.get(key); if (o instanceof Long) { return (Long) o; } else if (o instanceof String) { return Long.parseLong((String)o); } else if (o instanceof Integer) { return ((Integer)o).longValue(); } else { throw new RuntimeException("Not a long value"); } } /** * Get a value from a bundle and convert to a long. * * @param b Bundle * @param key Key in bundle * * @return Result */ public static String getAsString(Bundle b, String key) { Object o = b.get(key); return o.toString(); } public interface ItemWithIdFixup { long fixupId(CatalogueDBAdapter db); long getId(); boolean isUniqueById(); } /** * Passed a list of Objects, remove duplicates based on the toString result. * * ENHANCE Add author_aliases table to allow further pruning (eg. Joe Haldeman == Jow W Haldeman). * ENHANCE Add series_aliases table to allow further pruning (eg. 'Amber Series' <==> 'Amber'). * * @param db Database connection to lookup IDs * @param list List to clean up */ public static <T extends ItemWithIdFixup> boolean pruneList(CatalogueDBAdapter db, ArrayList<T> list) { Hashtable<String,Boolean> names = new Hashtable<String,Boolean>(); Hashtable<Long,Boolean> ids = new Hashtable<Long,Boolean>(); // We have to go forwards through the list because 'first item' is important, // but we also can't delete things as we traverse if we are going forward. So // we build a list of items to delete. ArrayList<Integer> toDelete = new ArrayList<Integer>(); for(int i = 0; i < list.size(); i++) { T item = list.get(i); Long id = item.fixupId(db); String name = item.toString().trim().toUpperCase(); // Series special case - same name different series number. // This means different series positions will have the same ID but will have // different names; so ItemWithIdFixup contains the 'isUniqueById()' method. if (ids.containsKey(id) && !names.containsKey(name) && !item.isUniqueById()) { ids.put(id, true); names.put(name, true); } else if (names.containsKey(name) || (id != 0 && ids.containsKey(id))) { toDelete.add(i); } else { ids.put(id, true); names.put(name, true); } } for(int i = toDelete.size() - 1; i >= 0; i--) list.remove(toDelete.get(i).intValue()); return toDelete.size() > 0; } /** * Remove series from the list where the names are the same, but one entry has a null or empty position. * eg. the followig list should be processed as indicated: * * fred(5) * fred <-- delete * bill <-- delete * bill <-- delete * bill(1) * * @param list */ public static boolean pruneSeriesList(ArrayList<Series> list) { ArrayList<Series> toDelete = new ArrayList<Series>(); Hashtable<String, Series> index = new Hashtable<String, Series> (); for(Series s: list) { final boolean emptyNum = s.num == null || s.num.trim().equals(""); final String lcName = s.name.trim().toLowerCase(); final boolean inNames = index.containsKey(lcName); if (!inNames) { // Just add and continue index.put(lcName, s); } else { // See if we can purge either if (emptyNum) { // Always delete series with empty numbers if an equally or more specific one exists toDelete.add(s); } else { // See if the one in 'index' also has a num Series orig = index.get(lcName); if (orig.num == null || orig.num.trim().equals("")) { // Replace with this one, and mark orig for delete index.put(lcName, s); toDelete.add(orig); } else { // Both have numbers. See if they are the same. if (s.num.trim().toLowerCase().equals(orig.num.trim().toLowerCase())) { // Same exact series, delete this one toDelete.add(s); } else { // Nothing to do: this is a different series position } } } } } for (Series s: toDelete) list.remove(s); return (toDelete.size() > 0); } /** * Convert a array of objects to a string. * * @param <T> * @param a Array * @return Resulting string */ public static <T> String ArrayToString(ArrayList<T> a) { String details = ""; for (T i : a) { if (details.length() > 0) details += "|"; details += Utils.encodeListItem(i.toString(), '|'); } return details; } // TODO: Make sure all URL getters use this if possible. static public void parseUrlOutput(String path, SAXParserFactory factory, DefaultHandler handler) { SAXParser parser; URL url; try { url = new URL(path); parser = factory.newSAXParser(); parser.parse(Utils.getInputStream(url), handler); // Dont bother catching general exceptions, they will be caught by the caller. } catch (MalformedURLException e) { String s = "unknown"; try { s = e.getMessage(); } catch (Exception e2) {}; Logger.logError(e, s); } catch (ParserConfigurationException e) { String s = "unknown"; try { s = e.getMessage(); } catch (Exception e2) {}; Logger.logError(e, s); } catch (SAXException e) { String s = e.getMessage(); // "unknown"; try { s = e.getMessage(); } catch (Exception e2) {}; Logger.logError(e, s); } catch (java.io.IOException e) { String s = "unknown"; try { s = e.getMessage(); } catch (Exception e2) {}; Logger.logError(e, s); } } /** * Shrinks the image in the passed file to the specified dimensions, and places the image * in the passed view. The bitmap is returned. * * @param file * @param destView * @param maxWidth * @param maxHeight * @param exact * * @return */ public static Bitmap fetchFileIntoImageView(File file, ImageView destView, int maxWidth, int maxHeight, boolean exact) { Bitmap bm = null; // resultant Bitmap (which we will return) // Get the file, if it exists. Otherwise set 'help' icon and exit. if (!file.exists()) { if (destView != null) destView.setImageResource(android.R.drawable.ic_menu_help); return null; } bm = shrinkFileIntoImageView(destView, file.getPath(), maxWidth, maxHeight, exact); return bm; } /** * Construct the cache ID for a given thumbnail spec. * * NOTE: Any changes to the resulting name MUST be reflect in CoversDbHelper.eraseCachedBookCover() * * @param hash * @param maxWidth * @param maxHeight * @return */ public static final String getCoverCacheId(final String hash, final int maxWidth, final int maxHeight) { // NOTE: Any changes to the resulting name MUST be reflect in CoversDbHelper.eraseCachedBookCover() return hash + ".thumb." + maxWidth + "x" + maxHeight + ".jpg"; } /** * Utility routine to delete all cached covers of a specified book */ public void deleteCachedBookCovers(String hash) { CoversDbHelper coversDb = getCoversDb(); if (coversDb != null) { coversDb.deleteBookCover(hash); } } /** * Called in the UI thread, will return a cached image OR NULL. * * @param originalFile File representing original image file * @param destView View to populate * @param cacheId ID of the image in the cache * * @return Bitmap (if cached) or NULL (if not cached) */ public Bitmap fetchCachedImageIntoImageView(final File originalFile, final ImageView destView, final String cacheId) { Bitmap bm = null; // resultant Bitmap (which we will return) // Get the db CoversDbHelper coversDb = getCoversDb(); if (coversDb != null) { byte[] bytes; // Wrap in try/catch. It's possible the SDCard got removed and DB is now inaccessible Date expiry; if (originalFile == null) expiry = new Date(0L); else expiry = new Date(originalFile.lastModified()); try { bytes = coversDb.getFile(cacheId, expiry); } catch (Exception e) { bytes = null; }; if (bytes != null) { try { bm = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); } catch (Exception e) { bytes = null; }; } } if (bm != null) { // // Remove any tasks that may be getting the image because they may overwrite anything we do. // Remember: the view may have been re-purposed and have a different associated task which // must be removed from the view and removed from the queue. // if (destView != null) GetThumbnailTask.clearOldTaskFromView( destView ); // We found it in cache if (destView != null) destView.setImageBitmap(bm); // Return the image } return bm; } /** * Called in the UI thread, will either use a cached cover OR start a background task to create and load it. * * If a cached image is used a background task is still started to check the file date vs the cache date. If the * cached image date is < the file, it is rebuilt. * * @param destView View to populate * @param maxWidth Max width of resulting image * @param maxHeight Max height of resulting image * @param exact Whether to fit dimensions exactly * @param bookId ID of book to retrieve. * @param checkCache Indicates if cache should be checked for this cover * @param allowBackground Indicates if request can be put in background task. * * @return Bitmap (if cached) or NULL (if done in background) */ public final Bitmap fetchBookCoverIntoImageView(final ImageView destView, int maxWidth, int maxHeight, final boolean exact, final String hash, final boolean checkCache, final boolean allowBackground) { // Get the original file so we can use the modification date, path etc File coverFile = CatalogueDBAdapter.fetchThumbnailByUuid(hash); Bitmap bm = null; boolean cacheWasChecked = false; // If we want to check the cache, AND we dont have cache building happening, then check it. if (checkCache && !GetThumbnailTask.hasActiveTasks() && !ThumbnailCacheWriterTask.hasActiveTasks()) { final String cacheId = getCoverCacheId(hash, maxWidth, maxHeight); bm = fetchCachedImageIntoImageView(coverFile, destView, cacheId); cacheWasChecked = true; } else { //System.out.println("Skipping cache check"); } if (bm != null) return bm; // Check the file exists. Otherwise set 'help' icon and exit. //if (!coverFile.exists()) { // if (destView != null) // destView.setImageResource(android.R.drawable.ic_menu_help); // return null; //} // If we get here, the image is not in the cache but the original exists. See if we can queue it. if (allowBackground) { destView.setImageBitmap(null); GetThumbnailTask.getThumbnail(hash, destView, maxWidth, maxHeight, cacheWasChecked); return null; } //File coverFile = CatalogueDBAdapter.fetchThumbnail(bookId); // File is not in cache, original exists, we are in the background task (or not allowed to queue request) return shrinkFileIntoImageView(destView, coverFile.getPath(), maxWidth, maxHeight, exact); } /** * Shrinks the passed image file spec into the specificed dimensions, and returns the bitmap. If the view * is non-null, the image is also placed in the view. * * @param destView * @param filename * @param maxWidth * @param maxHeight * @param exact * * @return */ private static Bitmap shrinkFileIntoImageView(ImageView destView, String filename, int maxWidth, int maxHeight, boolean exact) { Bitmap bm = null; // Read the file to get file size BitmapFactory.Options opt = new BitmapFactory.Options(); opt.inJustDecodeBounds = true; BitmapFactory.decodeFile( filename, opt ); // If no size info, or a single pixel, assume file bad and set the 'alert' icon if ( opt.outHeight <= 0 || opt.outWidth <= 0 || (opt.outHeight== 1 && opt.outWidth == 1) ) { if (destView != null) destView.setImageResource(android.R.drawable.ic_dialog_alert); return null; } // Next time we don't just want the bounds, we want the file opt.inJustDecodeBounds = false; // Work out how to scale the file to fit in required bbox float widthRatio = (float)maxWidth / opt.outWidth; float heightRatio = (float)maxHeight / opt.outHeight; // Work out scale so that it fit exactly float ratio = widthRatio < heightRatio ? widthRatio : heightRatio; // Note that inSampleSize seems to ALWAYS be forced to a power of 2, no matter what we // specify, so we just work with powers of 2. int idealSampleSize = (int)android.util.FloatMath.ceil(1/ratio); // This is the sample size we want to use // Get the nearest *bigger* power of 2. int samplePow2 = (int)Math.pow(2, Math.ceil(Math.log(idealSampleSize)/Math.log(2))); try { if (exact) { // Create one bigger than needed and scale it; this is an attempt to improve quality. opt.inSampleSize = samplePow2 / 2; if (opt.inSampleSize < 1) opt.inSampleSize = 1; Bitmap tmpBm = BitmapFactory.decodeFile( filename, opt ); if (tmpBm == null) { // We ran out of memory, most likely // TODO: Need a way to try loading images after GC(), or something. Otherwise, covers in cover browser wil stay blank. Logger.logError(new RuntimeException("Unexpectedly failed to decode bitmap; memory exhausted?")); return null; } android.graphics.Matrix matrix = new android.graphics.Matrix(); // Fixup ratio based on new sample size and scale it. ratio = ratio / (1.0f / opt.inSampleSize); matrix.postScale(ratio, ratio); bm = Bitmap.createBitmap(tmpBm, 0, 0, opt.outWidth, opt.outHeight, matrix, true); // Recycle if original was not returned if (bm != tmpBm) { tmpBm.recycle(); tmpBm = null; } } else { // Use a scale that will make image *no larger than* the desired size if (ratio < 1.0f) opt.inSampleSize = samplePow2; bm = BitmapFactory.decodeFile( filename, opt ); } } catch (OutOfMemoryError e) { return null; } // Set ImageView and return bitmap if (destView != null) destView.setImageBitmap(bm); return bm; } public static void showLtAlertIfNecessary(Context context, boolean always, String suffix) { if (USE_LT) { LibraryThingManager ltm = new LibraryThingManager(context); if (!ltm.isAvailable()) StandardDialogs.needLibraryThingAlert(context, always, suffix); } } /** * Check if phone has a network connection * * @return */ /* public static boolean isOnline(Context ctx) { ConnectivityManager cm = (ConnectivityManager) ctx.getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo netInfo = cm.getActiveNetworkInfo(); if (netInfo != null && netInfo.isConnectedOrConnecting()) { return true; } return false; } */ /** * Check if phone can connect to a specific host. * Does not work.... * * ENHANCE: Find a way to make network host checks possible * * @return */ /* public static boolean hostIsAvailable(Context ctx, String host) { if (!isOnline(ctx)) return false; int addr; try { addr = lookupHost(host); } catch (Exception e) { return false; } ConnectivityManager cm = (ConnectivityManager) ctx.getSystemService(Context.CONNECTIVITY_SERVICE); try { return cm.requestRouteToHost(ConnectivityManager., addr); } catch (Exception e) { return false; } } */ public static int lookupHost(String hostname) { InetAddress inetAddress; try { inetAddress = InetAddress.getByName(hostname); } catch (UnknownHostException e) { return -1; } byte[] addrBytes; int addr; addrBytes = inetAddress.getAddress(); addr = ((addrBytes[3] & 0xff) << 24) | ((addrBytes[2] & 0xff) << 16) | ((addrBytes[1] & 0xff) << 8) | (addrBytes[0] & 0xff); return addr; } /** * Format the given string using the passed paraeters. */ public static String format(Context c, int id, Object...objects) { String f = c.getString(id); return String.format(f, objects); } /** * Get the 'covers' DB from external storage. */ public final CoversDbHelper getCoversDb() { if (mCoversDb == null) { if (mCoversDbCreateFail) return null; try { mCoversDb = new CoversDbHelper(); } catch (Exception e) { mCoversDbCreateFail = true; } } return mCoversDb; } /** * Cleanup DB connection, if present */ public void close() { if (mCoversDb != null) mCoversDb.close(); } /** * Analyze the covers db */ public void analyzeCovers() { CoversDbHelper db = getCoversDb(); if (db != null) db.analyze(); } /** * Erase contents of covers cache */ public void eraseCoverCache() { CoversDbHelper db = getCoversDb(); if (db != null) db.eraseCoverCache(); } /** * Erase contents of covers cache */ public int eraseCachedBookCover(String uuid) { CoversDbHelper db = getCoversDb(); if (db != null) return db.eraseCachedBookCover(uuid); else return 0; } /** Calendar to construct dates from month numbers */ private static Calendar mCalendar = null; /** Formatter for month names given dates */ private static SimpleDateFormat mMonthNameFormatter = null; public static String getMonthName(int month) { if (mMonthNameFormatter == null) mMonthNameFormatter = new SimpleDateFormat("MMMM"); // Create static calendar if necessary if (mCalendar == null) mCalendar = Calendar.getInstance(); // Assumes months are integers and in sequence...which everyone seems to assume mCalendar.set(Calendar.MONTH, month - 1 + java.util.Calendar.JANUARY); return mMonthNameFormatter.format(mCalendar.getTime()); } /** * Format a number of bytes in a human readable form */ public static String formatFileSize(float space) { String sizeFmt; if (space < 3072) { // Show 'bytes' if < 3k sizeFmt = BookCatalogueApp.getResourceString(R.string.bytes); } else if (space < 250 * 1024) { // Show Kb if less than 250kB sizeFmt = BookCatalogueApp.getResourceString(R.string.kilobytes); space = space / 1024; } else { // Show MB otherwise... sizeFmt = BookCatalogueApp.getResourceString(R.string.megabytes); space = space / (1024 * 1024); } return String.format(sizeFmt,space); } /** * Set the passed Activity background based on user preferences */ public static void initBackground(int bgResource, Activity a, boolean bright) { initBackground(bgResource, a.findViewById(R.id.root), bright); } public static void initBackground(int bgResource, SherlockFragment f, boolean bright) { initBackground(bgResource, f.getView().findViewById(R.id.root), bright); } /** * Set the passed Activity background based on user preferences */ public static void initBackground(int bgResource, Activity a, int rootId, boolean bright) { initBackground(bgResource, a.findViewById(rootId), bright); } public static void initBackground(int bgResource, View root, boolean bright) { try { final int backgroundColor = BookCatalogueApp.context.getResources().getColor(R.color.background_grey); if (BookCatalogueApp.isBackgroundImageDisabled()) { root.setBackgroundColor(backgroundColor); if (root instanceof ListView) { setCacheColorHintSafely((ListView)root, backgroundColor); } } else { if (root instanceof ListView) { ListView lv = ((ListView)root); setCacheColorHintSafely(lv, 0x00000000); } //Drawable d = cleanupTiledBackground(a.getResources().getDrawable(bgResource)); Drawable d = makeTiledBackground(bright); root.setBackgroundDrawable(d); } root.invalidate(); } catch (Exception e) { // Usually the errors result from memory problems; do a gc just in case. System.gc(); // This is a purely cosmetic function; just log the error Logger.logError(e, "Error setting background"); } } /** * Reuse of bitmaps in tiled backgrounds is a known cause of problems: * http://stackoverflow.com/questions/4077487/background-image-not-repeating-in-android-layout * So we avoid reusing them. * * This seems to have become further messed up in 4.1 so now, we just created them manually. No references, * but the old cleanup method (see below for cleanupTiledBackground()) no longer works. Since it effectively * un-cached the background, we just create it here. * * The main problem with this approach is that the style is defined in code rather than XML. * * @param a Activity context * @param bright Flag indicating if background should be 'bright' * * @return Background Drawable */ public static Drawable makeTiledBackground(boolean bright) { // Storage for the layers Drawable[] drawables = new Drawable[2]; // Get the BG image, put in tiled drawable Bitmap b = BitmapFactory.decodeResource(BookCatalogueApp.context.getResources(), R.drawable.books_bg); BitmapDrawable bmD = new BitmapDrawable(BookCatalogueApp.context.getResources(), b); bmD.setTileModeXY(TileMode.REPEAT, TileMode.REPEAT); // Add to layers drawables[0] = bmD; // Set up the gradient colours based on 'bright' setting int[] colours = new int[3]; if (bright) { colours[0] = Color.argb(224, 0, 0, 0); colours[1] = Color.argb(208, 0, 0, 0); colours[2] = Color.argb(48, 0, 0, 0); } else { colours[0] = Color.argb(255, 0, 0, 0); colours[1] = Color.argb(208, 0, 0, 0); colours[2] = Color.argb(160, 0, 0, 0); } // Create a gradient and add to layers GradientDrawable gd = new GradientDrawable(GradientDrawable.Orientation.LEFT_RIGHT, colours); drawables[1] = gd; // Make the layers and we are done. LayerDrawable ll = new LayerDrawable(drawables); ll.setDither(true); return ll; } ///** // * Reuse of bitmaps in tiled backgrounds is a known cause of problems: // * http://stackoverflow.com/questions/4077487/background-image-not-repeating-in-android-layout // * So we avoid reusing them // * // * @param d Drawable background that may be a BitmapDrawable or a layered drawablewhose first // * layer is a tiled bitmap // * // * @return Modified Drawable // */ //private static Drawable cleanupTiledBackground(Drawable d) { // if (d instanceof LayerDrawable) { // System.out.println("BG: BG is layered"); // LayerDrawable ld = (LayerDrawable)d; // Drawable l = ld.getDrawable(0); // if (l instanceof BitmapDrawable) { // d.mutate(); // l.mutate(); // System.out.println("BG: Layer0 is BMP"); // BitmapDrawable bmp = (BitmapDrawable) l; // bmp.mutate(); // make sure that we aren't sharing state anymore // //bmp.setTileModeXY(TileMode.CLAMP, TileMode.CLAMP); // bmp.setTileModeXY(TileMode.REPEAT, TileMode.REPEAT); // } else { // System.out.println("BG: Layer0 is " + l.getClass().getSimpleName() + " (ignored)"); // } // } else if (d instanceof BitmapDrawable) { // System.out.println("BG: Drawable is BMP"); // BitmapDrawable bmp = (BitmapDrawable) d; // bmp.mutate(); // make sure that we aren't sharing state anymore // //bmp.setTileModeXY(TileMode.CLAMP, TileMode.CLAMP); // bmp.setTileModeXY(TileMode.REPEAT, TileMode.REPEAT); // } // return d; //} /** * Call setCacheColorHint on a listview and trap IndexOutOfBoundsException. * * There is a bug in Android 2.2-2.3 (approx) that causes this call to throw * exceptions *sometimes* (circumstances unclear): * * http://code.google.com/p/android/issues/detail?id=9775 * * Ideally this code should use reflection to set it, or check android versions. * * @param lv ListView to set * @param hint Colour hint */ public static void setCacheColorHintSafely(ListView lv, int hint) { try { lv.setCacheColorHint(hint); } catch (IndexOutOfBoundsException e) { // Ignore System.out.println("Android Bug avoided"); } } /** * Format the passed bundle in a way that is convenient for display * * @param b Bundle to format * * @return Formatted string */ public static String bundleToString(Bundle b) { StringBuilder sb = new StringBuilder(); for(String k: b.keySet()) { sb.append(k); sb.append("->"); try { sb.append(b.get(k).toString()); } catch (Exception e) { sb.append("<<Unknown>>"); } sb.append("\n"); } return sb.toString(); } private interface INextView { int getNext(View v); void setNext(View v, int id); } /** * Ensure that next up/down/left/right View is visible for all sub-views of the * passed view. * * @param root */ public static void fixFocusSettings(View root) { final INextView getDown = new INextView() { @Override public int getNext(View v) { return v.getNextFocusDownId(); } @Override public void setNext(View v, int id) { v.setNextFocusDownId(id); } }; final INextView getUp = new INextView() { @Override public int getNext(View v) { return v.getNextFocusUpId(); } @Override public void setNext(View v, int id) { v.setNextFocusUpId(id); } }; final INextView getLeft = new INextView() { @Override public int getNext(View v) { return v.getNextFocusLeftId(); } @Override public void setNext(View v, int id) { v.setNextFocusLeftId(id); } }; final INextView getRight = new INextView() { @Override public int getNext(View v) { return v.getNextFocusRightId(); } @Override public void setNext(View v, int id) { v.setNextFocusRightId(id); } }; Hashtable<Integer,View> vh = getViews(root); for(Entry<Integer, View> ve: vh.entrySet()) { final View v = ve.getValue(); if (v.getVisibility() == View.VISIBLE) { fixNextView(vh, v, getDown); fixNextView(vh, v, getUp); fixNextView(vh, v, getLeft); fixNextView(vh, v, getRight); } } } /** * Passed a collection of views, a specific View and an INextView, ensure that the * currently set 'next' view is actually a visible view, updating it if necessary. * * @param vh Collection of all views * @param v View to check * @param getter Methods to get/set 'next' view */ private static void fixNextView(Hashtable<Integer,View> vh, View v, INextView getter) { int nextId = getter.getNext(v); if (nextId != View.NO_ID) { int actualNextId = getNextView(vh, nextId, getter); if (actualNextId != nextId) getter.setNext(v, actualNextId); } } /** * Passed a collection of views, a specific view and an INextView object find the * first VISIBLE object returned by INextView when called recursively. * * @param vh Collection of all views * @param nextId ID of 'next' view to get * @param getter Interface to lookup 'next' ID given a view * * @return ID if first visible 'next' view */ private static int getNextView(Hashtable<Integer,View> vh, int nextId, INextView getter) { final View v = vh.get(nextId); if (v == null) return View.NO_ID; if (v.getVisibility() == View.VISIBLE) return nextId; return getNextView(vh, getter.getNext(v), getter); } /** * Passed a parent View return a collection of all child views that have IDs. * * @param v Parent View * * @return Hashtable of descendants with ID != NO_ID */ private static Hashtable<Integer,View> getViews(View v) { Hashtable<Integer,View> vh = new Hashtable<Integer,View>(); getViews(v, vh); return vh; } /** * Passed a parent view, add it and all children view (if any) to the passed collection * * @param p Parent View * @param vh Collection */ private static void getViews(View p, Hashtable<Integer,View> vh) { // Get the view ID and add it to collection if not already present. final int id = p.getId(); if (id != View.NO_ID && !vh.containsKey(id)) { vh.put(id, p); } // If it's a ViewGroup, then process children recursively. if (p instanceof ViewGroup) { final ViewGroup g = (ViewGroup)p; final int nChildren = g.getChildCount(); for(int i = 0; i < nChildren; i++) { getViews(g.getChildAt(i), vh); } } } /** * Debug utility to dump an entire view hierarchy to the output. * * @param depth * @param v */ //public static void dumpViewTree(int depth, View v) { // for(int i = 0; i < depth*4; i++) // System.out.print(" "); // System.out.print(v.getClass().getName() + " (" + v.getId() + ")" + (v.getId() == R.id.descriptionLabelzzz? "DESC! ->" : " ->")); // if (v instanceof TextView) { // String s = ((TextView)v).getText().toString(); // System.out.println(s.substring(0, Math.min(s.length(), 20))); // } else { // System.out.println(); // } // if (v instanceof ViewGroup) { // ViewGroup g = (ViewGroup)v; // for(int i = 0; i < g.getChildCount(); i++) { // dumpViewTree(depth+1, g.getChildAt(i)); // } // } //} /** * Passed date components build a (partial) SQL format date string. * * @param year * @param month * @param day * * @return Formatted date, eg. '2011-11-01' or '2011-11' */ public static String buildPartialDate(Integer year, Integer month, Integer day) { String value; if (year == null) { value = ""; } else { value = String.format("%04d", year); if (month != null && month > 0) { String mm = month.toString(); if (mm.length() == 1) { mm = "0" + mm; } value += "-" + mm; if (day != null && day > 0) { String dd = day.toString(); if (dd.length() == 1) { dd = "0" + dd; } value += "-" + dd; } } } return value; } /** * Set the relevant fields in a BigDateDialog * * @param dialog Dialog to set * @param current Current value (may be null) * @param listener Listener to be called on dialg completion. */ public static void prepareDateDialogFragment(PartialDatePickerFragment dialog, Object current) { String dateString = current == null ? "" : current.toString(); // get the current date Integer yyyy = null; Integer mm = null; Integer dd = null; try { String[] dateAndTime = dateString.split(" "); String[] date = dateAndTime[0].split("-"); yyyy = Integer.parseInt(date[0]); mm = Integer.parseInt(date[1]); dd = Integer.parseInt(date[2]); } catch (Exception e) { //do nothing } dialog.setDate(yyyy, mm, dd); } // /** // * Build a new BigDateDialog and return it. // * // * @param context // * @param titleId // * @param listener // * @return // */ // public static PartialDatePicker buildDateDialog(Context context, int titleId, PartialDatePicker.OnDateSetListener listener) { // PartialDatePicker dialog = new PartialDatePicker(context); // dialog.setTitle(titleId); // dialog.setOnDateSetListener(listener); // return dialog; // } /** * Utility routine to get an author list from the intent extras * * @param i Intent with author list * @return List of authors */ @SuppressWarnings("unchecked") public static ArrayList<Author> getAuthorsFromBundle(Bundle b) { return (ArrayList<Author>) b.getSerializable(CatalogueDBAdapter.KEY_AUTHOR_ARRAY); } /** * Utility routine to get a series list from the intent extras * * @param i Intent with series list * @return List of series */ @SuppressWarnings("unchecked") public static ArrayList<Series> getSeriesFromBundle(Bundle b) { return (ArrayList<Series>) b.getSerializable(CatalogueDBAdapter.KEY_SERIES_ARRAY); } /** * Utility routine to get the list from the passed bundle. Added to reduce lint warnings... * * @param b Bundle containig list */ @SuppressWarnings("unchecked") public static <T> ArrayList<T> getListFromBundle(Bundle b, String key) { return (ArrayList<T>) b.getSerializable(key); } /** * Saved copy of the MD5 hash of the public key that signed this app, or a useful * text message if an error or other problem occurred. */ private static String mSignedBy = null; /** * Return the MD5 hash of the public key that signed this app, or a useful * text message if an error or other problem occurred. */ public static String signedBy(Context context) { // Get value if no cached value exists if (mSignedBy == null) { try { // Get app info PackageManager manager = context.getPackageManager(); PackageInfo appInfo = manager.getPackageInfo( context.getPackageName(), PackageManager.GET_SIGNATURES); // Each sig is a PK of the signer: // https://groups.google.com/forum/?fromgroups=#!topic/android-developers/fPtdt6zDzns for(Signature sig: appInfo.signatures) { if (sig != null) { final MessageDigest sha1 = MessageDigest.getInstance("MD5"); final byte[] publicKey = sha1.digest(sig.toByteArray()); // Turn the hex bytes into a more traditional MD5 string representation. final StringBuffer hexString = new StringBuffer(); boolean first = true; for (int i = 0; i < publicKey.length; i++) { if (!first) { hexString.append(":"); } else { first = false; } String byteString = Integer.toHexString(0xFF & publicKey[i]); if (byteString.length() == 1) hexString.append("0"); hexString.append(byteString); } String fingerprint = hexString.toString(); // Append as needed (theoretically could have more than one sig */ if (mSignedBy == null) mSignedBy = fingerprint; else mSignedBy += "/" + fingerprint; } } } catch (NameNotFoundException e) { // Default if package not found...kind of unlikely mSignedBy = "NOPACKAGE"; } catch (Exception e) { // Default if we die mSignedBy = e.getMessage(); } } return mSignedBy; } /** * Utility function to convert string to boolean * * @param s String to convert * @param emptyIsFalse TODO * * @return Boolean value */ public static boolean stringToBoolean(String s, boolean emptyIsFalse) { boolean v; if (s == null || s.equals("")) if (emptyIsFalse) { v = false; } else { throw new RuntimeException("Not a valid boolean value"); } else if (s.equals("1")) v = true; else if (s.equals("0")) v = false; else { s = s.trim().toLowerCase(); if (s.equals("t")) v = true; else if (s.equals("f")) v = false; else if (s.equals("true")) v = true; else if (s.equals("false")) v = false; else if (s.equals("y")) v = true; else if (s.equals("n")) v = false; else if (s.equals("yes")) v = true; else if (s.equals("no")) v = false; else { try { Integer i = Integer.parseInt(s); return i != 0; } catch (Exception e) { throw new RuntimeException("Not a valid boolean value"); } } } return v; } public static boolean objectToBoolean(Object o) { if (o instanceof Boolean) { return (Boolean)o; } if (o instanceof Integer || o instanceof Long) { return (Long)o != 0; } try { return (Boolean)o; } catch (ClassCastException e) { return stringToBoolean(o.toString(), true); } } public static void openAmazonSearchPage(Activity context, String author, String series) { try { AmazonUtils.openLink(context, author, series); } catch(Exception ae) { // An Amazon error should not crash the app Logger.logError(ae, "Unable to call the Amazon API"); Toast.makeText(context, R.string.unexpected_error, Toast.LENGTH_LONG).show(); // This code works, but Amazon have a nasty tendency to cancel Associate IDs... //String baseUrl = "http://www.amazon.com/gp/search?index=books&tag=philipwarneri-20&tracking_id=philipwarner-20"; //String extra = AmazonUtils.buildSearchArgs(author, series); //if (extra != null && !extra.trim().equals("")) { // Intent loadweb = new Intent(Intent.ACTION_VIEW, Uri.parse(baseUrl + extra)); // context.startActivity(loadweb); //} } return; } /** * Linkify partial HTML. Linkify methods remove all spans before building links, this * method preserves them. * * See: http://stackoverflow.com/questions/14538113/using-linkify-addlinks-combine-with-html-fromhtml * * @param html Partial HTML * @param linkifyMask Linkify mask to use in Linkify.addLinks * * @return Spannable with all links */ public static Spannable linkifyHtml(String html, int linkifyMask) { // Get the spannable HTML Spanned text = Html.fromHtml(html); // Save the span details for later restoration URLSpan[] currentSpans = text.getSpans(0, text.length(), URLSpan.class); // Build an empty spannable then add the links SpannableString buffer = new SpannableString(text); Linkify.addLinks(buffer, linkifyMask); // Add back the HTML spannables for (URLSpan span : currentSpans) { int end = text.getSpanEnd(span); int start = text.getSpanStart(span); buffer.setSpan(span, start, end, 0); } return buffer; } }