diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 75db9cffa..fe16eb0e5 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -143,6 +143,11 @@ android:enabled="true"> + + + + diff --git a/res/values/ids.xml b/res/values/ids.xml index 995cebbad..5356cd119 100644 --- a/res/values/ids.xml +++ b/res/values/ids.xml @@ -17,5 +17,7 @@ + + \ No newline at end of file diff --git a/res/values/strings.xml b/res/values/strings.xml index 00cddd4d6..2aa08296f 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -195,6 +195,8 @@ Manual Login Login with your gpodder.net account in order to sync your subscriptions. + Logout + Logout was successful @@ -268,6 +270,11 @@ Start sync now Go to main screen + gpodder.net authentication error + Wrong username or password + gpodder.net sync error + An error occurred during syncing:\u0020 + Selected folder: Create folder diff --git a/res/xml/preferences.xml b/res/xml/preferences.xml index 3216b7ad8..ac3160028 100644 --- a/res/xml/preferences.xml +++ b/res/xml/preferences.xml @@ -92,6 +92,9 @@ android:summary="@string/pref_gpodnet_authenticate_sum"> + diff --git a/src/de/danoeh/antennapod/activity/PreferenceActivity.java b/src/de/danoeh/antennapod/activity/PreferenceActivity.java index 880724c28..411a10257 100644 --- a/src/de/danoeh/antennapod/activity/PreferenceActivity.java +++ b/src/de/danoeh/antennapod/activity/PreferenceActivity.java @@ -21,10 +21,12 @@ import android.util.Log; import android.view.Menu; import android.view.MenuItem; +import android.widget.Toast; import de.danoeh.antennapod.AppConfig; import de.danoeh.antennapod.R; import de.danoeh.antennapod.asynctask.FlattrClickWorker; import de.danoeh.antennapod.asynctask.OpmlExportWorker; +import de.danoeh.antennapod.preferences.GpodnetPreferences; import de.danoeh.antennapod.preferences.UserPreferences; import de.danoeh.antennapod.util.flattr.FlattrUtils; @@ -41,6 +43,8 @@ public class PreferenceActivity extends android.preference.PreferenceActivity { private static final String PREF_ABOUT = "prefAbout"; private static final String PREF_CHOOSE_DATA_DIR = "prefChooseDataDir"; private static final String AUTO_DL_PREF_SCREEN = "prefAutoDownloadSettings"; + private static final String PREF_GPODNET_LOGIN = "pref_gpodnet_authenticate"; + private static final String PREF_GPODNET_LOGOUT = "pref_gpodnet_logout"; private CheckBoxPreference[] selectedNetworks; @@ -156,11 +160,29 @@ public class PreferenceActivity extends android.preference.PreferenceActivity { return true; } }); + findPreference(PREF_GPODNET_LOGOUT).setOnPreferenceClickListener(new OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + GpodnetPreferences.logout(); + Toast toast = Toast.makeText(PreferenceActivity.this, R.string.pref_gpodnet_logout_toast, Toast.LENGTH_SHORT); + toast.show(); + updateGpodnetPreferenceScreen(); + return true; + } + }); + updateGpodnetPreferenceScreen(); buildUpdateIntervalPreference(); buildAutodownloadSelectedNetworsPreference(); setSelectedNetworksEnabled(UserPreferences .isEnableAutodownloadWifiFilter()); + + } + + private void updateGpodnetPreferenceScreen() { + final boolean loggedIn = GpodnetPreferences.loggedIn(); + findPreference(PREF_GPODNET_LOGIN).setEnabled(!loggedIn); + findPreference(PREF_GPODNET_LOGOUT).setEnabled(loggedIn); } private void buildUpdateIntervalPreference() { diff --git a/src/de/danoeh/antennapod/activity/gpoddernet/GpodnetAuthenticationActivity.java b/src/de/danoeh/antennapod/activity/gpoddernet/GpodnetAuthenticationActivity.java index 081ef5cf0..74695f38c 100644 --- a/src/de/danoeh/antennapod/activity/gpoddernet/GpodnetAuthenticationActivity.java +++ b/src/de/danoeh/antennapod/activity/gpoddernet/GpodnetAuthenticationActivity.java @@ -17,6 +17,7 @@ import de.danoeh.antennapod.gpoddernet.GpodnetServiceException; import de.danoeh.antennapod.gpoddernet.model.GpodnetDevice; import de.danoeh.antennapod.preferences.GpodnetPreferences; import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.service.GpodnetSyncService; import java.util.ArrayList; import java.util.List; @@ -280,13 +281,19 @@ public class GpodnetAuthenticationActivity extends ActionBarActivity { final Button sync = (Button) view.findViewById(R.id.butSyncNow); final Button back = (Button) view.findViewById(R.id.butGoMainscreen); + sync.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + GpodnetSyncService.sendSyncIntent(GpodnetAuthenticationActivity.this); + finish(); + } + }); back.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { Intent intent = new Intent(GpodnetAuthenticationActivity.this, MainActivity.class); intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); startActivity(intent); - } }); } diff --git a/src/de/danoeh/antennapod/preferences/GpodnetPreferences.java b/src/de/danoeh/antennapod/preferences/GpodnetPreferences.java index 6c4d7b41c..0a9984137 100644 --- a/src/de/danoeh/antennapod/preferences/GpodnetPreferences.java +++ b/src/de/danoeh/antennapod/preferences/GpodnetPreferences.java @@ -16,10 +16,17 @@ public class GpodnetPreferences { public static final String PREF_GPODNET_PASSWORD = "de.danoeh.antennapod.preferences.gpoddernet.password"; public static final String PREF_GPODNET_DEVICEID = "de.danoeh.antennapod.preferences.gpoddernet.deviceID"; + public static final String PREF_LAST_SYNC_TIMESTAMP = "de.danoeh.antennapod.preferences.gpoddernet.last_sync_timestamp"; + private static String username; private static String password; private static String deviceID; + /** + * Last value returned by getSubscriptionChanges call. Will be used for all subsequent calls of getSubscriptionChanges. + */ + private static long lastSyncTimestamp; + private static boolean preferencesLoaded = false; private static SharedPreferences getPreferences() { @@ -32,7 +39,7 @@ public class GpodnetPreferences { username = prefs.getString(PREF_GPODNET_USERNAME, null); password = prefs.getString(PREF_GPODNET_PASSWORD, null); deviceID = prefs.getString(PREF_GPODNET_DEVICEID, null); - + lastSyncTimestamp = prefs.getLong(PREF_LAST_SYNC_TIMESTAMP, 0); preferencesLoaded = true; } } @@ -43,6 +50,11 @@ public class GpodnetPreferences { editor.commit(); } + private static void writePreference(String key, long value) { + SharedPreferences.Editor editor = getPreferences().edit(); + editor.putLong(key, value); + editor.commit(); + } public static String getUsername() { ensurePreferencesLoaded(); @@ -73,4 +85,29 @@ public class GpodnetPreferences { GpodnetPreferences.deviceID = deviceID; writePreference(PREF_GPODNET_DEVICEID, deviceID); } + + public static long getLastSyncTimestamp() { + ensurePreferencesLoaded(); + return lastSyncTimestamp; + } + + public static void setLastSyncTimestamp(long lastSyncTimestamp) { + GpodnetPreferences.lastSyncTimestamp = lastSyncTimestamp; + writePreference(PREF_LAST_SYNC_TIMESTAMP, lastSyncTimestamp); + } + + /** + * Returns true if device ID, username and password have a non-null value + */ + public static boolean loggedIn() { + ensurePreferencesLoaded(); + return deviceID != null && username != null && password != null; + } + + public static void logout() { + setUsername(null); + setPassword(null); + setDeviceID(null); + setLastSyncTimestamp(0); + } } diff --git a/src/de/danoeh/antennapod/service/GpodnetSyncService.java b/src/de/danoeh/antennapod/service/GpodnetSyncService.java new file mode 100644 index 000000000..585b8919b --- /dev/null +++ b/src/de/danoeh/antennapod/service/GpodnetSyncService.java @@ -0,0 +1,257 @@ +package de.danoeh.antennapod.service; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.IBinder; +import android.support.v4.app.NotificationCompat; +import android.util.Log; +import de.danoeh.antennapod.AppConfig; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.feed.Feed; +import de.danoeh.antennapod.gpoddernet.GpodnetService; +import de.danoeh.antennapod.gpoddernet.GpodnetServiceAuthenticationException; +import de.danoeh.antennapod.gpoddernet.GpodnetServiceException; +import de.danoeh.antennapod.gpoddernet.model.GpodnetSubscriptionChange; +import de.danoeh.antennapod.preferences.GpodnetPreferences; +import de.danoeh.antennapod.storage.DBReader; +import de.danoeh.antennapod.storage.DBTasks; +import de.danoeh.antennapod.storage.DownloadRequestException; +import de.danoeh.antennapod.storage.DownloadRequester; +import de.danoeh.antennapod.util.NetworkUtils; + +import java.util.Date; +import java.util.List; + +/** + * Synchronizes local subscriptions with gpodder.net service. The service should be started with an ACTION_UPLOAD_CHANGES, + * ACTION_DOWNLOAD_CHANGES or ACTION_SYNC as an action argument. This class also provides static methods for starting the GpodnetSyncService. + */ +public class GpodnetSyncService extends Service { + private static final String TAG = "GpodnetSyncService"; + + private static final long WAIT_INTERVAL = 5000L; + + public static final String ARG_ACTION = "action"; + + /** + * Starts a new upload action. The service will not upload immediately, but wait for a certain amount of time in + * case any other upload requests occur. + */ + public static final String ACTION_UPLOAD_CHANGES = "de.danoeh.antennapod.intent.action.upload_changes"; + /** + * Starts a new download action. The service will download all changes in the subscription list since the last sync. + * New subscriptions will be added to the database, removed subscriptions will be removed from the database + */ + public static final String ACTION_DOWNLOAD_CHANGES = "de.danoeh.antennapod.intent.action.download_changes"; + /** + * Starts a new upload action immediately and a new download action after that. + */ + public static final String ACTION_SYNC = "de.danoeh.antennapod.intent.action.sync"; + + private GpodnetService service; + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + final String action = (intent != null) ? intent.getStringExtra(ARG_ACTION) : null; + if (action != null && action.equals(ACTION_UPLOAD_CHANGES)) { + Log.d(TAG, String.format("Waiting %d milliseconds before uploading changes", WAIT_INTERVAL)); + + uploadWaiterThread.restart(); + } else if (action != null && action.equals(ACTION_DOWNLOAD_CHANGES)) { + new Thread() { + @Override + public void run() { + downloadChanges(); + } + }.start(); + } else if (action != null && action.equals(ACTION_SYNC)) { + new Thread() { + @Override + public void run() { + uploadChanges(); + downloadChanges(); + } + }.start(); + } + return START_FLAG_REDELIVERY; + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (AppConfig.DEBUG) Log.d(TAG, "onDestroy"); + uploadWaiterThread.interrupt(); + + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + private GpodnetService tryLogin() throws GpodnetServiceException { + if (service == null) { + service = new GpodnetService(); + service.authenticate(GpodnetPreferences.getUsername(), GpodnetPreferences.getPassword()); + } + return service; + } + + private synchronized void downloadChanges() { + if (GpodnetPreferences.loggedIn() && NetworkUtils.networkAvailable(GpodnetSyncService.this)) { + if (AppConfig.DEBUG) Log.d(TAG, "Downloading changes"); + try { + GpodnetService service = tryLogin(); + GpodnetSubscriptionChange changes = service.getSubscriptionChanges(GpodnetPreferences.getUsername(), GpodnetPreferences.getDeviceID(), GpodnetPreferences.getLastSyncTimestamp()); + if (AppConfig.DEBUG) Log.d(TAG, "Changes " + changes.toString()); + + GpodnetPreferences.setLastSyncTimestamp(changes.getTimestamp()); + List subscriptionList = DBReader.getFeedListDownloadUrls(GpodnetSyncService.this); + + for (String downloadUrl : changes.getAdded()) { + if (!subscriptionList.contains(downloadUrl)) { + Feed feed = new Feed(downloadUrl, new Date()); + DownloadRequester.getInstance().downloadFeed(GpodnetSyncService.this, feed); + } + } + for (String downloadUrl : changes.getRemoved()) { + DBTasks.removeFeedWithDownloadUrl(GpodnetSyncService.this, downloadUrl); + } + } catch (GpodnetServiceException e) { + e.printStackTrace(); + updateErrorNotification(e); + } catch (DownloadRequestException e) { + e.printStackTrace(); + } + } + stopSelf(); + } + + private synchronized void uploadChanges() { + if (GpodnetPreferences.loggedIn() && NetworkUtils.networkAvailable(GpodnetSyncService.this)) { + try { + if (AppConfig.DEBUG) Log.d(TAG, "Uploading subscription list"); + GpodnetService service = tryLogin(); + List subscriptions = DBReader.getFeedListDownloadUrls(GpodnetSyncService.this); + + if (AppConfig.DEBUG) Log.d(TAG, "Uploading subscriptions: " + subscriptions.toString()); + + service.uploadSubscriptions(GpodnetPreferences.getUsername(), GpodnetPreferences.getDeviceID(), subscriptions); + } catch (GpodnetServiceException e) { + e.printStackTrace(); + updateErrorNotification(e); + } + } + stopSelf(); + } + + private void updateErrorNotification(GpodnetServiceException exception) { + if (AppConfig.DEBUG) Log.d(TAG, "Posting error notification"); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(this); + final String title; + final String description; + final int id; + if (exception instanceof GpodnetServiceAuthenticationException) { + title = getString(R.string.gpodnetsync_auth_error_title); + description = getString(R.string.gpodnetsync_auth_error_descr); + id = R.id.notification_gpodnet_sync_autherror; + } else { + title = getString(R.string.gpodnetsync_error_title); + description = getString(R.string.gpodnetsync_error_descr) + exception.getMessage(); + id = R.id.notification_gpodnet_sync_error; + } + Notification notification = builder.setContentTitle(title) + .setContentText(description) + .setSmallIcon(R.drawable.stat_notify_sync_error) + .setAutoCancel(true) + .build(); + NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + nm.notify(id, notification); + } + + private WaiterThread uploadWaiterThread = new WaiterThread(WAIT_INTERVAL) { + @Override + public void onWaitCompleted() { + uploadChanges(); + } + }; + + private abstract class WaiterThread { + private long waitInterval; + private Thread thread; + + private WaiterThread(long waitInterval) { + this.waitInterval = waitInterval; + reinit(); + } + + public abstract void onWaitCompleted(); + + public void exec() { + if (!thread.isAlive()) { + thread.start(); + } + } + + private void reinit() { + if (thread != null && thread.isAlive()) { + Log.d(TAG, "Interrupting waiter thread"); + thread.interrupt(); + } + thread = new Thread() { + @Override + public void run() { + try { + Thread.sleep(waitInterval); + } catch (InterruptedException e) { + e.printStackTrace(); + } + if (!isInterrupted()) { + synchronized (this) { + onWaitCompleted(); + } + } + } + }; + } + + public void restart() { + reinit(); + exec(); + } + + public void interrupt() { + if (thread != null && thread.isAlive()) { + thread.interrupt(); + } + } + } + + public static void sendActionDownloadIntent(Context context) { + if (GpodnetPreferences.loggedIn()) { + Intent intent = new Intent(context, GpodnetSyncService.class); + intent.putExtra(ARG_ACTION, ACTION_DOWNLOAD_CHANGES); + context.startService(intent); + } + } + + public static void sendActionUploadIntent(Context context) { + if (GpodnetPreferences.loggedIn()) { + Intent intent = new Intent(context, GpodnetSyncService.class); + intent.putExtra(ARG_ACTION, ACTION_UPLOAD_CHANGES); + context.startService(intent); + } + } + + public static void sendSyncIntent(Context context) { + if (GpodnetPreferences.loggedIn()) { + Intent intent = new Intent(context, GpodnetSyncService.class); + intent.putExtra(ARG_ACTION, ACTION_SYNC); + context.startService(intent); + } + } +} diff --git a/src/de/danoeh/antennapod/storage/DBReader.java b/src/de/danoeh/antennapod/storage/DBReader.java index 28ab3d939..a5a4c8cd4 100644 --- a/src/de/danoeh/antennapod/storage/DBReader.java +++ b/src/de/danoeh/antennapod/storage/DBReader.java @@ -75,6 +75,27 @@ public final class DBReader { return feeds; } + /** + * Returns a list with the download URLs of all feeds. + * @param context A context that is used for opening the database connection. + * @return A list of Strings with the download URLs of all feeds. + * */ + public static List getFeedListDownloadUrls(final Context context) { + PodDBAdapter adapter = new PodDBAdapter(context); + List result = new ArrayList(); + adapter.open(); + Cursor feeds = adapter.getFeedCursorDownloadUrls(); + if (feeds.moveToFirst()) { + do { + result.add(feeds.getString(1)); + } while (feeds.moveToNext()); + } + feeds.close(); + adapter.close(); + + return result; + } + /** * Returns a list of 'expired Feeds', i.e. Feeds that have not been updated for a certain amount of time. * diff --git a/src/de/danoeh/antennapod/storage/DBTasks.java b/src/de/danoeh/antennapod/storage/DBTasks.java index 741699bdf..9fd084843 100644 --- a/src/de/danoeh/antennapod/storage/DBTasks.java +++ b/src/de/danoeh/antennapod/storage/DBTasks.java @@ -23,6 +23,7 @@ import de.danoeh.antennapod.feed.FeedImage; import de.danoeh.antennapod.feed.FeedItem; import de.danoeh.antennapod.feed.FeedMedia; import de.danoeh.antennapod.preferences.UserPreferences; +import de.danoeh.antennapod.service.GpodnetSyncService; import de.danoeh.antennapod.service.PlaybackService; import de.danoeh.antennapod.service.download.DownloadStatus; import de.danoeh.antennapod.util.DownloadError; @@ -39,6 +40,39 @@ public final class DBTasks { private DBTasks() { } + /** + * Removes the feed with the given download url. This method should NOT be executed on the GUI thread. + * @param context Used for accessing the db + * @param downloadUrl URL of the feed. + * */ + public static void removeFeedWithDownloadUrl(Context context, String downloadUrl) { + PodDBAdapter adapter = new PodDBAdapter(context); + adapter.open(); + Cursor cursor = adapter.getFeedCursorDownloadUrls(); + long feedID = 0; + if (cursor.moveToFirst()) { + do { + if (cursor.getString(1).equals(downloadUrl)) { + feedID = cursor.getLong(0); + } + } while (cursor.moveToNext()); + } + cursor.close(); + adapter.close(); + + if (feedID != 0) { + try { + DBWriter.deleteFeed(context, feedID).get(); + } catch (InterruptedException e) { + e.printStackTrace(); + } catch (ExecutionException e) { + e.printStackTrace(); + } + } else { + Log.w(TAG, "removeFeedWithDownloadUrl: Could not find feed with url: " + downloadUrl); + } + } + /** * Starts playback of a FeedMedia object's file. This method will build an Intent based on the given parameters to * start the {@link PlaybackService}. @@ -110,6 +144,8 @@ public final class DBTasks { refreshFeeds(context, DBReader.getFeedList(context)); } isRefreshing.set(false); + + GpodnetSyncService.sendSyncIntent(context); } }.start(); } else { diff --git a/src/de/danoeh/antennapod/storage/DBWriter.java b/src/de/danoeh/antennapod/storage/DBWriter.java index d96299dbe..bbc6f94b7 100644 --- a/src/de/danoeh/antennapod/storage/DBWriter.java +++ b/src/de/danoeh/antennapod/storage/DBWriter.java @@ -18,6 +18,7 @@ import android.util.Log; import de.danoeh.antennapod.AppConfig; import de.danoeh.antennapod.feed.*; import de.danoeh.antennapod.preferences.PlaybackPreferences; +import de.danoeh.antennapod.service.GpodnetSyncService; import de.danoeh.antennapod.service.PlaybackService; import de.danoeh.antennapod.service.download.DownloadStatus; import de.danoeh.antennapod.util.QueueAccess; @@ -171,6 +172,8 @@ public class DBWriter { } adapter.removeFeed(feed); adapter.close(); + + GpodnetSyncService.sendActionUploadIntent(context); EventDistributor.getInstance().sendFeedUpdateBroadcast(); } } @@ -614,6 +617,7 @@ public class DBWriter { adapter.setCompleteFeed(feed); adapter.close(); + GpodnetSyncService.sendActionUploadIntent(context); EventDistributor.getInstance().sendFeedUpdateBroadcast(); } }); diff --git a/src/de/danoeh/antennapod/storage/PodDBAdapter.java b/src/de/danoeh/antennapod/storage/PodDBAdapter.java index bb743e2b1..ff98d4ae7 100644 --- a/src/de/danoeh/antennapod/storage/PodDBAdapter.java +++ b/src/de/danoeh/antennapod/storage/PodDBAdapter.java @@ -659,6 +659,10 @@ public class PodDBAdapter { return c; } + public final Cursor getFeedCursorDownloadUrls() { + return db.query(TABLE_NAME_FEEDS, new String[]{KEY_ID, KEY_DOWNLOAD_URL}, null, null, null, null, null); + } + public final Cursor getExpiredFeedsCursor(long expirationTime) { Cursor c = db.query(TABLE_NAME_FEEDS, null, "?