net.oddsoftware.android.feedscribe.data.FeedManager.java Source code

Java tutorial

Introduction

Here is the source code for net.oddsoftware.android.feedscribe.data.FeedManager.java

Source

/*
 *  Copyright 2012 Brendan McCarthy (brendan@oddsoftware.net)
 *
 *  This file is part of Feedscribe.
 *
 *  Feedscribe is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License version 3 
 *  as published by the Free Software Foundation.
 *
 *  Feedscribe 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 Feedscribe.  If not, see <http://www.gnu.org/licenses/>.
 */
package net.oddsoftware.android.feedscribe.data;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.io.StringReader;
import java.net.MalformedURLException;
import java.net.ProxySelector;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Locale;
import java.util.TimeZone;
import java.util.zip.GZIPInputStream;
import java.util.zip.InflaterInputStream;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;

import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpHost;
import org.apache.http.HttpResponse;
import org.apache.http.StatusLine;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.impl.conn.ProxySelectorRoutePlanner;
import org.apache.http.protocol.BasicHttpContext;
import org.apache.http.protocol.ExecutionContext;
import org.apache.http.protocol.HttpContext;
import org.htmlcleaner.CleanerProperties;
import org.htmlcleaner.ContentNode;
import org.htmlcleaner.HtmlCleaner;
import org.htmlcleaner.HtmlNode;
import org.htmlcleaner.SpecialEntity;
import org.htmlcleaner.TagNode;
import org.htmlcleaner.TagNodeVisitor;
import org.w3c.dom.DOMException;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlPullParserFactory;
import org.xmlpull.v1.XmlSerializer;

import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager.NameNotFoundException;
import android.os.Environment;

import net.oddsoftware.android.feedscribe.*;
import net.oddsoftware.android.html.HttpCache;
import net.oddsoftware.android.utils.Logger;
import net.oddsoftware.android.utils.Utilities;

public class FeedManager {

    private static FeedManager mInstance = null;

    private FeedDBAdaptor mDB;

    int mPackageVersion;
    int mPreviousPackageVersion;

    public static String USER_AGENT = "Mozilla/5.0 (Linux; U; Android 2.1; en-us; Nexus One Build/ERD62)"
            + " AppleWebKit/530.17 (KHTML, like Gecko) Version/4.0 Mobile Safari/530.17";

    private FeedUpdateListener mFeedUpdateListener = null;

    private ArrayList<Download> mDownloads;

    private FeedConfig mFeedConfig = null;

    private Logger mLog = null;

    public static final SimpleDateFormat rfc822DateFormats[] = new SimpleDateFormat[] {
            // Sat, 22 Jan 2011 19:25:00 +1100
            new SimpleDateFormat("EEE, d MMM yy HH:mm:ss z", Locale.US),
            new SimpleDateFormat("EEE, d MMM yy HH:mm z", Locale.US),
            new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss z", Locale.US),
            new SimpleDateFormat("EEE, d MMM yyyy HH:mm z", Locale.US),
            new SimpleDateFormat("d MMM yy HH:mm z", Locale.US),
            new SimpleDateFormat("d MMM yy HH:mm:ss z", Locale.US),
            new SimpleDateFormat("d MMM yyyy HH:mm z", Locale.US),
            new SimpleDateFormat("d MMM yyyy HH:mm:ss z", Locale.US),
            new SimpleDateFormat("d MMM yyyy HH:mm:ss", Locale.US),

            // Here is an example of an invalid RFC822 date-time. This is commonly seen in RSS 1.0 feeds generated by older versions of Movable Type:
            // 2002-10-02T08:00:00-05:00
            //new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssz"),
    };

    public static final int MAX_ETAG_LENGTH = 200;

    public synchronized static FeedManager getInstance(Context ctx) {
        if (mInstance == null) {
            mInstance = new FeedManager(ctx);
        }
        return mInstance;
    }

    public synchronized static void closeInstance() {
        if (mInstance != null) {
            mInstance.close();
            mInstance = null;
        }
    }

    protected FeedManager(Context ctx) {
        mDB = new FeedDBAdaptor(ctx);
        mDB.open();

        mFeedConfig = FeedConfig.getInstance(ctx);

        mLog = Globals.LOG;

        // try and pull the package version from the package manager
        mPackageVersion = Globals.VERSION_CODE;
        mPreviousPackageVersion = 0;
        try {
            PackageInfo info = ctx.getPackageManager().getPackageInfo(ctx.getPackageName(), 0);
            mPackageVersion = info.versionCode;
        } catch (NameNotFoundException exc) {
            if (mLog.e())
                mLog.e("unable to get package version ", exc);
        }

        mDownloads = new ArrayList<Download>();
        loadDownloads();

        loadConfig();
    }

    public void close() {
        mDB.close();
    }

    public ArrayList<ShortFeedItem> getShortItems(int feedTypes) {
        ArrayList<ShortFeedItem> result = new ArrayList<ShortFeedItem>();

        ArrayList<Feed> feeds = mDB.getFeeds(feedTypes);

        for (Feed feed : feeds) {
            ArrayList<ShortFeedItem> items = mDB.getShortFeedItems(feed.mId, null, false);

            result.addAll(items);
        }

        Collections.sort(result);
        Collections.reverse(result);

        return result;
    }

    public ArrayList<ShortFeedItem> getShortItems(int feedTypes, String query) {
        String[] terms = query.split("\\s");

        ArrayList<ShortFeedItem> result = new ArrayList<ShortFeedItem>();

        ArrayList<Feed> feeds = mDB.getFeeds(feedTypes);

        for (Feed feed : feeds) {
            ArrayList<ShortFeedItem> items = mDB.getShortFeedItems(feed.mId, terms, false);

            result.addAll(items);
        }

        Collections.sort(result);
        Collections.reverse(result);

        return result;
    }

    public ArrayList<ShortFeedItem> getShortItems(long feedId) {
        ArrayList<ShortFeedItem> result = mDB.getShortFeedItems(feedId, null, false);

        if (result != null) {
            Collections.sort(result);
            Collections.reverse(result);
        }

        return result;
    }

    public void deDuplicate(ArrayList<ShortFeedItem> items) {
        int i = 1;

        while (i < items.size()) {
            ShortFeedItem cur = items.get(i);
            ShortFeedItem prev = items.get(i - 1);

            boolean remove = false;

            if (cur.mLink.equals(prev.mLink)) {
                remove = true;
            } else if (cur.mTitle.equals(prev.mTitle)) {
                try {
                    URI currentURI = new URI(cur.mLink);
                    URI prevURI = new URI(prev.mLink);

                    if (currentURI.getHost().equals(prevURI.getHost())) {
                        if (mLog.d())
                            mLog.d("removing duplicate from display because of title and host match");
                        remove = true;
                    }
                } catch (URISyntaxException exc) {
                    if (mLog.e())
                        mLog.e("deDuplicateArray error parsing links", exc);
                }
            }

            if (remove) {
                if (mLog.d())
                    mLog.d("removing duplicate from display, link is " + cur.mLink);
                items.remove(i);
            } else {
                ++i;
            }
        }
    }

    public FeedItem getItemById(long id) {
        return mDB.getFeedItem(id);
    }

    public boolean updateItem(FeedItem item) {
        return mDB.updateFeedItem(item);
    }

    public boolean updateItemFlags(FeedItem item) {
        return mDB.updateFeedItemFlags(item);
    }

    /** @return true if any updates were attempted */
    public boolean updateItems(long feedId, boolean forceUpdate, int minIntervalMinutes) {
        ArrayList<Feed> feeds = mDB.getFeeds();

        Date now = new Date();
        ArrayList<Feed> newFeeds = new ArrayList<Feed>();

        if (forceUpdate) {
            newFeeds = feeds;
        } else {
            for (Feed feed : feeds) {
                FeedStatus status = mDB.getFeedStatus(feed.mId);
                if (status == null) {
                    status = new FeedStatus();
                    status.mFeedId = feed.mId;
                }

                if (calculateUpdateTime(status, minIntervalMinutes).before(now)) {
                    newFeeds.add(feed);
                }
            }
        }

        if (newFeeds.size() == 0) {
            return false; // no updates
        }

        if (mFeedUpdateListener != null) {
            mFeedUpdateListener.feedUpdateProgress(0, newFeeds.size());
        }

        ArrayList<FeedItem> updatedItems = new ArrayList<FeedItem>();

        HttpCache httpCache = new HttpCache(mDB.getContext());

        int newItemCount = 0;
        int feedNumber = 0;
        for (Feed feed : newFeeds) {
            if (feedId != 0 && feed.mId != feedId) {
                continue;
            }

            FeedStatus status = mDB.getFeedStatus(feed.mId);
            if (status == null) {
                status = new FeedStatus();
                status.mFeedId = feed.mId;
            }

            ArrayList<FeedItem> feedItems = new ArrayList<FeedItem>();

            ArrayList<Enclosure> enclosures = new ArrayList<Enclosure>();

            String oldImageURL = feed.mImageURL;

            downloadFeed(feed, status, feedItems, enclosures);

            status.mLastHit.setTime(now.getTime());

            mDB.updateFeedStatus(status);

            if (feed.mImageURL != null && !feed.mImageURL.equals(oldImageURL)) {
                boolean theResult = mDB.updateFeedImageURL(feed);
                Globals.LOG.d("changing feed image to " + feed.mImageURL + " result " + theResult);
            }

            updateFeedImage(feed);

            ArrayList<ShortFeedItem> dbItems = mDB.getShortFeedItems(feed.mId, null, true);
            // search for duplicates, add any new items
            for (FeedItem newItem : feedItems) {
                newItem.mFeedId = feed.mId;

                boolean needUpdate = false;
                boolean found = false;

                ShortFeedItem duplicate = findDuplicate(newItem, dbItems);

                if (duplicate != null) {
                    found = true;
                    newItem.mId = duplicate.mId;

                    // if the new item is newer than the newest duplicate
                    if (newItem.mPubDate.getTime() > duplicate.mPubDate) {
                        needUpdate = true;
                    }
                }

                if (found && needUpdate) {
                    // TODO - why not this
                    //if (db.updateFeedItem(newItem))
                } else if (!found) {
                    if (mLog.d())
                        mLog.d("hit a new feed item guid " + newItem.mGUID + " link " + newItem.mLink
                                + " inserting into db");

                    // clean html description

                    if (newItem.mCleanDescription.length() > 0) {
                        newItem.mCleanDescription = cleanDescription(newItem.mCleanDescription);
                    } else {
                        newItem.mCleanDescription = cleanDescription(newItem.mDescription);
                    }

                    if (newItem.mPubDate.getTime() == 0) {
                        newItem.mPubDate = new Date();
                    }

                    newItem.mCleanTitle = cleanDescription(newItem.mTitle);

                    newItem.mCleanDescription = removeTitleFromDescription(newItem.mCleanDescription,
                            newItem.mCleanTitle);

                    if (mDB.updateFeedItem(newItem)) {
                        newItemCount += 1;
                        dbItems.add(new ShortFeedItem(newItem.mId, newItem.mLink, newItem.mPubDate.getTime(),
                                newItem.mTitle, newItem.mEnclosureURL, newItem.mGUID, newItem.mFlags));

                        if (newItem.mEnclosure != null) {
                            newItem.mEnclosure.mItemId = newItem.mId;
                            mDB.updateEnclosure(newItem.mEnclosure);
                        }
                    }

                    updatedItems.add(newItem);
                }
            }

            if (!feedItems.isEmpty()) {
                // find everything in dbitems that is marked as deleted, and check against feed items
                // if it's not there, really delete it
                for (Iterator<ShortFeedItem> i = dbItems.iterator(); i.hasNext();) {
                    ShortFeedItem item = i.next();

                    if ((item.mFlags & FeedItem.FLAG_DELETED) != 0) {
                        if (findDuplicate(item, feedItems) == null) {
                            if (mLog.d())
                                mLog.d("really deleting feed item " + item.mId + " from feed " + item.mFeedId);
                            mDB.deleteFeedItem(item.mId);
                            i.remove();
                        }
                    }
                }
            } else {
                mLog.d("skipping delete items because we didn't get any feed items at all");
            }

            FeedSettings feedSettings = getFeedSettings(feed.mId);
            if (feedSettings != null && feedSettings.mDisplayFullArticle) {
                for (ShortFeedItem feedItem : dbItems) {
                    if (((feedItem.mFlags & FeedItem.FLAG_DELETED) == 0)
                            && ((feedItem.mFlags & FeedItem.FLAG_READ) == 0)) {
                        httpCache.seed(feedItem.mLink);
                    }
                }
            }

            if (mFeedUpdateListener != null) {
                mFeedUpdateListener.feedUpdateProgress(feedNumber, newFeeds.size());
            }
            feedNumber++;
        }
        httpCache.maintainCache();

        mFeedConfig.addNewItemCount(newItemCount);

        //        // figure out if there are any images to download
        //        ArrayList<String> imageURLS = new ArrayList<String>();
        //        for(FeedItem item: updatedItems)
        //        {
        //            if( item.mImageURL.length() > 0 )
        //            {
        //                imageURLS.add(item.mImageURL);
        //            }
        //        }
        //        updatedItems.clear();
        //        
        //        // then download them
        //        for(String imageURL: imageURLS)
        //        {
        //            if (Globals.LOGGING) Log.d(Globals.LOG_TAG, "downloading an image " + imageURL);
        //            
        //            downloadImage(imageURL, false);
        //            
        //            if( mFeedUpdateListener != null )
        //            {
        //                mFeedUpdateListener.feedUpdateProgress(feedNumber, newFeeds.size() + imageURLS.size() );
        //            }
        //            feedNumber++;
        //        }
        //        
        //        // mDB.expireImages();
        // for now ignore the above and just delete any existing images, until we figure out how to cache them better
        mDB.deleteOlderImages(new Date().getTime());

        return true; // updates were tried
    }

    private void updateFeedImage(Feed feed) {
        if (feed.mImageURL == null || feed.mImageURL.length() == 0) {
            return;
        }

        Globals.LOG.d("updateFeedImage - downloading image url " + feed.mImageURL + " for feed " + feed.mURL);

        downloadImage(feed.mImageURL, true);
    }

    private ShortFeedItem findDuplicate(FeedItem newItem, ArrayList<ShortFeedItem> items) {
        for (ShortFeedItem item : items) {
            if (newItem.mGUID.length() != 0 && newItem.mGUID.equals(item.mGUID)) {
                if (mLog.v())
                    mLog.v("direct hit");

                // direct hit
                return item;
            }

            // next we check the pub date and title for an exact match
            if (newItem.mPubDate.getTime() == item.mPubDate && newItem.mTitle.equals(item.mTitle)) {
                // fuzzy match but it will do
                mLog.d("fuzzy match on title and pub date");
                return item;
            }
        }

        return null;
    }

    private FeedItem findDuplicate(ShortFeedItem newItem, ArrayList<FeedItem> items) {
        for (FeedItem item : items) {
            if (newItem.mGUID.length() != 0 && newItem.mGUID.equals(item.mGUID)) {
                if (mLog.v())
                    mLog.v("direct hit");

                // direct hit
                return item;
            }

            // next we check the pub date and title for an exact match
            if (newItem.mPubDate == item.mPubDate.getTime() && newItem.mTitle.equals(item.mTitle)) {
                // fuzzy match but it will do
                mLog.d("fuzzy match on title and pub date");
                return item;
            }
        }

        return null;
    }

    protected Date calculateUpdateTime(FeedStatus feedStatus, int minIntervalMinutes) {
        int ttl = feedStatus.mTTL;

        if (ttl > 0) {
            if (ttl < minIntervalMinutes) {
                ttl = minIntervalMinutes;
            }

            // ignore ttls below 5 minutes
            if (ttl < 5) {
                ttl = 5;
            }
            // ignore ttls above 1 day
            else if (ttl > (24 * 60)) {
                ttl = 24 * 60;
            }
        } else {
            ttl = 60;
        }

        if (mLog.d())
            mLog.d("set ttl from " + feedStatus.mTTL + " to " + ttl);

        Date updateTime = new Date();

        updateTime.setTime(feedStatus.mLastHit.getTime() + ttl * 60000);

        return updateTime;
    }

    void downloadImage(String address, boolean persistant) {
        if (mDB.hasImage(address)) {
            mDB.updateImageTime(address, new Date().getTime());

            return;
        }

        try {
            // use apache http client lib to set parameters from feedStatus
            DefaultHttpClient client = new DefaultHttpClient();

            // set up proxy handler
            ProxySelectorRoutePlanner routePlanner = new ProxySelectorRoutePlanner(
                    client.getConnectionManager().getSchemeRegistry(), ProxySelector.getDefault());
            client.setRoutePlanner(routePlanner);

            HttpGet request = new HttpGet(address);

            request.setHeader("User-Agent", USER_AGENT);

            HttpResponse response = client.execute(request);
            StatusLine status = response.getStatusLine();
            HttpEntity entity = response.getEntity();

            if (entity != null && status.getStatusCode() == 200) {
                InputStream inputStream = entity.getContent();
                // TODO - parse content-length here

                ByteArrayOutputStream data = new ByteArrayOutputStream();

                byte bytes[] = new byte[512];
                int count;
                while ((count = inputStream.read(bytes)) > 0) {
                    data.write(bytes, 0, count);
                }

                if (data.size() > 0) {
                    mDB.insertImage(address, new Date().getTime(), persistant, data.toByteArray());
                }
            }
        } catch (IOException exc) {
            if (mLog.e())
                mLog.e("error downloading image" + address, exc);
        }
    }

    void downloadFeed(Feed feed, FeedStatus feedStatus, ArrayList<FeedItem> feedItems,
            ArrayList<Enclosure> enclosures) {
        if (feed.mURL.startsWith("http")) {
            downloadFeedHttp(feed, feedStatus, feedItems, enclosures);
        }
    }

    void downloadFeedHttp(Feed feed, FeedStatus feedStatus, ArrayList<FeedItem> feedItems,
            ArrayList<Enclosure> enclosures) {
        try {
            // use apache http client lib to set parameters from feedStatus
            DefaultHttpClient client = new DefaultHttpClient();

            // set up proxy handler
            ProxySelectorRoutePlanner routePlanner = new ProxySelectorRoutePlanner(
                    client.getConnectionManager().getSchemeRegistry(), ProxySelector.getDefault());
            client.setRoutePlanner(routePlanner);

            HttpGet request = new HttpGet(feed.mURL);

            HttpContext httpContext = new BasicHttpContext();

            request.setHeader("User-Agent", USER_AGENT);

            // send etag if we have it
            if (feedStatus.mETag.length() > 0) {
                request.setHeader("If-None-Match", feedStatus.mETag);
            }

            // send If-Modified-Since if we have it
            if (feedStatus.mLastModified.getTime() > 0) {
                SimpleDateFormat dateFormat = new SimpleDateFormat("EEE', 'dd' 'MMM' 'yyyy' 'HH:mm:ss' GMT'",
                        Locale.US);
                dateFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
                String formattedTime = dateFormat.format(feedStatus.mLastModified);
                // If-Modified-Since: Sat, 29 Oct 1994 19:43:31 GMT
                request.setHeader("If-Modified-Since", formattedTime);
            }

            request.setHeader("Accept-Encoding", "gzip,deflate");
            HttpResponse response = client.execute(request, httpContext);

            if (mLog.d())
                mLog.d("http request: " + feed.mURL);
            if (mLog.d())
                mLog.d("http response code: " + response.getStatusLine());

            InputStream inputStream = null;

            StatusLine status = response.getStatusLine();
            HttpEntity entity = response.getEntity();

            if (entity != null) {
                inputStream = entity.getContent();
            }

            try {
                if (entity != null && status.getStatusCode() == 200) {
                    Header encodingHeader = entity.getContentEncoding();

                    if (encodingHeader != null) {
                        if (encodingHeader.getValue().equalsIgnoreCase("gzip")) {
                            inputStream = new GZIPInputStream(inputStream);
                        } else if (encodingHeader.getValue().equalsIgnoreCase("deflate")) {
                            inputStream = new InflaterInputStream(inputStream);
                        }
                    }

                    // remove caching attributes to be replaced with new ones
                    feedStatus.mETag = "";
                    feedStatus.mLastModified.setTime(0);
                    feedStatus.mTTL = 0;

                    boolean success = parseFeed(inputStream, feed, feedStatus, feedItems, enclosures);

                    if (success) {
                        // if the parse was ok, update these attributes
                        // ETag: "6050003-78e5-4981d775e87c0"
                        Header etagHeader = response.getFirstHeader("ETag");
                        if (etagHeader != null) {
                            if (etagHeader.getValue().length() < MAX_ETAG_LENGTH) {
                                feedStatus.mETag = etagHeader.getValue();
                            } else {
                                mLog.e("etag length was too big: " + etagHeader.getValue().length());
                            }
                        }

                        // Last-Modified: Fri, 24 Dec 2010 00:57:11 GMT
                        Header lastModifiedHeader = response.getFirstHeader("Last-Modified");
                        if (lastModifiedHeader != null) {
                            try {
                                feedStatus.mLastModified = parseRFC822Date(lastModifiedHeader.getValue());
                            } catch (ParseException exc) {
                                mLog.e("unable to parse date", exc);
                            }
                        }

                        HttpUriRequest currentReq = (HttpUriRequest) httpContext
                                .getAttribute(ExecutionContext.HTTP_REQUEST);
                        HttpHost currentHost = (HttpHost) httpContext
                                .getAttribute(ExecutionContext.HTTP_TARGET_HOST);
                        String currentUrl = currentHost.toURI() + currentReq.getURI();

                        mLog.w("loaded redirect from " + request.getURI().toString() + " to " + currentUrl);

                        feedStatus.mLastURL = currentUrl;
                    }
                } else {
                    if (status.getStatusCode() == 304) {
                        mLog.d("received 304 not modified");
                    }
                }
            } finally {
                if (inputStream != null) {
                    inputStream.close();
                }
            }
        } catch (IOException exc) {
            mLog.e("error downloading feed " + feed.mURL, exc);
        }
    }

    public static Date parseRFC822Date(String str) throws ParseException {
        for (SimpleDateFormat dateFormat : rfc822DateFormats) {
            try {
                return dateFormat.parse(str);
            } catch (ParseException exc) {
            }
        }

        throw new ParseException("unable to match any rfc822 date to:" + str, 0);
    }

    public static Date parseRFC3339Date(String datestring) throws ParseException {
        Date d = new Date();

        //if there is no time zone, we don't need to do any special parsing.
        if (datestring.endsWith("Z")) {
            try {
                SimpleDateFormat s = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US);//spec for RFC3339                    
                d = s.parse(datestring);
            } catch (ParseException pe) {
                //try again with optional decimals
                SimpleDateFormat s = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'", Locale.US);//spec for RFC3339 (with fractional seconds)
                s.setLenient(true);
                d = s.parse(datestring);
            }
            return d;
        }

        //step one, split off the timezone.
        int zoneMarker = Math.max(datestring.lastIndexOf('-'), datestring.lastIndexOf('+'));
        String firstpart = datestring.substring(0, zoneMarker);
        String secondpart = datestring.substring(zoneMarker);

        //step two, remove the colon from the timezone offset
        secondpart = secondpart.substring(0, secondpart.indexOf(':'))
                + secondpart.substring(secondpart.indexOf(':') + 1);
        datestring = firstpart + secondpart;
        SimpleDateFormat s = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US);//spec for RFC3339      
        try {
            d = s.parse(datestring);
        } catch (java.text.ParseException pe) {
            //try again with optional decimals
            s = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ", Locale.US);//spec for RFC3339 (with fractional seconds)
            s.setLenient(true);
            d = s.parse(datestring);
        }
        return d;
    }

    private boolean parseFeed(InputStream is, Feed feed, FeedStatus feedStatus, ArrayList<FeedItem> feedItems,
            ArrayList<Enclosure> enclosures) {
        try {
            DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance();
            DocumentBuilder dBuilder = dbFactory.newDocumentBuilder();
            Document doc = dBuilder.parse(is);

            parseAsRss(doc, feed, feedStatus, feedItems, enclosures);
            parseAsAtom(doc, feed, feedStatus, feedItems, enclosures);

            return true;
        } catch (ParserConfigurationException exc) {
            mLog.e("error parsing rss", exc);
        } catch (SAXException exc) {
            mLog.e("error parsing rss", exc);
        } catch (DOMException exc) {
            mLog.e("error parsing rss", exc);
        } catch (IOException exc) {
            mLog.e("error parsing rss", exc);
        }
        return false;
    }

    private void parseAsRss(Document doc, Feed feed, FeedStatus feedStatus, ArrayList<FeedItem> feedItems,
            ArrayList<Enclosure> enclosures) {
        // parse all 'item' elements
        NodeList nl = doc.getElementsByTagName("item");
        for (int i = 0; i < nl.getLength(); i++) {
            FeedItem feedItem = new FeedItem();
            Node node = nl.item(i);
            if (node.getNodeType() == Node.ELEMENT_NODE) {
                Element eElement = (Element) node;

                feedItem.mTitle = extractValue(eElement, "title");
                feedItem.mLink = extractValue(eElement, "link");
                feedItem.mGUID = extractValue(eElement, "guid");
                feedItem.mAuthor = extractValue(eElement, "author");
                feedItem.mDescription = extractValue(eElement, "description");
                feedItem.mOriginalLink = extractValue(eElement, "feedburner:origLink");

                feedItem.mImageURL = extractAttribute(eElement, "media:thumbnail", "url");

                if (feedItem.mAuthor.length() == 0) {
                    feedItem.mAuthor = extractValue(eElement, "dc:creator");
                }

                Date pubDate = new Date();
                pubDate.setTime(0);

                String pubDateString = extractValue(eElement, "pubDate");
                try {
                    pubDate = parseRFC822Date(pubDateString);
                } catch (ParseException exc) {
                    mLog.e("unable to parse item pubdate:" + pubDateString);
                }
                feedItem.mPubDate = pubDate;

                NodeList enclosuresList = eElement.getElementsByTagName("enclosure");
                if (enclosuresList != null && enclosuresList.getLength() > 0) {
                    NamedNodeMap enclosureAttributes = enclosuresList.item(0).getAttributes();
                    if (enclosureAttributes != null) {
                        Enclosure enclosure = new Enclosure();

                        Node enclosureURL = enclosureAttributes.getNamedItem("url");
                        Node enclosureLength = enclosureAttributes.getNamedItem("length");
                        Node enclosureType = enclosureAttributes.getNamedItem("type");

                        if (enclosureURL != null) {
                            enclosure.mURL = enclosureURL.getNodeValue();
                        }

                        if (enclosureLength != null) {
                            try {
                                enclosure.mLength = Long.parseLong(enclosureLength.getNodeValue());
                            } catch (NumberFormatException exc) {
                                mLog.e("error parsing enclosure length", exc);
                            }
                        }

                        if (enclosureType != null) {
                            enclosure.mContentType = enclosureType.getNodeValue();
                        }

                        String duration = extractValue(eElement, "itunes:duration");
                        if (duration != null && duration.length() > 0) {
                            enclosure.mDuration = Utilities.parseDuration(duration) * 1000;
                        }

                        duration = extractAttribute(eElement, "media:content", "duration");
                        if (duration != null && duration.length() > 0) {
                            enclosure.mDuration = Utilities.parseDuration(duration) * 1000;
                        }

                        duration = extractValue(eElement, "blip:runtime");
                        if (duration != null && duration.length() > 0) {
                            enclosure.mDuration = Utilities.parseDuration(duration) * 1000;
                        }

                        // TODO - find a better way to do this
                        // nuke image enclosures for now
                        if (enclosure.mContentType.startsWith("image/")) {
                            enclosure.mURL = "";
                        }

                        if (enclosure.mURL.length() > 0) {
                            try {
                                URL url = new URL(enclosure.mURL);

                                feedItem.mEnclosureURL = url.toExternalForm();
                                enclosure.mURL = url.toExternalForm();
                                feedItem.mEnclosure = enclosure;

                                enclosures.add(enclosure);
                            } catch (MalformedURLException exc) {
                                mLog.e("error parsing enclosure url", exc);
                            }
                        }
                    }
                }

                feedItems.add(feedItem);
            }
        }

        // parse all 'channel' elements
        nl = doc.getElementsByTagName("channel");
        for (int i = 0; i < nl.getLength(); i++) {
            Node node = nl.item(i);
            if (node.getNodeType() == Node.ELEMENT_NODE) {
                // extract ttl
                Element eElement = (Element) node;
                String ttlString = extractValue(eElement, "ttl");

                if (ttlString.length() > 0) {
                    try {
                        feedStatus.mTTL = Integer.parseInt(ttlString);
                    } catch (NumberFormatException exc) {
                        mLog.e("error parsing ttl: " + ttlString, exc);
                    }
                }

                if (feed != null) {
                    String title = extractValue(eElement, "title");
                    String link = extractValue(eElement, "link");
                    String description = extractValue(eElement, "description");

                    feed.mName = title;
                    feed.mLink = link;
                    feed.mDescription = description;

                    Node imageNode = eElement.getElementsByTagName("image").item(0);
                    if (imageNode != null && imageNode.getNodeType() == Node.ELEMENT_NODE) {
                        String imageUrl = extractValue((Element) imageNode, "url");
                        feed.mImageURL = imageUrl;
                    }
                }
            }
        }
    }

    private void parseAsAtom(Document doc, Feed feed, FeedStatus feedStatus, ArrayList<FeedItem> feedItems,
            ArrayList<Enclosure> enclosures) {
        // parse all 'item' elements
        NodeList nl = doc.getElementsByTagName("entry");
        for (int i = 0; i < nl.getLength(); i++) {
            FeedItem feedItem = new FeedItem();
            Node node = nl.item(i);
            if (node.getNodeType() == Node.ELEMENT_NODE) {
                Element eElement = (Element) node;

                feedItem.mTitle = extractValue(eElement, "title");
                feedItem.mGUID = extractValue(eElement, "id");
                feedItem.mCleanDescription = extractValue(eElement, "summary");
                feedItem.mDescription = extractValue(eElement, "content");
                feedItem.mOriginalLink = extractValue(eElement, "feedburner:origLink");

                feedItem.mImageURL = extractAttribute(eElement, "media:thumbnail", "url");

                NodeList authorNodes = eElement.getElementsByTagName("author");
                for (int j = 0; j < authorNodes.getLength(); ++j) {
                    if (authorNodes.item(j).getNodeType() == Node.ELEMENT_NODE) {
                        feedItem.mAuthor = extractValue((Element) (authorNodes.item(j)), "name");
                        if (feedItem.mAuthor.length() > 0) {
                            break;
                        }
                    }
                }

                Date pubDate = new Date();
                pubDate.setTime(0);

                String pubDateString = extractValue(eElement, "updated");
                try {
                    pubDate = parseRFC3339Date(pubDateString);
                } catch (ParseException exc) {
                    mLog.e("unable to parse item pubdate:" + pubDateString);
                }
                feedItem.mPubDate = pubDate;

                NodeList linksList = eElement.getElementsByTagName("link");
                if (linksList != null) {
                    for (int j = 0; j < linksList.getLength(); ++j) {
                        NamedNodeMap linkAttributes = linksList.item(j).getAttributes();
                        Node relNode = linkAttributes.getNamedItem("rel");
                        if (relNode == null) {
                            continue;
                        }
                        String rel = relNode.getNodeValue();
                        if (rel.equals("alternate") && feedItem.mLink.length() == 0) {
                            feedItem.mLink = extractAttribute(eElement, "link", "href");
                        } else if (rel.equals("enclosure")) {
                            Enclosure enclosure = new Enclosure();

                            Node enclosureURL = linkAttributes.getNamedItem("href");
                            Node enclosureLength = linkAttributes.getNamedItem("length");
                            Node enclosureType = linkAttributes.getNamedItem("type");

                            if (enclosureURL != null) {
                                enclosure.mURL = enclosureURL.getNodeValue();
                            }

                            if (enclosureLength != null) {
                                try {
                                    enclosure.mLength = Long.parseLong(enclosureLength.getNodeValue());
                                } catch (NumberFormatException exc) {
                                    mLog.e("error parsing enclosure length", exc);
                                }
                            }

                            if (enclosureType != null) {
                                enclosure.mContentType = enclosureType.getNodeValue();
                            }

                            // TODO - find a better way to do this
                            // nuke image enclosures for now
                            if (enclosure.mContentType.startsWith("image/")) {
                                enclosure.mURL = "";
                            }

                            if (enclosure.mURL.length() > 0) {
                                try {
                                    URL url = new URL(enclosure.mURL);

                                    feedItem.mEnclosureURL = url.toExternalForm();
                                    enclosure.mURL = url.toExternalForm();
                                    feedItem.mEnclosure = enclosure;

                                    enclosures.add(enclosure);
                                } catch (MalformedURLException exc) {
                                    mLog.e("error parsing enclosure url", exc);
                                }
                            }
                        }
                    }
                }

                if (feedItem.mLink.length() > 0) {
                    feedItems.add(feedItem);
                }
            }
        } // proccess all entries

        // parse all 'feed' elements
        nl = doc.getElementsByTagName("feed");
        for (int i = 0; i < nl.getLength(); i++) {
            Node node = nl.item(i);
            if (node.getNodeType() == Node.ELEMENT_NODE) {
                // extract ttl
                Element eElement = (Element) node;
                if (feed != null) {
                    String title = extractValue(eElement, "title");
                    String link = extractAttribute(eElement, "link", "href");
                    String description = extractValue(eElement, "subtitle");

                    feed.mName = title;
                    feed.mLink = link;
                    feed.mDescription = description;
                    feed.mImageURL = extractValue(eElement, "icon");
                }
            }

            feedStatus.mTTL = 10;
        }
    }

    private String extractValue(Element eElement, String tag) {
        StringBuilder builder = new StringBuilder();
        NodeList nlList = eElement.getElementsByTagName(tag);
        if (nlList.getLength() > 0) {
            nlList = nlList.item(0).getChildNodes();
            for (int j = 0; j < nlList.getLength(); ++j) {
                Node nValue = (Node) nlList.item(j);
                builder.append(nValue.getNodeValue());
            }
            deSpace(builder);
        }
        return builder.toString();
    }

    private String extractAttribute(Element root, String tagName, String attributeName) {
        StringBuffer buffer = new StringBuffer();
        NodeList nlList = root.getElementsByTagName(tagName);
        if (nlList.getLength() > 0) {
            NamedNodeMap attributes = nlList.item(0).getAttributes();
            Node nValue = attributes.getNamedItem(attributeName);
            if (nValue != null) {
                buffer.append(nValue.getNodeValue());
            }
        }
        return buffer.toString();
    }

    private void deSpace(StringBuilder builder) {
        // remove whitespace from front
        while (builder.length() > 0) {
            if (Character.isWhitespace(builder.charAt(0))) {
                builder.deleteCharAt(0);
            } else {
                break;
            }
        }

        // remove whitespace from back
        while (builder.length() > 0) {
            int j = builder.length() - 1;
            if (Character.isWhitespace(builder.charAt(j))) {
                builder.deleteCharAt(j);
            } else {
                break;
            }
        }

        // remove duplicate spaces
        // TODO - detect any whitespace type
        boolean previousWasSpace = false;
        for (int j = 0; j < builder.length();) {
            char c = builder.charAt(j);
            if (previousWasSpace && c == ' ') {
                builder.deleteCharAt(j);
            } else {
                ++j;
                previousWasSpace = c == ' ';
            }
        }
    }

    public String cleanDescription(TagNode node) {
        final StringBuilder description = new StringBuilder();

        node.traverse(new TagNodeVisitor() {
            @Override
            public boolean visit(TagNode tagNode, HtmlNode htmlNode) {
                if (htmlNode instanceof ContentNode) {
                    ContentNode contentNode = (ContentNode) htmlNode;
                    htmlUnescapeInto(contentNode.getContent(), description);
                }
                return true;
            }

        });

        return description.toString().trim();
    }

    public String cleanDescription(String input) {
        HtmlCleaner cleaner = new HtmlCleaner();
        CleanerProperties props = cleaner.getProperties();
        props.setOmitComments(true);

        TagNode node = cleaner.clean(input);

        return cleanDescription(node);
    }

    public String removeTitleFromDescription(String description, String title) {
        if (description.startsWith(title)) {
            description = description.substring(title.length(), description.length()).trim();
        }

        return description;
    }

    public void loadConfig() {
        mPreviousPackageVersion = mFeedConfig.getPreviousPackageVersion(mPreviousPackageVersion);
    }

    public void setFeedUpdateListener(FeedUpdateListener listener) {
        mFeedUpdateListener = listener;
    }

    public boolean isFirstRun() {
        return mPreviousPackageVersion <= Globals.PREVIOUS_VERSION_CODE;
    }

    public void clearFirstRun() {
        if (mPreviousPackageVersion != mPackageVersion) {
            mPreviousPackageVersion = mPackageVersion;

            mFeedConfig.setPreviousPackageVersion(mPreviousPackageVersion);
        }
    }

    public byte[] getImage(String imageURL) {
        return mDB.getImage(imageURL);
    }

    public Feed getFeed(long feedId) {
        return mDB.getFeed(feedId);
    }

    public Feed getFeedByItemId(long itemId) {
        return mDB.getFeedByItemId(itemId);
    }

    public void htmlUnescapeInto(StringBuilder source, StringBuilder dest) {
        boolean inEntity = false;
        StringBuilder entity = new StringBuilder(10);
        int sourceLength = source.length();
        for (int i = 0; i < sourceLength; ++i) {
            char c = source.charAt(i);

            if (inEntity) {
                if (c == ';') {
                    // first see if this is a special sequence
                    SpecialEntity special = org.htmlcleaner.SpecialEntity.getEntity(entity.toString());
                    if (special != null) {
                        dest.append(special.getCharacter());
                    }
                    // next try hex starting with #x
                    else if (entity.length() > 1 && entity.charAt(0) == '#' && entity.charAt(1) == 'x') {
                        int value = 0;
                        for (int j = 2; j < entity.length(); ++j) {
                            value *= 16;
                            char e = entity.charAt(j);
                            if (e >= '0' && e <= '9') {
                                value += (int) (e - '0');
                            } else if (e >= 'a' && e <= 'f') {
                                value += 10 + (int) (e - 'a');
                            } else if (e >= 'A' && e <= 'F') {
                                value += 10 + (int) (e - 'A');
                            } else {
                                value = 0;
                                break;
                            }
                        }

                        if (value != 0) {
                            dest.append((char) value);
                        }
                    }
                    // next try decimal starting with #
                    else if (entity.length() > 0 && entity.charAt(0) == '#') {
                        int value = 0;
                        for (int j = 1; j < entity.length(); ++j) {
                            value *= 10;
                            char e = entity.charAt(j);
                            if (e >= '0' && e <= '9') {
                                value += (int) (e - '0');
                            } else {
                                value = 0;
                                break;
                            }
                        }

                        if (value != 0) {
                            dest.append((char) value);
                        }
                    }
                    inEntity = false;
                    entity.setLength(0);
                } else {
                    entity.append(c);
                }
            } else {
                if (c == '&') {
                    inEntity = true;
                } else {
                    dest.append(c);
                }
            }
        }
    }

    public boolean addFeed(URL url, String name, int feedType) {
        boolean success = false;

        Feed feed = new Feed(feedType);
        feed.mURL = url.toExternalForm();

        FeedStatus status = new FeedStatus();
        ArrayList<FeedItem> items = new ArrayList<FeedItem>();
        ArrayList<Enclosure> enclosures = new ArrayList<Enclosure>();

        // TODO - this should be merged with the feed updater
        // TODO - this should actually check for success
        downloadFeed(feed, status, items, enclosures);

        if (name != null) {
            feed.mName = name;
        }

        if (items.size() > 0 && feed.mName.length() > 0) {
            mLog.w("feed downloaded, adding to db");
            if (mDB.addFeed(feed)) {
                status.mFeedId = feed.mId;

                mDB.updateFeedStatus(status);

                mLog.d("status updated, adding " + items.size() + " items");

                for (FeedItem item : items) {
                    item.mFeedId = feed.mId;

                    if (item.mCleanDescription.length() > 0) {
                        item.mCleanDescription = cleanDescription(item.mCleanDescription);
                    } else {
                        item.mCleanDescription = cleanDescription(item.mDescription);
                    }

                    item.mCleanTitle = cleanDescription(item.mTitle);
                    item.mCleanDescription = removeTitleFromDescription(item.mCleanDescription, item.mCleanTitle);

                    if (mLog.d())
                        mLog.d("adding item " + item.mCleanTitle + " enclosure " + item.mEnclosureURL);

                    mDB.updateFeedItem(item);

                    if (item.mEnclosure != null) {
                        item.mEnclosure.mItemId = item.mId;
                    }
                }

                for (Enclosure enclosure : enclosures) {
                    mDB.updateEnclosure(enclosure);
                }

                updateFeedImage(feed);

                success = true;
            }
        }

        return success;
    }

    public FeedDBAdaptor getDBAdaptor() {
        return mDB;
    }

    public Enclosure getEnclosure(String url) {
        return mDB.getEnclosure(url);
    }

    public Enclosure getEnclosure(FeedItem item) {
        Enclosure enclosure = mDB.getEnclosureFromItemId(item.mId);

        if (enclosure != null) {
            item.mEnclosure = enclosure;
        }

        return enclosure;
    }

    public boolean createFile(Enclosure enclosure) {
        try {
            // track down the feed it belongs to
            String feedName = null;

            FeedItem item = mDB.getFeedItem(enclosure.mItemId);
            if (item != null) {
                Feed feed = mDB.getFeed(item.mFeedId);
                if (feed != null) {
                    feedName = feed.mName;
                }
            }

            if (feedName == null) {
                mLog.e("unabled to find feed for enclosure " + enclosure.mURL);
                return false;
            }

            URL url = new URL(enclosure.mURL);

            String[] parts = url.getPath().split("/");
            String filename = url.getPath();
            if (parts.length > 0) {
                filename = parts[parts.length - 1];
            }

            // TODO - sanitise file name

            String destinationPath = Environment.getExternalStorageDirectory().getAbsolutePath() + File.separator
                    + "Podcasts" + File.separator + feedName + File.separator + filename;

            File destination = new File(destinationPath);

            for (int i = 1; i <= 100; ++i) {
                if (destination.exists()) {
                    destination = new File(destinationPath + "." + i);
                } else {
                    break;
                }
            }

            mLog.e("downloading " + url.toExternalForm() + " to " + destination);

            File parentFile = destination.getParentFile();
            if (!parentFile.isDirectory()) {
                parentFile.mkdirs();

                mLog.e("Tried to create parent directory " + parentFile);
            }

            boolean success = false;

            try {
                success = destination.createNewFile();
            } catch (IOException exc) {
                mLog.e("Error creating destination file " + destination + " for download", exc);
            }

            if (success) {
                enclosure.mDownloadPath = destination.getAbsolutePath();
            }

            return success;
        } catch (MalformedURLException exc) {
            mLog.e("beginDownload - error parsing url", exc);
        }
        return false;
    }

    public Enclosure getEnclosure(long enclosureId) {
        return mDB.getEnclosure(enclosureId);
    }

    public boolean updateEnclosure(Enclosure enclosure) {
        return mDB.updateEnclosure(enclosure);
    }

    public HashMap<Long, FeedEnclosureInfo> getFeedEnclosureInfo(String enclosureType) {
        return mDB.getFeedEnclosureInfo(enclosureType);
    }

    public HashMap<Long, FeedEnclosureInfo> getFeedsWithoutEnclosuresInfo() {
        return mDB.getFeedsWithoutEnclosuresInfo();
    }

    public ArrayList<Feed> getFeeds() {
        return mDB.getFeeds();
    }

    public ArrayList<FeedItemEnclosureInfo> getFeedItemEnclosureInfo(long feedId, String enclosureType) {
        return mDB.getFeedItemEnclosureInfo(feedId, enclosureType);
    }

    @SuppressWarnings("unchecked")
    public synchronized ArrayList<Download> getDownloads() {
        return (ArrayList<Download>) mDownloads.clone();
    }

    public synchronized boolean isDownloading(Enclosure enclosure) {
        for (Download download : mDownloads) {
            if (download.mEnclosureId == enclosure.mId) {
                return true;
            }
        }
        return false;
    }

    public synchronized void addDownload(FeedItem item, Enclosure enclosure) {
        if (isDownloading(enclosure)) {
            return;
        }

        Download download = new Download();

        download.mId = mDB.addDownload(enclosure.mId);
        download.mEnclosureId = enclosure.mId;
        download.mName = item.mCleanTitle;

        download.mSize = enclosure.mLength;
        download.mDownloaded = 0;
        download.mInProgress = false;
        download.mCancelled = false;

        mDownloads.add(download);
    }

    private synchronized void loadDownloads() {
        ArrayList<Download> downloads = mDB.getAllDownloads();

        for (Download download : downloads) {
            Enclosure enclosure = mDB.getEnclosure(download.mEnclosureId);

            if (enclosure == null) {
                continue;
            }

            FeedItem item = mDB.getFeedItem(enclosure.mItemId);

            if (item == null) {
                continue;
            }

            download.mName = item.mCleanTitle;
            download.mSize = enclosure.mLength;
            download.mDownloaded = 0;
            download.mInProgress = false;

            mDownloads.add(download);
        }
    }

    public synchronized void downloadComplete(Download download) {
        mDB.deleteDownload(download.mId);
        mDownloads.remove(download);
    }

    public synchronized boolean deleteDownload(Download download, Enclosure enclosure) {
        File f = new File(enclosure.mDownloadPath);
        boolean deleted = f.delete();
        mDB.deleteDownload(download.mId);
        mDownloads.remove(download);

        return deleted;
    }

    public void deleteFeed(Feed feed, boolean deleteDownloads) {
        if (feed == null) {
            return;
        }

        ArrayList<ShortFeedItem> items = mDB.getShortFeedItems(feed.mId, null, true);
        for (ShortFeedItem item : items) {
            Enclosure enclosure = mDB.getEnclosureFromItemId(item.mId);

            if (enclosure == null) {
                continue;
            }

            for (Download download : mDownloads) {
                if (download.mEnclosureId == enclosure.mId) {
                    download.mCancelled = true;
                }
            }

            if (enclosure.mDownloadPath.length() > 0 && deleteDownloads) {
                File f = new File(enclosure.mDownloadPath);
                boolean deleted = f.delete();
                if (!deleted)
                    mLog.e("failed to delete downloaded enclosure " + enclosure.mDownloadPath);

                // attempt to delete enclosing folder - don't care about result
                f.getParentFile().delete();
            }

            mDB.deleteDownloadByEnclosure(enclosure.mId);
            mDB.deleteEnclosure(enclosure.mId);
        }

        mDB.deleteFeed(feed.mId);
    }

    public boolean deleteFeedItem(FeedItem item) {
        item.mFlags = item.mFlags | FeedItem.FLAG_DELETED;
        item.mDescription = "";
        item.mCleanDescription = "";
        boolean success = mDB.updateFeedItem(item);

        Enclosure enclosure = getEnclosure(item);

        if (enclosure != null) {
            mDB.deleteEnclosure(enclosure.mId);
            mDB.deleteDownloadByEnclosure(enclosure.mId);

            if (enclosure.mDownloadPath.length() > 0) {
                File f = new File(enclosure.mDownloadPath);
                boolean deleted = f.delete();

                if (!deleted)
                    mLog.e("failed to delete downloaded enclosure " + enclosure.mDownloadPath);
            }
        }

        if (!success)
            mLog.e("failed to delete item id " + item.mId);

        return success;
    }

    public void deleteDownloadedEnclosure(Enclosure enclosure) {
        mDB.deleteDownloadByEnclosure(enclosure.mId);
        if (enclosure.mDownloadPath.length() > 0) {
            File f = new File(enclosure.mDownloadPath);
            boolean deleted = f.delete();

            if (!deleted)
                mLog.e("failed to delete downloaded enclosure " + enclosure.mDownloadPath);
        }

        enclosure.mDownloadPath = "";
        enclosure.mDownloadTime = 0;
        mDB.updateEnclosure(enclosure);
    }

    public void deleteFeedItemsRead(long feedId) {
        long startTime = System.currentTimeMillis();

        ArrayList<ShortFeedItem> items = mDB.getShortFeedItems(feedId, null, false);

        mDB.mDb.beginTransaction();
        try {
            for (ShortFeedItem item : items) {
                if ((item.mFlags & FeedItem.FLAG_DELETED) == 0 && (item.mFlags & FeedItem.FLAG_READ) != 0
                        && (item.mFlags & FeedItem.FLAG_STARRED) == 0) {
                    FeedItem fullItem = getItemById(item.mId);
                    deleteFeedItem(fullItem);
                }
            }
            mDB.mDb.setTransactionSuccessful();
        } finally {
            mDB.mDb.endTransaction();
        }

        if (mLog.d())
            mLog.d("deleteFeedItemsRead " + items.size() + " took " + (System.currentTimeMillis() - startTime));
    }

    public void setFeedItemsRead(long feedId) {
        mDB.setFeedItemsRead(feedId);
    }

    public void verifyEnclosure(Enclosure enclosure) {
        boolean changed = false;

        if (enclosure.mDownloadTime > 0) {
            File f = new File(enclosure.mDownloadPath);
            if (!f.exists()) {
                enclosure.mDownloadTime = 0;
                changed = true;
            }
        }

        if (changed) {
            updateEnclosure(enclosure);
        }
    }

    public FeedConfig getFeedConfig() {
        return mFeedConfig;
    }

    public Feed getLocalFeed() {
        Feed localFeed = mDB.getFeedByURL(Feed.SCHEME_LOCAL + ":/readlater");
        if (localFeed == null) {
            localFeed = new Feed(Feed.TYPE_NEWS);
            localFeed.mName = "Local Bookmarks";
            localFeed.mURL = Feed.SCHEME_LOCAL + ":/readlater";
            if (mDB.addFeed(localFeed)) {
                FeedSettings feedSettings = new FeedSettings();
                feedSettings.mFeedId = localFeed.mId;
                feedSettings.mCacheFullArticle = true;
                feedSettings.mDisplayFullArticle = true;
                feedSettings.mCacheImages = true;
                feedSettings.mTextify = true;
                feedSettings.mUpdateAutomatically = true;
                mDB.updateFeedSettings(feedSettings);
            }
        }
        return localFeed;
    }

    public void addLocalBookmark(String url) {
        Feed localFeed = getLocalFeed();
        FeedItem item = new FeedItem();
        item.mFeedId = localFeed.mId;
        item.mLink = url;
        item.mOriginalLink = url;
        item.mPubDate = new Date();
        item.mCleanTitle = url;
        item.mTitle = url;

        // TODO - add this article to the download queue and flesh out some details

        mDB.updateFeedItem(item);
    }

    public FeedSettings getFeedSettings(long feedId) {
        FeedSettings feedSettings = mDB.getFeedSettings(feedId);

        if (feedSettings == null) {
            feedSettings = new FeedSettings();
            feedSettings.mFeedId = feedId;
            feedSettings.mCacheFullArticle = true;
            feedSettings.mDisplayFullArticle = false;
            feedSettings.mCacheImages = true;
            feedSettings.mTextify = false;
            feedSettings.mUpdateAutomatically = true;
        }
        return feedSettings;
    }

    public void updateFeedSettings(FeedSettings mFeedSettings) {
        mDB.updateFeedSettings(mFeedSettings);
    }

    public String exportOPML() {
        try {
            ByteArrayOutputStream os = new ByteArrayOutputStream(16 * 1024);
            XmlPullParserFactory parserFactory = XmlPullParserFactory.newInstance();
            XmlSerializer serializer = parserFactory.newSerializer();
            serializer.setOutput(os, "UTF-8");

            serializer.startDocument("UTF-8", true);

            serializer.startTag(null, "opml");
            serializer.attribute(null, "version", "1.0");
            serializer.startTag(null, "head");
            serializer.startTag(null, "title");
            serializer.text("FeedScribe Export");
            serializer.endTag(null, "title");
            serializer.endTag(null, "head");

            serializer.startTag(null, "body");

            ArrayList<Feed> feeds = mDB.getFeeds();

            for (Feed feed : feeds) {
                serializer.startTag(null, "outline");
                serializer.attribute(null, "text", feed.mName);
                serializer.attribute(null, "title", feed.mName);
                serializer.attribute(null, "type", "rss");
                serializer.attribute(null, "xmlUrl", feed.mURL);

                if (feed.mLink != null && feed.mLink.length() > 0) {
                    serializer.attribute(null, "htmlUrl", feed.mLink);
                }

                serializer.endTag(null, "outline");
            }
            serializer.endTag(null, "body");
            serializer.endTag(null, "opml");

            serializer.endDocument();

            return os.toString();
        } catch (IOException exc) {
            mLog.e("FeedManager.exportOPML", exc);
        } catch (XmlPullParserException exc) {
            mLog.e("FeedManager.exportOPML", exc);
        }
        return null;
    }

    /**
     * @return -1 for failure, otherwise number of items imported
     */
    public int importOPML(String data) {
        try {
            XmlPullParserFactory parserFactory = XmlPullParserFactory.newInstance();
            XmlPullParser parser = parserFactory.newPullParser();
            parser.setInput(new StringReader(data));
            return importOPML(parser);
        } catch (XmlPullParserException e) {
            mLog.e("importOPML", e);
        }
        return -1;
    }

    /**
     * @return -1 for failure, otherwise number of items imported
     */
    public int importOPML(Reader reader) {
        try {
            XmlPullParserFactory parserFactory = XmlPullParserFactory.newInstance();
            XmlPullParser parser = parserFactory.newPullParser();
            parser.setInput(reader);
            return importOPML(parser);
        } catch (XmlPullParserException e) {
            mLog.e("importOPML", e);
        }
        return -1;
    }

    /**
     * 
     * @param parser
     * @return -1 for failure, otherwise number of items imported
     */
    protected int importOPML(XmlPullParser parser) {
        try {
            int eventType = parser.getEventType();

            boolean isOPML = false;
            boolean inBody = false;
            int numAdded = 0;

            while (eventType != XmlPullParser.END_DOCUMENT) {
                if (eventType == XmlPullParser.START_DOCUMENT) {

                } else if (eventType == XmlPullParser.START_TAG) {
                    String tag = parser.getName();
                    if ("opml".equals(tag) && "1.0".equals(parser.getAttributeValue(null, "version"))) {
                        isOPML = true;
                    } else if ("body".equals(tag) && isOPML) {
                        inBody = true;
                    } else if ("outline".equals(tag) && inBody
                            && "rss".equals(parser.getAttributeValue(null, "type"))) {
                        String name = parser.getAttributeValue(null, "title");
                        if (name == null) {
                            name = parser.getAttributeValue(null, "text");
                        }

                        String url = parser.getAttributeValue(null, "xmlUrl");

                        if (name != null && url != null) {
                            url = url.toLowerCase(Locale.US);

                            mLog.w("checking for existing feed name " + name + " url " + url);

                            // make sure we don't duplicate any urls
                            boolean found = false;
                            ArrayList<Feed> feeds = getFeeds();
                            for (Feed feed : feeds) {
                                if (feed.mURL.equals(url)) {
                                    found = true;
                                }
                            }

                            if (!found) {
                                try {
                                    mLog.w("adding feed name " + name + " url " + url);
                                    URL realURL = new URL(url);
                                    addFeed(realURL, name, Feed.TYPE_PODCAST);
                                    numAdded += 1;
                                } catch (MalformedURLException exc) {
                                    mLog.e("importOPML", exc);
                                }
                            }
                        }
                    }
                } else if (eventType == XmlPullParser.END_TAG) {
                    String tag = parser.getName();
                    if ("body".equals(tag)) {
                        inBody = false;
                    }
                }

                eventType = parser.next();
            }

            if (isOPML) {
                return numAdded;
            } else {
                return -1;
            }
        } catch (XmlPullParserException e) {
            mLog.e("importOPML", e);
        } catch (IOException e) {
            mLog.e("importOPML", e);
        }
        return -1;
    }

    public boolean setFeedName(long feedId, String newName) {
        return mDB.setFeedName(feedId, newName);
    }
}