package de.danoeh.antennapodsp.storage;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.preference.PreferenceManager;
import android.util.Log;
import de.danoeh.antennapodsp.AppConfig;
import de.danoeh.antennapodsp.feed.*;
import de.danoeh.antennapodsp.preferences.PlaybackPreferences;
import de.danoeh.antennapodsp.service.download.DownloadStatus;
import de.danoeh.antennapodsp.service.playback.PlaybackService;
import de.danoeh.antennapodsp.util.QueueAccess;
import java.io.File;
import java.util.Date;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadFactory;
/**
* Provides methods for writing data to AntennaPod's database.
* In general, DBWriter-methods will be executed on an internal ExecutorService.
* Some methods return a Future-object which the caller can use for waiting for the method's completion. The returned Future's
* will NOT contain any results.
* The caller can also use the {@link EventDistributor} in order to be notified about the method's completion asynchronously.
* This class will use the {@link EventDistributor} to notify listeners about changes in the database.
*/
public class DBWriter {
private static final String TAG = "DBWriter";
private static final ExecutorService dbExec;
static {
dbExec = Executors.newSingleThreadExecutor(new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r);
t.setPriority(Thread.MIN_PRIORITY);
return t;
}
});
}
private DBWriter() {
}
/**
* Deletes a downloaded FeedMedia file from the storage device.
*
* @param context A context that is used for opening a database connection.
* @param mediaId ID of the FeedMedia object whose downloaded file should be deleted.
*/
public static Future<?> deleteFeedMediaOfItem(final Context context,
final long mediaId) {
return dbExec.submit(new Runnable() {
@Override
public void run() {
final FeedMedia media = DBReader.getFeedMedia(context, mediaId);
if (media != null) {
Log.i(TAG, String.format("Requested to delete FeedMedia [id=%d, title=%s, downloaded=%s",
media.getId(), media.getEpisodeTitle(), String.valueOf(media.isDownloaded())));
boolean result = false;
if (media.isDownloaded()) {
// delete downloaded media file
File mediaFile = new File(media.getFile_url());
if (mediaFile.exists()) {
result = mediaFile.delete();
}
media.setDownloaded(false);
media.setFile_url(null);
PodDBAdapter adapter = new PodDBAdapter(context);
adapter.open();
adapter.setMedia(media);
adapter.close();
// If media is currently being played, change playback
// type to 'stream' and shutdown playback service
SharedPreferences prefs = PreferenceManager
.getDefaultSharedPreferences(context);
if (PlaybackPreferences.getCurrentlyPlayingMedia() == FeedMedia.PLAYABLE_TYPE_FEEDMEDIA) {
if (media.getId() == PlaybackPreferences
.getCurrentlyPlayingFeedMediaId()) {
SharedPreferences.Editor editor = prefs.edit();
editor.putBoolean(
PlaybackPreferences.PREF_CURRENT_EPISODE_IS_STREAM,
true);
editor.commit();
}
if (PlaybackPreferences
.getCurrentlyPlayingFeedMediaId() == media
.getId()) {
context.sendBroadcast(new Intent(
PlaybackService.ACTION_SHUTDOWN_PLAYBACK_SERVICE));
}
}
}
if (AppConfig.DEBUG)
Log.d(TAG, "Deleting File. Result: " + result);
EventDistributor.getInstance().sendQueueUpdateBroadcast();
EventDistributor.getInstance().sendUnreadItemsUpdateBroadcast();
}
}
});
}
/**
* Deletes a Feed and all downloaded files of its components like images and downloaded episodes.
*
* @param context A context that is used for opening a database connection.
* @param feedId ID of the Feed that should be deleted.
*/
public static Future<?> deleteFeed(final Context context, final long feedId) {
return dbExec.submit(new Runnable() {
@Override
public void run() {
DownloadRequester requester = DownloadRequester.getInstance();
SharedPreferences prefs = PreferenceManager
.getDefaultSharedPreferences(context
.getApplicationContext());
final Feed feed = DBReader.getFeed(context, feedId);
if (feed != null) {
if (PlaybackPreferences.getCurrentlyPlayingMedia() == FeedMedia.PLAYABLE_TYPE_FEEDMEDIA
&& PlaybackPreferences.getLastPlayedFeedId() == feed
.getId()) {
context.sendBroadcast(new Intent(
PlaybackService.ACTION_SHUTDOWN_PLAYBACK_SERVICE));
SharedPreferences.Editor editor = prefs.edit();
editor.putLong(
PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEED_ID,
-1);
editor.commit();
}
// delete image file
if (feed.getImage() != null) {
if (feed.getImage().isDownloaded()
&& feed.getImage().getFile_url() != null) {
File imageFile = new File(feed.getImage()
.getFile_url());
imageFile.delete();
} else if (requester.isDownloadingFile(feed.getImage())) {
requester.cancelDownload(context, feed.getImage());
}
}
// delete stored media files and mark them as read
List<FeedItem> queue = DBReader.getQueue(context);
boolean queueWasModified = false;
if (feed.getItems() == null) {
DBReader.getFeedItemList(context, feed);
}
for (FeedItem item : feed.getItems()) {
queueWasModified |= queue.remove(item);
if (item.getMedia() != null
&& item.getMedia().isDownloaded()) {
File mediaFile = new File(item.getMedia()
.getFile_url());
mediaFile.delete();
} else if (item.getMedia() != null
&& requester.isDownloadingFile(item.getMedia())) {
requester.cancelDownload(context, item.getMedia());
}
if (item.isItemImage()) {
FeedImage image = item.getImage();
if (image.isDownloaded() && image.getFile_url() != null) {
File imgFile = new File(image.getFile_url());
imgFile.delete();
} else if (requester.isDownloadingFile(image)) {
requester.cancelDownload(context, item.getImage());
}
}
}
PodDBAdapter adapter = new PodDBAdapter(context);
adapter.open();
if (queueWasModified) {
adapter.setQueue(queue);
}
adapter.removeFeed(feed);
adapter.close();
EventDistributor.getInstance().sendFeedUpdateBroadcast();
}
}
});
}
/**
* Deletes the entire playback history.
*
* @param context A context that is used for opening a database connection.
*/
public static Future<?> clearPlaybackHistory(final Context context) {
return dbExec.submit(new Runnable() {
@Override
public void run() {
PodDBAdapter adapter = new PodDBAdapter(context);
adapter.open();
adapter.clearPlaybackHistory();
adapter.close();
EventDistributor.getInstance()
.sendPlaybackHistoryUpdateBroadcast();
}
});
}
/**
* Adds a FeedMedia object to the playback history. A FeedMedia object is in the playback history if
* its playback completion date is set to a non-null value. This method will set the playback completion date to the
* current date regardless of the current value.
*
* @param context A context that is used for opening a database connection.
* @param media FeedMedia that should be added to the playback history.
*/
public static Future<?> addItemToPlaybackHistory(final Context context,
final FeedMedia media) {
return dbExec.submit(new Runnable() {
@Override
public void run() {
if (AppConfig.DEBUG)
Log.d(TAG, "Adding new item to playback history");
media.setPlaybackCompletionDate(new Date());
PodDBAdapter adapter = new PodDBAdapter(context);
adapter.open();
adapter.setFeedMediaPlaybackCompletionDate(media);
adapter.close();
EventDistributor.getInstance().sendPlaybackHistoryUpdateBroadcast();
}
});
}
private static void cleanupDownloadLog(final PodDBAdapter adapter) {
final long logSize = adapter.getDownloadLogSize();
if (logSize > DBReader.DOWNLOAD_LOG_SIZE) {
if (AppConfig.DEBUG)
Log.d(TAG, "Cleaning up download log");
adapter.removeDownloadLogItems(logSize - DBReader.DOWNLOAD_LOG_SIZE);
}
}
/**
* Adds a Download status object to the download log.
*
* @param context A context that is used for opening a database connection.
* @param status The DownloadStatus object.
*/
public static Future<?> addDownloadStatus(final Context context,
final DownloadStatus status) {
return dbExec.submit(new Runnable() {
@Override
public void run() {
PodDBAdapter adapter = new PodDBAdapter(context);
adapter.open();
adapter.setDownloadStatus(status);
adapter.close();
EventDistributor.getInstance().sendDownloadLogUpdateBroadcast();
}
});
}
/**
* Inserts a FeedItem in the queue at the specified index. The 'read'-attribute of the FeedItem will be set to
* true. If the FeedItem is already in the queue, the queue will not be modified.
*
* @param context A context that is used for opening a database connection.
* @param itemId ID of the FeedItem that should be added to the queue.
* @param index Destination index. Must be in range 0..queue.size()
* @param performAutoDownload True if an auto-download process should be started after the operation
* @throws IndexOutOfBoundsException if index < 0 || index >= queue.size()
*/
public static Future<?> addQueueItemAt(final Context context, final long itemId,
final int index, final boolean performAutoDownload) {
return dbExec.submit(new Runnable() {
@Override
public void run() {
final PodDBAdapter adapter = new PodDBAdapter(context);
adapter.open();
final List<FeedItem> queue = DBReader
.getQueue(context, adapter);
FeedItem item = null;
if (queue != null) {
boolean queueModified = false;
boolean unreadItemsModified = false;
if (!itemListContains(queue, itemId)) {
item = DBReader.getFeedItem(context, itemId);
if (item != null) {
queue.add(index, item);
queueModified = true;
if (!item.isRead()) {
item.setRead(true);
unreadItemsModified = true;
}
}
}
if (queueModified) {
adapter.setQueue(queue);
EventDistributor.getInstance()
.sendQueueUpdateBroadcast();
}
if (unreadItemsModified && item != null) {
adapter.setSingleFeedItem(item);
EventDistributor.getInstance()
.sendUnreadItemsUpdateBroadcast();
}
}
adapter.close();
if (performAutoDownload) {
DBTasks.autodownloadUndownloadedItems(context);
}
}
});
}
/**
* Appends FeedItem objects to the end of the queue. The 'read'-attribute of all items will be set to true.
* If a FeedItem is already in the queue, the FeedItem will not change its position in the queue.
*
* @param context A context that is used for opening a database connection.
* @param itemIds IDs of the FeedItem objects that should be added to the queue.
*/
public static Future<?> addQueueItem(final Context context,
final long... itemIds) {
return dbExec.submit(new Runnable() {
@Override
public void run() {
if (itemIds.length > 0) {
final PodDBAdapter adapter = new PodDBAdapter(context);
adapter.open();
final List<FeedItem> queue = DBReader.getQueue(context,
adapter);
if (queue != null) {
boolean queueModified = false;
boolean unreadItemsModified = false;
List<FeedItem> itemsToSave = new LinkedList<FeedItem>();
for (int i = 0; i < itemIds.length; i++) {
if (!itemListContains(queue, itemIds[i])) {
final FeedItem item = DBReader.getFeedItem(
context, itemIds[i]);
if (item != null) {
queue.add(item);
queueModified = true;
if (!item.isRead()) {
item.setRead(true);
itemsToSave.add(item);
unreadItemsModified = true;
}
}
}
}
if (queueModified) {
adapter.setQueue(queue);
EventDistributor.getInstance()
.sendQueueUpdateBroadcast();
}
if (unreadItemsModified) {
adapter.setFeedItemlist(itemsToSave);
EventDistributor.getInstance()
.sendUnreadItemsUpdateBroadcast();
}
}
adapter.close();
DBTasks.autodownloadUndownloadedItems(context);
}
}
});
}
/**
* Removes all FeedItem objects from the queue.
*
* @param context A context that is used for opening a database connection.
*/
public static Future<?> clearQueue(final Context context) {
return dbExec.submit(new Runnable() {
@Override
public void run() {
PodDBAdapter adapter = new PodDBAdapter(context);
adapter.open();
adapter.clearQueue();
adapter.close();
EventDistributor.getInstance().sendQueueUpdateBroadcast();
}
});
}
/**
* Removes a FeedItem object from the queue.
*
* @param context A context that is used for opening a database connection.
* @param itemId ID of the FeedItem that should be removed.
* @param performAutoDownload true if an auto-download process should be started after the operation.
*/
public static Future<?> removeQueueItem(final Context context,
final long itemId, final boolean performAutoDownload) {
return dbExec.submit(new Runnable() {
@Override
public void run() {
final PodDBAdapter adapter = new PodDBAdapter(context);
adapter.open();
final List<FeedItem> queue = DBReader
.getQueue(context, adapter);
FeedItem item = null;
if (queue != null) {
boolean queueModified = false;
QueueAccess queueAccess = QueueAccess.ItemListAccess(queue);
if (queueAccess.contains(itemId)) {
item = DBReader.getFeedItem(context, itemId);
if (item != null) {
queueModified = queueAccess.remove(itemId);
}
}
if (queueModified) {
adapter.setQueue(queue);
EventDistributor.getInstance()
.sendQueueUpdateBroadcast();
} else {
Log.w(TAG, "Queue was not modified by call to removeQueueItem");
}
} else {
Log.e(TAG, "removeQueueItem: Could not load queue");
}
adapter.close();
if (performAutoDownload) {
DBTasks.autodownloadUndownloadedItems(context);
}
}
});
}
/**
* Moves the specified item to the top of the queue.
*
* @param context A context that is used for opening a database connection.
* @param itemId The item to move to the top of the queue
* @param broadcastUpdate true if this operation should trigger a QueueUpdateBroadcast. This option should be set to
* false if the caller wants to avoid unexpected updates of the GUI.
*/
public static Future<?> moveQueueItemToTop(final Context context, final long itemId, final boolean broadcastUpdate) {
return dbExec.submit(new Runnable() {
@Override
public void run() {
List<Long> queueIdList = DBReader.getQueueIDList(context);
int currentLocation = 0;
for (long id : queueIdList) {
if (id == itemId) {
moveQueueItemHelper(context, currentLocation, 0, broadcastUpdate);
return;
}
currentLocation++;
}
Log.e(TAG, "moveQueueItemToTop: item not found");
}
});
}
/**
* Moves the specified item to the bottom of the queue.
*
* @param context A context that is used for opening a database connection.
* @param itemId The item to move to the bottom of the queue
* @param broadcastUpdate true if this operation should trigger a QueueUpdateBroadcast. This option should be set to
* false if the caller wants to avoid unexpected updates of the GUI.
*/
public static Future<?> moveQueueItemToBottom(final Context context, final long itemId,
final boolean broadcastUpdate) {
return dbExec.submit(new Runnable() {
@Override
public void run() {
List<Long> queueIdList = DBReader.getQueueIDList(context);
int currentLocation = 0;
for (long id : queueIdList) {
if (id == itemId) {
moveQueueItemHelper(context, currentLocation, queueIdList.size() - 1,
broadcastUpdate);
return;
}
currentLocation++;
}
Log.e(TAG, "moveQueueItemToBottom: item not found");
}
});
}
/**
* Changes the position of a FeedItem in the queue.
*
* @param context A context that is used for opening a database connection.
* @param from Source index. Must be in range 0..queue.size()-1.
* @param to Destination index. Must be in range 0..queue.size()-1.
* @param broadcastUpdate true if this operation should trigger a QueueUpdateBroadcast. This option should be set to
* false if the caller wants to avoid unexpected updates of the GUI.
* @throws IndexOutOfBoundsException if (to < 0 || to >= queue.size()) || (from < 0 || from >= queue.size())
*/
public static Future<?> moveQueueItem(final Context context, final int from,
final int to, final boolean broadcastUpdate) {
return dbExec.submit(new Runnable() {
@Override
public void run() {
moveQueueItemHelper(context, from, to, broadcastUpdate);
}
});
}
/**
* Changes the position of a FeedItem in the queue.
* <p/>
* This function must be run using the ExecutorService (dbExec).
*
* @param context A context that is used for opening a database connection.
* @param from Source index. Must be in range 0..queue.size()-1.
* @param to Destination index. Must be in range 0..queue.size()-1.
* @param broadcastUpdate true if this operation should trigger a QueueUpdateBroadcast. This option should be set to
* false if the caller wants to avoid unexpected updates of the GUI.
* @throws IndexOutOfBoundsException if (to < 0 || to >= queue.size()) || (from < 0 || from >= queue.size())
*/
private static void moveQueueItemHelper(final Context context, final int from,
final int to, final boolean broadcastUpdate) {
final PodDBAdapter adapter = new PodDBAdapter(context);
adapter.open();
final List<FeedItem> queue = DBReader
.getQueue(context, adapter);
if (queue != null) {
if (from >= 0 && from < queue.size() && to >= 0
&& to < queue.size()) {
final FeedItem item = queue.remove(from);
queue.add(to, item);
adapter.setQueue(queue);
if (broadcastUpdate) {
EventDistributor.getInstance()
.sendQueueUpdateBroadcast();
}
}
} else {
Log.e(TAG, "moveQueueItemHelper: Could not load queue");
}
adapter.close();
}
/**
* Sets the 'read'-attribute of a FeedItem to the specified value.
*
* @param context A context that is used for opening a database connection.
* @param item The FeedItem object
* @param read New value of the 'read'-attribute
* @param resetMediaPosition true if this method should also reset the position of the FeedItem's FeedMedia object.
* If the FeedItem has no FeedMedia object, this parameter will be ignored.
*/
public static Future<?> markItemRead(Context context, FeedItem item, boolean read, boolean resetMediaPosition) {
long mediaId = (item.hasMedia()) ? item.getMedia().getId() : 0;
return markItemRead(context, item.getId(), read, mediaId, resetMediaPosition);
}
/**
* Sets the 'read'-attribute of a FeedItem to the specified value.
*
* @param context A context that is used for opening a database connection.
* @param itemId ID of the FeedItem
* @param read New value of the 'read'-attribute
*/
public static Future<?> markItemRead(final Context context, final long itemId,
final boolean read) {
return markItemRead(context, itemId, read, 0, false);
}
private static Future<?> markItemRead(final Context context, final long itemId,
final boolean read, final long mediaId,
final boolean resetMediaPosition) {
return dbExec.submit(new Runnable() {
@Override
public void run() {
final PodDBAdapter adapter = new PodDBAdapter(context);
adapter.open();
adapter.setFeedItemRead(read, itemId, mediaId,
resetMediaPosition);
adapter.close();
EventDistributor.getInstance().sendUnreadItemsUpdateBroadcast();
}
});
}
/**
* Sets the 'read'-attribute of all FeedItems of a specific Feed to true.
*
* @param context A context that is used for opening a database connection.
* @param feedId ID of the Feed.
*/
public static Future<?> markFeedRead(final Context context, final long feedId) {
return dbExec.submit(new Runnable() {
@Override
public void run() {
final PodDBAdapter adapter = new PodDBAdapter(context);
adapter.open();
Cursor itemCursor = adapter.getAllItemsOfFeedCursor(feedId);
long[] itemIds = new long[itemCursor.getCount()];
itemCursor.moveToFirst();
for (int i = 0; i < itemIds.length; i++) {
itemIds[i] = itemCursor.getLong(PodDBAdapter.KEY_ID_INDEX);
itemCursor.moveToNext();
}
itemCursor.close();
adapter.setFeedItemRead(true, itemIds);
adapter.close();
EventDistributor.getInstance().sendUnreadItemsUpdateBroadcast();
}
});
}
/**
* Sets the 'read'-attribute of all FeedItems to true.
*
* @param context A context that is used for opening a database connection.
*/
public static Future<?> markAllItemsRead(final Context context) {
return dbExec.submit(new Runnable() {
@Override
public void run() {
final PodDBAdapter adapter = new PodDBAdapter(context);
adapter.open();
Cursor itemCursor = adapter.getUnreadItemsCursor();
long[] itemIds = new long[itemCursor.getCount()];
itemCursor.moveToFirst();
for (int i = 0; i < itemIds.length; i++) {
itemIds[i] = itemCursor.getLong(PodDBAdapter.KEY_ID_INDEX);
itemCursor.moveToNext();
}
itemCursor.close();
adapter.setFeedItemRead(true, itemIds);
adapter.close();
EventDistributor.getInstance().sendUnreadItemsUpdateBroadcast();
}
});
}
static Future<?> addNewFeed(final Context context, final Feed feed) {
return dbExec.submit(new Runnable() {
@Override
public void run() {
final PodDBAdapter adapter = new PodDBAdapter(context);
adapter.open();
adapter.setCompleteFeed(feed);
adapter.close();
EventDistributor.getInstance().sendFeedUpdateBroadcast();
}
});
}
static Future<?> setCompleteFeed(final Context context, final Feed feed) {
return dbExec.submit(new Runnable() {
@Override
public void run() {
PodDBAdapter adapter = new PodDBAdapter(context);
adapter.open();
adapter.setCompleteFeed(feed);
adapter.close();
EventDistributor.getInstance().sendFeedUpdateBroadcast();
}
});
}
/**
* Saves a FeedMedia object in the database. This method will save all attributes of the FeedMedia object. The
* contents of FeedComponent-attributes (e.g. the FeedMedia's 'item'-attribute) will not be saved.
*
* @param context A context that is used for opening a database connection.
* @param media The FeedMedia object.
*/
public static Future<?> setFeedMedia(final Context context,
final FeedMedia media) {
return dbExec.submit(new Runnable() {
@Override
public void run() {
PodDBAdapter adapter = new PodDBAdapter(context);
adapter.open();
adapter.setMedia(media);
adapter.close();
}
});
}
/**
* Saves the 'position' and 'duration' attributes of a FeedMedia object
*
* @param context A context that is used for opening a database connection.
* @param media The FeedMedia object.
*/
public static Future<?> setFeedMediaPlaybackInformation(final Context context, final FeedMedia media) {
return dbExec.submit(new Runnable() {
@Override
public void run() {
PodDBAdapter adapter = new PodDBAdapter(context);
adapter.open();
adapter.setFeedMediaPlaybackInformation(media);
adapter.close();
}
});
}
/**
* Saves a FeedItem object in the database. This method will save all attributes of the FeedItem object including
* the content of FeedComponent-attributes.
*
* @param context A context that is used for opening a database connection.
* @param item The FeedItem object.
*/
public static Future<?> setFeedItem(final Context context,
final FeedItem item) {
return dbExec.submit(new Runnable() {
@Override
public void run() {
PodDBAdapter adapter = new PodDBAdapter(context);
adapter.open();
adapter.setSingleFeedItem(item);
adapter.close();
}
});
}
/**
* Saves a FeedImage object in the database. This method will save all attributes of the FeedImage object. The
* contents of FeedComponent-attributes (e.g. the FeedImages's 'feed'-attribute) will not be saved.
*
* @param context A context that is used for opening a database connection.
* @param image The FeedImage object.
*/
public static Future<?> setFeedImage(final Context context,
final FeedImage image) {
return dbExec.submit(new Runnable() {
@Override
public void run() {
PodDBAdapter adapter = new PodDBAdapter(context);
adapter.open();
adapter.setImage(image);
adapter.close();
}
});
}
/**
* Updates download URLs of feeds from a given Map. The key of the Map is the original URL of the feed
* and the value is the updated URL
*/
public static Future<?> updateFeedDownloadURLs(final Context context, final Map<String, String> urls) {
return dbExec.submit(new Runnable() {
@Override
public void run() {
PodDBAdapter adapter = new PodDBAdapter(context);
adapter.open();
for (String key : urls.keySet()) {
if (AppConfig.DEBUG) Log.d(TAG, "Replacing URL " + key + " with url " + urls.get(key));
adapter.setFeedDownloadUrl(key, urls.get(key));
}
adapter.close();
}
});
}
/**
* Saves a FeedPreferences object in the database. The Feed ID of the FeedPreferences-object MUST NOT be 0.
*
* @param context Used for opening a database connection.
* @param preferences The FeedPreferences object.
*/
public static Future<?> setFeedPreferences(final Context context, final FeedPreferences preferences) {
return dbExec.submit(new Runnable() {
@Override
public void run() {
PodDBAdapter adapter = new PodDBAdapter(context);
adapter.open();
adapter.setFeedPreferences(preferences);
adapter.close();
}
});
}
private static boolean itemListContains(List<FeedItem> items, long itemId) {
for (FeedItem item : items) {
if (item.getId() == itemId) {
return true;
}
}
return false;
}
}