summaryrefslogtreecommitdiff
path: root/core
diff options
context:
space:
mode:
authorthrillfall <thrillfall@users.noreply.github.com>2021-10-06 22:12:47 +0200
committerGitHub <noreply@github.com>2021-10-06 22:12:47 +0200
commitbc85ebc806367d863973bc9434e7b0d9d5fd2168 (patch)
tree5a729b84f1a12c3de8d3178ad7d688eb6bb552be /core
parentdab44b68436601f415edb095da605811e985eb00 (diff)
downloadAntennaPod-bc85ebc806367d863973bc9434e7b0d9d5fd2168.zip
Add synchronization with gPodder Nextcloud server app (#5243)
Diffstat (limited to 'core')
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/preferences/GpodnetPreferences.java115
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java11
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java49
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java3
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java38
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java31
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java1
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/sync/GuidValidator.java3
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/sync/LockingAsyncExecutor.java35
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/sync/SyncService.java362
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationCredentials.java67
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationProviderViewData.java47
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationSettings.java83
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/sync/queue/SynchronizationQueueSink.java67
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/sync/queue/SynchronizationQueueStorage.java140
-rw-r--r--core/src/main/res/drawable-nodpi/nextcloud_logo.pngbin0 -> 3432 bytes
-rw-r--r--core/src/main/res/values/strings.xml23
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/sync/GuidValidatorTest.java1
18 files changed, 621 insertions, 455 deletions
diff --git a/core/src/main/java/de/danoeh/antennapod/core/preferences/GpodnetPreferences.java b/core/src/main/java/de/danoeh/antennapod/core/preferences/GpodnetPreferences.java
deleted file mode 100644
index e338e0d01..000000000
--- a/core/src/main/java/de/danoeh/antennapod/core/preferences/GpodnetPreferences.java
+++ /dev/null
@@ -1,115 +0,0 @@
-package de.danoeh.antennapod.core.preferences;
-
-import android.content.Context;
-import android.content.SharedPreferences;
-import android.util.Log;
-import de.danoeh.antennapod.core.BuildConfig;
-import de.danoeh.antennapod.core.ClientConfig;
-import de.danoeh.antennapod.core.sync.SyncService;
-import de.danoeh.antennapod.net.sync.gpoddernet.GpodnetService;
-
-/**
- * Manages preferences for accessing gpodder.net service
- */
-public class GpodnetPreferences {
-
- private GpodnetPreferences(){}
-
- private static final String TAG = "GpodnetPreferences";
-
- private static final String PREF_NAME = "gpodder.net";
- private static final String PREF_GPODNET_USERNAME = "de.danoeh.antennapod.preferences.gpoddernet.username";
- private static final String PREF_GPODNET_PASSWORD = "de.danoeh.antennapod.preferences.gpoddernet.password";
- private static final String PREF_GPODNET_DEVICEID = "de.danoeh.antennapod.preferences.gpoddernet.deviceID";
- private static final String PREF_GPODNET_HOSTNAME = "prefGpodnetHostname";
-
- private static String username;
- private static String password;
- private static String deviceID;
- private static String hosturl;
-
- private static boolean preferencesLoaded = false;
-
- private static SharedPreferences getPreferences() {
- return ClientConfig.applicationCallbacks.getApplicationInstance().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
- }
-
- private static synchronized void ensurePreferencesLoaded() {
- if (!preferencesLoaded) {
- SharedPreferences prefs = getPreferences();
- username = prefs.getString(PREF_GPODNET_USERNAME, null);
- password = prefs.getString(PREF_GPODNET_PASSWORD, null);
- deviceID = prefs.getString(PREF_GPODNET_DEVICEID, null);
- hosturl = prefs.getString(PREF_GPODNET_HOSTNAME, GpodnetService.DEFAULT_BASE_HOST);
-
- preferencesLoaded = true;
- }
- }
-
- private static void writePreference(String key, String value) {
- SharedPreferences.Editor editor = getPreferences().edit();
- editor.putString(key, value);
- editor.apply();
- }
-
- public static String getUsername() {
- ensurePreferencesLoaded();
- return username;
- }
-
- public static void setUsername(String username) {
- GpodnetPreferences.username = username;
- writePreference(PREF_GPODNET_USERNAME, username);
- }
-
- public static String getPassword() {
- ensurePreferencesLoaded();
- return password;
- }
-
- public static void setPassword(String password) {
- GpodnetPreferences.password = password;
- writePreference(PREF_GPODNET_PASSWORD, password);
- }
-
- public static String getDeviceID() {
- ensurePreferencesLoaded();
- return deviceID;
- }
-
- public static void setDeviceID(String deviceID) {
- GpodnetPreferences.deviceID = deviceID;
- writePreference(PREF_GPODNET_DEVICEID, deviceID);
- }
-
- public static String getHosturl() {
- ensurePreferencesLoaded();
- return hosturl;
- }
-
- public static void setHosturl(String value) {
- if (!value.equals(hosturl)) {
- logout();
- writePreference(PREF_GPODNET_HOSTNAME, value);
- hosturl = value;
- }
- }
-
- /**
- * 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 synchronized void logout() {
- if (BuildConfig.DEBUG) Log.d(TAG, "Logout: Clearing preferences");
- setUsername(null);
- setPassword(null);
- setDeviceID(null);
- SyncService.clearQueue(ClientConfig.applicationCallbacks.getApplicationInstance());
- UserPreferences.setGpodnetNotificationsEnabled();
- }
-
-}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java
index 8c9035621..6bbd704e2 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java
@@ -6,21 +6,22 @@ import android.util.Log;
import androidx.annotation.NonNull;
+import org.greenrobot.eventbus.EventBus;
+
import java.io.File;
import java.util.concurrent.ExecutionException;
import de.danoeh.antennapod.core.event.UnreadItemsUpdateEvent;
-import de.danoeh.antennapod.model.feed.FeedItem;
-import de.danoeh.antennapod.model.feed.FeedMedia;
import de.danoeh.antennapod.core.service.download.DownloadRequest;
import de.danoeh.antennapod.core.service.download.DownloadStatus;
import de.danoeh.antennapod.core.storage.DBReader;
import de.danoeh.antennapod.core.storage.DBWriter;
+import de.danoeh.antennapod.core.sync.queue.SynchronizationQueueSink;
import de.danoeh.antennapod.core.util.ChapterUtils;
import de.danoeh.antennapod.core.util.DownloadError;
-import de.danoeh.antennapod.core.sync.SyncService;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedMedia;
import de.danoeh.antennapod.net.sync.model.EpisodeAction;
-import org.greenrobot.eventbus.EventBus;
/**
* Handles a completed media download.
@@ -103,7 +104,7 @@ public class MediaDownloadedHandler implements Runnable {
EpisodeAction action = new EpisodeAction.Builder(item, EpisodeAction.DOWNLOAD)
.currentTimestamp()
.build();
- SyncService.enqueueEpisodeAction(context, action);
+ SynchronizationQueueSink.enqueueEpisodeActionIfSynchronizationIsActive(context, action);
}
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java
index f503c16f4..848ea7cfc 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java
@@ -1,5 +1,7 @@
package de.danoeh.antennapod.core.service.playback;
+import static de.danoeh.antennapod.model.feed.FeedPreferences.SPEED_USE_GLOBAL;
+
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
@@ -21,13 +23,7 @@ import android.os.Build;
import android.os.Bundle;
import android.os.IBinder;
import android.os.Vibrator;
-import androidx.preference.PreferenceManager;
-import androidx.annotation.NonNull;
-import androidx.annotation.StringRes;
-import androidx.core.app.NotificationCompat;
-import androidx.core.app.NotificationManagerCompat;
import android.support.v4.media.MediaBrowserCompat;
-import androidx.media.MediaBrowserServiceCompat;
import android.support.v4.media.MediaDescriptionCompat;
import android.support.v4.media.MediaMetadataCompat;
import android.support.v4.media.session.MediaSessionCompat;
@@ -40,6 +36,17 @@ import android.view.SurfaceHolder;
import android.webkit.URLUtil;
import android.widget.Toast;
+import androidx.annotation.NonNull;
+import androidx.annotation.StringRes;
+import androidx.core.app.NotificationCompat;
+import androidx.core.app.NotificationManagerCompat;
+import androidx.media.MediaBrowserServiceCompat;
+import androidx.preference.PreferenceManager;
+
+import org.greenrobot.eventbus.EventBus;
+import org.greenrobot.eventbus.Subscribe;
+import org.greenrobot.eventbus.ThreadMode;
+
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -52,12 +59,6 @@ import de.danoeh.antennapod.core.event.ServiceEvent;
import de.danoeh.antennapod.core.event.settings.SkipIntroEndingChangedEvent;
import de.danoeh.antennapod.core.event.settings.SpeedPresetChangedEvent;
import de.danoeh.antennapod.core.event.settings.VolumeAdaptionChangedEvent;
-import de.danoeh.antennapod.model.feed.Chapter;
-import de.danoeh.antennapod.model.feed.Feed;
-import de.danoeh.antennapod.model.feed.FeedItem;
-import de.danoeh.antennapod.model.feed.FeedMedia;
-import de.danoeh.antennapod.model.feed.FeedPreferences;
-import de.danoeh.antennapod.model.playback.MediaType;
import de.danoeh.antennapod.core.preferences.PlaybackPreferences;
import de.danoeh.antennapod.core.preferences.SleepTimerPreferences;
import de.danoeh.antennapod.core.preferences.UserPreferences;
@@ -66,15 +67,21 @@ import de.danoeh.antennapod.core.storage.DBReader;
import de.danoeh.antennapod.core.storage.DBTasks;
import de.danoeh.antennapod.core.storage.DBWriter;
import de.danoeh.antennapod.core.storage.FeedSearcher;
-import de.danoeh.antennapod.core.sync.SyncService;
+import de.danoeh.antennapod.core.sync.queue.SynchronizationQueueSink;
import de.danoeh.antennapod.core.util.FeedItemUtil;
import de.danoeh.antennapod.core.util.IntentUtils;
import de.danoeh.antennapod.core.util.NetworkUtils;
import de.danoeh.antennapod.core.util.gui.NotificationUtils;
-import de.danoeh.antennapod.model.playback.Playable;
import de.danoeh.antennapod.core.util.playback.PlayableUtils;
import de.danoeh.antennapod.core.util.playback.PlaybackServiceStarter;
import de.danoeh.antennapod.core.widget.WidgetUpdater;
+import de.danoeh.antennapod.model.feed.Chapter;
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.model.feed.FeedPreferences;
+import de.danoeh.antennapod.model.playback.MediaType;
+import de.danoeh.antennapod.model.playback.Playable;
import de.danoeh.antennapod.ui.appstartintent.MainActivityStarter;
import de.danoeh.antennapod.ui.appstartintent.VideoPlayerActivityStarter;
import io.reactivex.Completable;
@@ -83,11 +90,6 @@ import io.reactivex.Single;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.Disposable;
import io.reactivex.schedulers.Schedulers;
-import org.greenrobot.eventbus.EventBus;
-import org.greenrobot.eventbus.Subscribe;
-import org.greenrobot.eventbus.ThreadMode;
-
-import static de.danoeh.antennapod.model.feed.FeedPreferences.SPEED_USE_GLOBAL;
/**
* Controls the MediaPlayer that plays a FeedMedia-file
@@ -966,7 +968,8 @@ public class PlaybackService extends MediaBrowserServiceCompat {
taskManager.cancelWidgetUpdater();
if (playable != null) {
if (playable instanceof FeedMedia) {
- SyncService.enqueueEpisodePlayed(getApplicationContext(), (FeedMedia) playable, false);
+ SynchronizationQueueSink.enqueueEpisodePlayedIfSynchronizationIsActive(getApplicationContext(),
+ (FeedMedia) playable, false);
}
playable.onPlaybackPause(getApplicationContext());
}
@@ -1110,10 +1113,12 @@ public class PlaybackService extends MediaBrowserServiceCompat {
}
if (ended || smartMarkAsPlayed) {
- SyncService.enqueueEpisodePlayed(getApplicationContext(), media, true);
+ SynchronizationQueueSink.enqueueEpisodePlayedIfSynchronizationIsActive(
+ getApplicationContext(), media, true);
media.onPlaybackCompleted(getApplicationContext());
} else {
- SyncService.enqueueEpisodePlayed(getApplicationContext(), media, false);
+ SynchronizationQueueSink.enqueueEpisodePlayedIfSynchronizationIsActive(
+ getApplicationContext(), media, false);
media.onPlaybackPause(getApplicationContext());
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java
index 185d85e7a..f776fe111 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java
@@ -575,7 +575,6 @@ public final class DBReader {
@Nullable
private static FeedItem getFeedItemByGuidOrEpisodeUrl(final String guid, final String episodeUrl,
PodDBAdapter adapter) {
- Log.d(TAG, "Loading feeditem with guid " + guid + " or episode url " + episodeUrl);
try (Cursor cursor = adapter.getFeedItemCursor(guid, episodeUrl)) {
if (!cursor.moveToNext()) {
return null;
@@ -633,8 +632,6 @@ public final class DBReader {
* Does NOT load additional attributes like feed or queue state.
*/
public static FeedItem getFeedItemByGuidOrEpisodeUrl(final String guid, final String episodeUrl) {
- Log.d(TAG, "getFeedItem() called with: " + "guid = [" + guid + "], episodeUrl = [" + episodeUrl + "]");
-
PodDBAdapter adapter = PodDBAdapter.getInstance();
adapter.open();
try {
diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java
index 9dd979dc7..377202c4b 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java
@@ -1,5 +1,7 @@
package de.danoeh.antennapod.core.storage;
+import static android.content.Context.MODE_PRIVATE;
+
import android.content.Context;
import android.content.SharedPreferences;
import android.database.Cursor;
@@ -9,22 +11,6 @@ import android.util.Log;
import androidx.annotation.VisibleForTesting;
-import de.danoeh.antennapod.core.R;
-import de.danoeh.antennapod.core.event.FeedItemEvent;
-import de.danoeh.antennapod.core.event.FeedListUpdateEvent;
-import de.danoeh.antennapod.core.event.MessageEvent;
-import de.danoeh.antennapod.model.feed.Feed;
-import de.danoeh.antennapod.model.feed.FeedItem;
-import de.danoeh.antennapod.model.feed.FeedMedia;
-import de.danoeh.antennapod.core.feed.LocalFeedUpdater;
-import de.danoeh.antennapod.core.preferences.UserPreferences;
-import de.danoeh.antennapod.core.service.download.DownloadStatus;
-import de.danoeh.antennapod.core.storage.mapper.FeedCursorMapper;
-import de.danoeh.antennapod.core.sync.SyncService;
-import de.danoeh.antennapod.core.util.DownloadError;
-import de.danoeh.antennapod.core.util.LongList;
-import de.danoeh.antennapod.core.util.comparator.FeedItemPubdateComparator;
-import de.danoeh.antennapod.net.sync.model.EpisodeAction;
import org.greenrobot.eventbus.EventBus;
import java.util.ArrayList;
@@ -41,7 +27,23 @@ import java.util.concurrent.Future;
import java.util.concurrent.FutureTask;
import java.util.concurrent.atomic.AtomicBoolean;
-import static android.content.Context.MODE_PRIVATE;
+import de.danoeh.antennapod.core.R;
+import de.danoeh.antennapod.core.event.FeedItemEvent;
+import de.danoeh.antennapod.core.event.FeedListUpdateEvent;
+import de.danoeh.antennapod.core.event.MessageEvent;
+import de.danoeh.antennapod.core.feed.LocalFeedUpdater;
+import de.danoeh.antennapod.core.preferences.UserPreferences;
+import de.danoeh.antennapod.core.service.download.DownloadStatus;
+import de.danoeh.antennapod.core.storage.mapper.FeedCursorMapper;
+import de.danoeh.antennapod.core.sync.SyncService;
+import de.danoeh.antennapod.core.sync.queue.SynchronizationQueueSink;
+import de.danoeh.antennapod.core.util.DownloadError;
+import de.danoeh.antennapod.core.util.LongList;
+import de.danoeh.antennapod.core.util.comparator.FeedItemPubdateComparator;
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.net.sync.model.EpisodeAction;
/**
* Provides methods for doing common tasks that use DBReader and DBWriter.
@@ -482,7 +484,7 @@ public final class DBTasks {
.position(oldItem.getMedia().getDuration() / 1000)
.total(oldItem.getMedia().getDuration() / 1000)
.build();
- SyncService.enqueueEpisodeAction(context, action);
+ SynchronizationQueueSink.enqueueEpisodeActionIfSynchronizationIsActive(context, action);
}
}
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java
index 34ea5e207..479a7763c 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java
@@ -7,8 +7,6 @@ import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
-import de.danoeh.antennapod.core.sync.SyncService;
-import de.danoeh.antennapod.net.sync.model.EpisodeAction;
import org.greenrobot.eventbus.EventBus;
import java.io.File;
@@ -32,23 +30,24 @@ import de.danoeh.antennapod.core.event.MessageEvent;
import de.danoeh.antennapod.core.event.PlaybackHistoryEvent;
import de.danoeh.antennapod.core.event.QueueEvent;
import de.danoeh.antennapod.core.event.UnreadItemsUpdateEvent;
-import de.danoeh.antennapod.model.feed.Feed;
import de.danoeh.antennapod.core.feed.FeedEvent;
-import de.danoeh.antennapod.model.feed.FeedItem;
-import de.danoeh.antennapod.model.feed.FeedMedia;
-import de.danoeh.antennapod.model.feed.FeedPreferences;
-import de.danoeh.antennapod.core.preferences.GpodnetPreferences;
import de.danoeh.antennapod.core.preferences.PlaybackPreferences;
import de.danoeh.antennapod.core.preferences.UserPreferences;
import de.danoeh.antennapod.core.service.download.DownloadStatus;
import de.danoeh.antennapod.core.service.playback.PlaybackService;
+import de.danoeh.antennapod.core.sync.queue.SynchronizationQueueSink;
import de.danoeh.antennapod.core.util.FeedItemPermutors;
import de.danoeh.antennapod.core.util.IntentUtils;
import de.danoeh.antennapod.core.util.LongList;
import de.danoeh.antennapod.core.util.Permutor;
+import de.danoeh.antennapod.core.util.playback.PlayableUtils;
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.model.feed.FeedPreferences;
import de.danoeh.antennapod.model.feed.SortOrder;
import de.danoeh.antennapod.model.playback.Playable;
-import de.danoeh.antennapod.core.util.playback.PlayableUtils;
+import de.danoeh.antennapod.net.sync.model.EpisodeAction;
/**
* Provides methods for writing data to AntennaPod's database.
@@ -132,13 +131,11 @@ public class DBWriter {
}
// Gpodder: queue delete action for synchronization
- if (GpodnetPreferences.loggedIn()) {
- FeedItem item = media.getItem();
- EpisodeAction action = new EpisodeAction.Builder(item, EpisodeAction.DELETE)
- .currentTimestamp()
- .build();
- SyncService.enqueueEpisodeAction(context, action);
- }
+ FeedItem item = media.getItem();
+ EpisodeAction action = new EpisodeAction.Builder(item, EpisodeAction.DELETE)
+ .currentTimestamp()
+ .build();
+ SynchronizationQueueSink.enqueueEpisodeActionIfSynchronizationIsActive(context, action);
}
EventBus.getDefault().post(FeedItemEvent.deletedMedia(Collections.singletonList(media.getItem())));
return true;
@@ -170,7 +167,7 @@ public class DBWriter {
adapter.removeFeed(feed);
adapter.close();
- SyncService.enqueueFeedRemoved(context, feed.getDownload_url());
+ SynchronizationQueueSink.enqueueFeedRemovedIfSynchronizationIsActive(context, feed.getDownload_url());
EventBus.getDefault().post(new FeedListUpdateEvent(feed));
});
}
@@ -782,7 +779,7 @@ public class DBWriter {
adapter.close();
for (Feed feed : feeds) {
- SyncService.enqueueFeedAdded(context, feed.getDownload_url());
+ SynchronizationQueueSink.enqueueFeedAddedIfSynchronizationIsActive(context, feed.getDownload_url());
}
BackupManager backupManager = new BackupManager(context);
diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java b/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java
index 85ce2dc99..55cfafbbb 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java
@@ -1123,7 +1123,6 @@ public class PodDBAdapter {
+ " INNER JOIN " + TABLE_NAME_FEEDS
+ " ON " + TABLE_NAME_FEED_ITEMS + "." + KEY_FEED + "=" + TABLE_NAME_FEEDS + "." + KEY_ID
+ " WHERE " + whereClauseCondition;
- Log.d(TAG, "SQL: " + query);
return db.rawQuery(query, null);
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/GuidValidator.java b/core/src/main/java/de/danoeh/antennapod/core/sync/GuidValidator.java
index 6d80a6457..74e5d5cdf 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/sync/GuidValidator.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/sync/GuidValidator.java
@@ -4,7 +4,8 @@ public class GuidValidator {
public static boolean isValidGuid(String guid) {
return guid != null
- && !guid.trim().isEmpty();
+ && !guid.trim().isEmpty()
+ && !guid.equals("null");
}
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/LockingAsyncExecutor.java b/core/src/main/java/de/danoeh/antennapod/core/sync/LockingAsyncExecutor.java
new file mode 100644
index 000000000..e7dbbbd3c
--- /dev/null
+++ b/core/src/main/java/de/danoeh/antennapod/core/sync/LockingAsyncExecutor.java
@@ -0,0 +1,35 @@
+package de.danoeh.antennapod.core.sync;
+
+import java.util.concurrent.locks.ReentrantLock;
+
+import io.reactivex.Completable;
+import io.reactivex.schedulers.Schedulers;
+
+public class LockingAsyncExecutor {
+
+ static final ReentrantLock lock = new ReentrantLock();
+
+ /**
+ * Take the lock and execute runnable (to prevent changes to preferences being lost when enqueueing while sync is
+ * in progress). If the lock is free, the runnable is directly executed in the calling thread to prevent overhead.
+ */
+ public static void executeLockedAsync(Runnable runnable) {
+ if (lock.tryLock()) {
+ try {
+ runnable.run();
+ } finally {
+ lock.unlock();
+ }
+ } else {
+ Completable.fromRunnable(() -> {
+ lock.lock();
+ try {
+ runnable.run();
+ } finally {
+ lock.unlock();
+ }
+ }).subscribeOn(Schedulers.io())
+ .subscribe();
+ }
+ }
+}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/SyncService.java b/core/src/main/java/de/danoeh/antennapod/core/sync/SyncService.java
index 9803a29db..8edc37ac4 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/sync/SyncService.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/sync/SyncService.java
@@ -5,7 +5,6 @@ import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
-import android.content.SharedPreferences;
import android.util.Log;
import androidx.annotation.NonNull;
@@ -20,12 +19,16 @@ import androidx.work.WorkManager;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
+import org.apache.commons.lang3.StringUtils;
+import org.greenrobot.eventbus.EventBus;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
import de.danoeh.antennapod.core.R;
import de.danoeh.antennapod.core.event.SyncServiceEvent;
-import de.danoeh.antennapod.model.feed.Feed;
-import de.danoeh.antennapod.model.feed.FeedItem;
-import de.danoeh.antennapod.model.feed.FeedMedia;
-import de.danoeh.antennapod.core.preferences.GpodnetPreferences;
import de.danoeh.antennapod.core.preferences.UserPreferences;
import de.danoeh.antennapod.core.service.download.AntennapodHttpClient;
import de.danoeh.antennapod.core.storage.DBReader;
@@ -33,10 +36,14 @@ import de.danoeh.antennapod.core.storage.DBTasks;
import de.danoeh.antennapod.core.storage.DBWriter;
import de.danoeh.antennapod.core.storage.DownloadRequestException;
import de.danoeh.antennapod.core.storage.DownloadRequester;
+import de.danoeh.antennapod.core.sync.queue.SynchronizationQueueStorage;
import de.danoeh.antennapod.core.util.FeedItemUtil;
import de.danoeh.antennapod.core.util.LongList;
import de.danoeh.antennapod.core.util.URLChecker;
import de.danoeh.antennapod.core.util.gui.NotificationUtils;
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedMedia;
import de.danoeh.antennapod.net.sync.gpoddernet.GpodnetService;
import de.danoeh.antennapod.net.sync.model.EpisodeAction;
import de.danoeh.antennapod.net.sync.model.EpisodeActionChanges;
@@ -44,156 +51,54 @@ import de.danoeh.antennapod.net.sync.model.ISyncService;
import de.danoeh.antennapod.net.sync.model.SubscriptionChanges;
import de.danoeh.antennapod.net.sync.model.SyncServiceException;
import de.danoeh.antennapod.net.sync.model.UploadChangesResponse;
-import io.reactivex.Completable;
-import io.reactivex.schedulers.Schedulers;
-
-import org.apache.commons.lang3.StringUtils;
-import org.greenrobot.eventbus.EventBus;
-import org.json.JSONArray;
-import org.json.JSONException;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.locks.ReentrantLock;
+import de.danoeh.antennapod.net.sync.nextcloud.NextcloudSyncService;
public class SyncService extends Worker {
- private static final String PREF_NAME = "SyncService";
- private static final String PREF_LAST_SUBSCRIPTION_SYNC_TIMESTAMP = "last_sync_timestamp";
- private static final String PREF_LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP = "last_episode_actions_sync_timestamp";
- private static final String PREF_QUEUED_FEEDS_ADDED = "sync_added";
- private static final String PREF_QUEUED_FEEDS_REMOVED = "sync_removed";
- private static final String PREF_QUEUED_EPISODE_ACTIONS = "sync_queued_episode_actions";
- private static final String PREF_LAST_SYNC_ATTEMPT_TIMESTAMP = "last_sync_attempt_timestamp";
- private static final String PREF_LAST_SYNC_ATTEMPT_SUCCESS = "last_sync_attempt_success";
- private static final String TAG = "SyncService";
- private static final String WORK_ID_SYNC = "SyncServiceWorkId";
- private static final ReentrantLock lock = new ReentrantLock();
+ public static final String TAG = "SyncService";
- private ISyncService syncServiceImpl;
+ private static final String WORK_ID_SYNC = "SyncServiceWorkId";
+ private final SynchronizationQueueStorage synchronizationQueueStorage;
public SyncService(@NonNull Context context, @NonNull WorkerParameters params) {
super(context, params);
+ synchronizationQueueStorage = new SynchronizationQueueStorage(context);
}
@Override
@NonNull
public Result doWork() {
- if (!GpodnetPreferences.loggedIn()) {
+ ISyncService activeSyncProvider = getActiveSyncProvider();
+ if (activeSyncProvider == null) {
return Result.success();
}
- syncServiceImpl = new GpodnetService(AntennapodHttpClient.getHttpClient(),
- GpodnetPreferences.getHosturl(), GpodnetPreferences.getDeviceID(),
- GpodnetPreferences.getUsername(), GpodnetPreferences.getPassword());
- SharedPreferences.Editor prefs = getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
- .edit();
- prefs.putLong(PREF_LAST_SYNC_ATTEMPT_TIMESTAMP, System.currentTimeMillis()).apply();
+
+ SynchronizationSettings.updateLastSynchronizationAttempt();
try {
- syncServiceImpl.login();
+ activeSyncProvider.login();
EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_subscriptions));
- syncSubscriptions();
- syncEpisodeActions();
- syncServiceImpl.logout();
+ syncSubscriptions(activeSyncProvider);
+ syncEpisodeActions(activeSyncProvider);
+ activeSyncProvider.logout();
clearErrorNotifications();
EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_success));
- prefs.putBoolean(PREF_LAST_SYNC_ATTEMPT_SUCCESS, true).apply();
+ SynchronizationSettings.setLastSynchronizationAttemptSuccess(true);
return Result.success();
- } catch (SyncServiceException e) {
+ } catch (Exception e) {
EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_error));
- prefs.putBoolean(PREF_LAST_SYNC_ATTEMPT_SUCCESS, false).apply();
+ SynchronizationSettings.setLastSynchronizationAttemptSuccess(false);
Log.e(TAG, Log.getStackTraceString(e));
- if (getRunAttemptCount() % 3 == 2) {
- // Do not spam users with notification and retry before notifying
- updateErrorNotification(e);
- }
- return Result.retry();
- }
- }
-
- public static void clearQueue(Context context) {
- executeLockedAsync(() ->
- context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE).edit()
- .putLong(PREF_LAST_SUBSCRIPTION_SYNC_TIMESTAMP, 0)
- .putLong(PREF_LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP, 0)
- .putLong(PREF_LAST_SYNC_ATTEMPT_TIMESTAMP, 0)
- .putString(PREF_QUEUED_EPISODE_ACTIONS, "[]")
- .putString(PREF_QUEUED_FEEDS_ADDED, "[]")
- .putString(PREF_QUEUED_FEEDS_REMOVED, "[]")
- .apply());
- }
- public static void enqueueFeedAdded(Context context, String downloadUrl) {
- if (!GpodnetPreferences.loggedIn()) {
- return;
- }
- executeLockedAsync(() -> {
- try {
- SharedPreferences prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
- String json = prefs.getString(PREF_QUEUED_FEEDS_ADDED, "[]");
- JSONArray queue = new JSONArray(json);
- queue.put(downloadUrl);
- prefs.edit().putString(PREF_QUEUED_FEEDS_ADDED, queue.toString()).apply();
- } catch (JSONException e) {
- e.printStackTrace();
- }
- sync(context);
- });
- }
-
- public static void enqueueFeedRemoved(Context context, String downloadUrl) {
- if (!GpodnetPreferences.loggedIn()) {
- return;
- }
- executeLockedAsync(() -> {
- try {
- SharedPreferences prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
- String json = prefs.getString(PREF_QUEUED_FEEDS_REMOVED, "[]");
- JSONArray queue = new JSONArray(json);
- queue.put(downloadUrl);
- prefs.edit().putString(PREF_QUEUED_FEEDS_REMOVED, queue.toString()).apply();
- } catch (JSONException e) {
- e.printStackTrace();
- }
- sync(context);
- });
- }
-
- public static void enqueueEpisodeAction(Context context, EpisodeAction action) {
- if (!GpodnetPreferences.loggedIn()) {
- return;
- }
- executeLockedAsync(() -> {
- try {
- SharedPreferences prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
- String json = prefs.getString(PREF_QUEUED_EPISODE_ACTIONS, "[]");
- JSONArray queue = new JSONArray(json);
- queue.put(action.writeToJsonObject());
- prefs.edit().putString(PREF_QUEUED_EPISODE_ACTIONS, queue.toString()).apply();
- } catch (JSONException e) {
- e.printStackTrace();
+ if (e instanceof SyncServiceException) {
+ if (getRunAttemptCount() % 3 == 2) {
+ // Do not spam users with notification and retry before notifying
+ updateErrorNotification(e);
+ }
+ return Result.retry();
+ } else {
+ updateErrorNotification(e);
+ return Result.failure();
}
- sync(context);
- });
- }
-
- public static void enqueueEpisodePlayed(Context context, FeedMedia media, boolean completed) {
- if (!GpodnetPreferences.loggedIn()) {
- return;
}
- if (media.getItem() == null) {
- return;
- }
- if (media.getStartPosition() < 0 || (!completed && media.getStartPosition() >= media.getPosition())) {
- return;
- }
- EpisodeAction action = new EpisodeAction.Builder(media.getItem(), EpisodeAction.PLAY)
- .currentTimestamp()
- .started(media.getStartPosition() / 1000)
- .position((completed ? media.getDuration() : media.getPosition()) / 1000)
- .total(media.getDuration() / 1000)
- .build();
- SyncService.enqueueEpisodeAction(context, action);
}
public static void sync(Context context) {
@@ -211,13 +116,8 @@ public class SyncService extends Worker {
}
public static void fullSync(Context context) {
- executeLockedAsync(() -> {
- context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE).edit()
- .putLong(PREF_LAST_SUBSCRIPTION_SYNC_TIMESTAMP, 0)
- .putLong(PREF_LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP, 0)
- .putLong(PREF_LAST_SYNC_ATTEMPT_TIMESTAMP, 0)
- .apply();
-
+ LockingAsyncExecutor.executeLockedAsync(() -> {
+ SynchronizationSettings.resetTimestamps();
OneTimeWorkRequest workRequest = getWorkRequest()
.setInitialDelay(0L, TimeUnit.SECONDS)
.build();
@@ -226,108 +126,14 @@ public class SyncService extends Worker {
});
}
- private static OneTimeWorkRequest.Builder getWorkRequest() {
- Constraints.Builder constraints = new Constraints.Builder();
- if (UserPreferences.isAllowMobileFeedRefresh()) {
- constraints.setRequiredNetworkType(NetworkType.CONNECTED);
- } else {
- constraints.setRequiredNetworkType(NetworkType.UNMETERED);
- }
-
- return new OneTimeWorkRequest.Builder(SyncService.class)
- .setConstraints(constraints.build())
- .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.MINUTES)
- .setInitialDelay(5L, TimeUnit.SECONDS); // Give it some time, so other actions can be queued
- }
-
- /**
- * Take the lock and execute runnable (to prevent changes to preferences being lost when enqueueing while sync is
- * in progress). If the lock is free, the runnable is directly executed in the calling thread to prevent overhead.
- */
- private static void executeLockedAsync(Runnable runnable) {
- if (lock.tryLock()) {
- try {
- runnable.run();
- } finally {
- lock.unlock();
- }
- } else {
- Completable.fromRunnable(() -> {
- lock.lock();
- try {
- runnable.run();
- } finally {
- lock.unlock();
- }
- }).subscribeOn(Schedulers.io())
- .subscribe();
- }
- }
-
- public static boolean isLastSyncSuccessful(Context context) {
- return context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
- .getBoolean(PREF_LAST_SYNC_ATTEMPT_SUCCESS, false);
- }
-
- public static long getLastSyncAttempt(Context context) {
- return context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
- .getLong(PREF_LAST_SYNC_ATTEMPT_TIMESTAMP, 0);
- }
-
- private List<EpisodeAction> getQueuedEpisodeActions() {
- ArrayList<EpisodeAction> actions = new ArrayList<>();
- try {
- SharedPreferences prefs = getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
- String json = prefs.getString(PREF_QUEUED_EPISODE_ACTIONS, "[]");
- JSONArray queue = new JSONArray(json);
- for (int i = 0; i < queue.length(); i++) {
- actions.add(EpisodeAction.readFromJsonObject(queue.getJSONObject(i)));
- }
- } catch (JSONException e) {
- e.printStackTrace();
- }
- return actions;
- }
-
- private List<String> getQueuedRemovedFeeds() {
- ArrayList<String> actions = new ArrayList<>();
- try {
- SharedPreferences prefs = getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
- String json = prefs.getString(PREF_QUEUED_FEEDS_REMOVED, "[]");
- JSONArray queue = new JSONArray(json);
- for (int i = 0; i < queue.length(); i++) {
- actions.add(queue.getString(i));
- }
- } catch (JSONException e) {
- e.printStackTrace();
- }
- return actions;
- }
-
- private List<String> getQueuedAddedFeeds() {
- ArrayList<String> actions = new ArrayList<>();
- try {
- SharedPreferences prefs = getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
- String json = prefs.getString(PREF_QUEUED_FEEDS_ADDED, "[]");
- JSONArray queue = new JSONArray(json);
- for (int i = 0; i < queue.length(); i++) {
- actions.add(queue.getString(i));
- }
- } catch (JSONException e) {
- e.printStackTrace();
- }
- return actions;
- }
-
- private void syncSubscriptions() throws SyncServiceException {
- final long lastSync = getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
- .getLong(PREF_LAST_SUBSCRIPTION_SYNC_TIMESTAMP, 0);
+ private void syncSubscriptions(ISyncService syncServiceImpl) throws SyncServiceException {
+ final long lastSync = SynchronizationSettings.getLastSubscriptionSynchronizationTimestamp();
final List<String> localSubscriptions = DBReader.getFeedListDownloadUrls();
SubscriptionChanges subscriptionChanges = syncServiceImpl.getSubscriptionChanges(lastSync);
long newTimeStamp = subscriptionChanges.getTimestamp();
- List<String> queuedRemovedFeeds = getQueuedRemovedFeeds();
- List<String> queuedAddedFeeds = getQueuedAddedFeeds();
+ List<String> queuedRemovedFeeds = synchronizationQueueStorage.getQueuedRemovedFeeds();
+ List<String> queuedAddedFeeds = synchronizationQueueStorage.getQueuedAddedFeeds();
Log.d(TAG, "Downloaded subscription changes: " + subscriptionChanges);
for (String downloadUrl : subscriptionChanges.getAdded()) {
@@ -359,26 +165,21 @@ public class SyncService extends Worker {
Log.d(TAG, "Added: " + StringUtils.join(queuedAddedFeeds, ", "));
Log.d(TAG, "Removed: " + StringUtils.join(queuedRemovedFeeds, ", "));
- lock.lock();
+ LockingAsyncExecutor.lock.lock();
try {
UploadChangesResponse uploadResponse = syncServiceImpl
.uploadSubscriptionChanges(queuedAddedFeeds, queuedRemovedFeeds);
- getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE).edit()
- .putString(PREF_QUEUED_FEEDS_ADDED, "[]").apply();
- getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE).edit()
- .putString(PREF_QUEUED_FEEDS_REMOVED, "[]").apply();
+ synchronizationQueueStorage.clearFeedQueues();
newTimeStamp = uploadResponse.timestamp;
} finally {
- lock.unlock();
+ LockingAsyncExecutor.lock.unlock();
}
}
- getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE).edit()
- .putLong(PREF_LAST_SUBSCRIPTION_SYNC_TIMESTAMP, newTimeStamp).apply();
+ SynchronizationSettings.setLastSubscriptionSynchronizationAttemptTimestamp(newTimeStamp);
}
- private void syncEpisodeActions() throws SyncServiceException {
- final long lastSync = getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
- .getLong(PREF_LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP, 0);
+ private void syncEpisodeActions(ISyncService syncServiceImpl) throws SyncServiceException {
+ final long lastSync = SynchronizationSettings.getLastEpisodeActionSynchronizationTimestamp();
EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_episodes_download));
EpisodeActionChanges getResponse = syncServiceImpl.getEpisodeActionChanges(lastSync);
long newTimeStamp = getResponse.getTimestamp();
@@ -387,7 +188,7 @@ public class SyncService extends Worker {
// upload local actions
EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_episodes_upload));
- List<EpisodeAction> queuedEpisodeActions = getQueuedEpisodeActions();
+ List<EpisodeAction> queuedEpisodeActions = synchronizationQueueStorage.getQueuedEpisodeActions();
if (lastSync == 0) {
EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_upload_played));
List<FeedItem> readItems = DBReader.getPlayedItems();
@@ -407,24 +208,21 @@ public class SyncService extends Worker {
}
}
if (queuedEpisodeActions.size() > 0) {
- lock.lock();
+ LockingAsyncExecutor.lock.lock();
try {
Log.d(TAG, "Uploading " + queuedEpisodeActions.size() + " actions: "
+ StringUtils.join(queuedEpisodeActions, ", "));
UploadChangesResponse postResponse = syncServiceImpl.uploadEpisodeActions(queuedEpisodeActions);
newTimeStamp = postResponse.timestamp;
Log.d(TAG, "Upload episode response: " + postResponse);
- getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE).edit()
- .putString(PREF_QUEUED_EPISODE_ACTIONS, "[]").apply();
+ synchronizationQueueStorage.clearEpisodeActionQueue();
} finally {
- lock.unlock();
+ LockingAsyncExecutor.lock.unlock();
}
}
- getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE).edit()
- .putLong(PREF_LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP, newTimeStamp).apply();
+ SynchronizationSettings.setLastEpisodeActionSynchronizationAttemptTimestamp(newTimeStamp);
}
-
private synchronized void processEpisodeActions(List<EpisodeAction> remoteActions) {
Log.d(TAG, "Processing " + remoteActions.size() + " actions");
if (remoteActions.size() == 0) {
@@ -432,7 +230,8 @@ public class SyncService extends Worker {
}
Map<Pair<String, String>, EpisodeAction> playActionsToUpdate = EpisodeActionFilter
- .getRemoteActionsOverridingLocalActions(remoteActions, getQueuedEpisodeActions());
+ .getRemoteActionsOverridingLocalActions(remoteActions,
+ synchronizationQueueStorage.getQueuedEpisodeActions());
LongList queueToBeRemoved = new LongList();
List<FeedItem> updatedItems = new ArrayList<>();
for (EpisodeAction action : playActionsToUpdate.values()) {
@@ -442,20 +241,24 @@ public class SyncService extends Worker {
Log.i(TAG, "Unknown feed item: " + action);
continue;
}
+ if (feedItem.getMedia() == null) {
+ Log.i(TAG, "Feed item has no media: " + action);
+ continue;
+ }
if (action.getAction() == EpisodeAction.NEW) {
DBWriter.markItemPlayed(feedItem, FeedItem.UNPLAYED, true);
continue;
}
- Log.d(TAG, "Most recent play action: " + action.toString());
- FeedMedia media = feedItem.getMedia();
- media.setPosition(action.getPosition() * 1000);
+ feedItem.getMedia().setPosition(action.getPosition() * 1000);
if (FeedItemUtil.hasAlmostEnded(feedItem.getMedia())) {
- Log.d(TAG, "Marking as played");
+ Log.d(TAG, "Marking as played: " + action);
feedItem.setPlayed(true);
+ feedItem.getMedia().setPosition(0);
queueToBeRemoved.add(feedItem.getId());
+ } else {
+ Log.d(TAG, "Setting position: " + action);
}
updatedItems.add(feedItem);
-
}
DBWriter.removeQueueItem(getApplicationContext(), false, queueToBeRemoved.toArray());
DBReader.loadAdditionalFeedItemListData(updatedItems);
@@ -469,7 +272,7 @@ public class SyncService extends Worker {
nm.cancel(R.id.notification_gpodnet_sync_autherror);
}
- private void updateErrorNotification(SyncServiceException exception) {
+ private void updateErrorNotification(Exception exception) {
if (!UserPreferences.gpodnetNotificationsEnabled()) {
Log.d(TAG, "Skipping sync error notification because of user setting");
return;
@@ -486,6 +289,7 @@ public class SyncService extends Worker {
NotificationUtils.CHANNEL_ID_SYNC_ERROR)
.setContentTitle(getApplicationContext().getString(R.string.gpodnetsync_error_title))
.setContentText(description)
+ .setStyle(new NotificationCompat.BigTextStyle().bigText(description))
.setContentIntent(pendingIntent)
.setSmallIcon(R.drawable.ic_notification_sync_error)
.setAutoCancel(true)
@@ -495,4 +299,36 @@ public class SyncService extends Worker {
.getSystemService(Context.NOTIFICATION_SERVICE);
nm.notify(R.id.notification_gpodnet_sync_error, notification);
}
+
+ private static OneTimeWorkRequest.Builder getWorkRequest() {
+ Constraints.Builder constraints = new Constraints.Builder();
+ if (UserPreferences.isAllowMobileFeedRefresh()) {
+ constraints.setRequiredNetworkType(NetworkType.CONNECTED);
+ } else {
+ constraints.setRequiredNetworkType(NetworkType.UNMETERED);
+ }
+
+ return new OneTimeWorkRequest.Builder(SyncService.class)
+ .setConstraints(constraints.build())
+ .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.MINUTES)
+ .setInitialDelay(5L, TimeUnit.SECONDS); // Give it some time, so other actions can be queued
+ }
+
+ private ISyncService getActiveSyncProvider() {
+ String selectedSyncProviderKey = SynchronizationSettings.getSelectedSyncProviderKey();
+ SynchronizationProviderViewData selectedService = SynchronizationProviderViewData
+ .valueOf(selectedSyncProviderKey);
+ switch (selectedService) {
+ case GPODDER_NET:
+ return new GpodnetService(AntennapodHttpClient.getHttpClient(),
+ SynchronizationCredentials.getHosturl(), SynchronizationCredentials.getDeviceID(),
+ SynchronizationCredentials.getUsername(), SynchronizationCredentials.getPassword());
+ case NEXTCLOUD_GPODDER:
+ return new NextcloudSyncService(AntennapodHttpClient.getHttpClient(),
+ SynchronizationCredentials.getHosturl(), SynchronizationCredentials.getUsername(),
+ SynchronizationCredentials.getPassword());
+ default:
+ return null;
+ }
+ }
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationCredentials.java b/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationCredentials.java
new file mode 100644
index 000000000..e08bc66ad
--- /dev/null
+++ b/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationCredentials.java
@@ -0,0 +1,67 @@
+package de.danoeh.antennapod.core.sync;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import de.danoeh.antennapod.core.ClientConfig;
+import de.danoeh.antennapod.core.preferences.UserPreferences;
+import de.danoeh.antennapod.core.sync.queue.SynchronizationQueueSink;
+
+/**
+ * Manages preferences for accessing gpodder.net service and other sync providers
+ */
+public class SynchronizationCredentials {
+
+ private SynchronizationCredentials() {
+ }
+
+ private static final String PREF_NAME = "gpodder.net";
+ private static final String PREF_USERNAME = "de.danoeh.antennapod.preferences.gpoddernet.username";
+ private static final String PREF_PASSWORD = "de.danoeh.antennapod.preferences.gpoddernet.password";
+ private static final String PREF_DEVICEID = "de.danoeh.antennapod.preferences.gpoddernet.deviceID";
+ private static final String PREF_HOSTNAME = "prefGpodnetHostname";
+
+ private static SharedPreferences getPreferences() {
+ return ClientConfig.applicationCallbacks.getApplicationInstance()
+ .getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
+ }
+
+ public static String getUsername() {
+ return getPreferences().getString(PREF_USERNAME, null);
+ }
+
+ public static void setUsername(String username) {
+ getPreferences().edit().putString(PREF_USERNAME, username).apply();
+ }
+
+ public static String getPassword() {
+ return getPreferences().getString(PREF_PASSWORD, null);
+ }
+
+ public static void setPassword(String password) {
+ getPreferences().edit().putString(PREF_PASSWORD, password).apply();
+ }
+
+ public static String getDeviceID() {
+ return getPreferences().getString(PREF_DEVICEID, null);
+ }
+
+ public static void setDeviceID(String deviceID) {
+ getPreferences().edit().putString(PREF_DEVICEID, deviceID).apply();
+ }
+
+ public static String getHosturl() {
+ return getPreferences().getString(PREF_HOSTNAME, null);
+ }
+
+ public static void setHosturl(String value) {
+ getPreferences().edit().putString(PREF_HOSTNAME, value).apply();
+ }
+
+ public static synchronized void clear(Context context) {
+ setUsername(null);
+ setPassword(null);
+ setDeviceID(null);
+ SynchronizationQueueSink.clearQueue(context);
+ UserPreferences.setGpodnetNotificationsEnabled();
+ }
+}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationProviderViewData.java b/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationProviderViewData.java
new file mode 100644
index 000000000..cba713f60
--- /dev/null
+++ b/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationProviderViewData.java
@@ -0,0 +1,47 @@
+package de.danoeh.antennapod.core.sync;
+
+import de.danoeh.antennapod.core.R;
+
+public enum SynchronizationProviderViewData {
+ GPODDER_NET(
+ "GPODDER_NET",
+ R.string.gpodnet_description,
+ R.drawable.gpodder_icon
+ ),
+ NEXTCLOUD_GPODDER(
+ "NEXTCLOUD_GPODDER",
+ R.string.synchronization_summary_nextcloud,
+ R.drawable.nextcloud_logo
+ );
+
+ public static SynchronizationProviderViewData fromIdentifier(String provider) {
+ for (SynchronizationProviderViewData synchronizationProvider : SynchronizationProviderViewData.values()) {
+ if (synchronizationProvider.getIdentifier().equals(provider)) {
+ return synchronizationProvider;
+ }
+ }
+ return null;
+ }
+
+ private final String identifier;
+ private final int iconResource;
+ private final int summaryResource;
+
+ SynchronizationProviderViewData(String identifier, int summaryResource, int iconResource) {
+ this.identifier = identifier;
+ this.iconResource = iconResource;
+ this.summaryResource = summaryResource;
+ }
+
+ public String getIdentifier() {
+ return identifier;
+ }
+
+ public int getIconResource() {
+ return iconResource;
+ }
+
+ public int getSummaryResource() {
+ return summaryResource;
+ }
+}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationSettings.java b/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationSettings.java
new file mode 100644
index 000000000..1a53ac0fb
--- /dev/null
+++ b/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationSettings.java
@@ -0,0 +1,83 @@
+package de.danoeh.antennapod.core.sync;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import de.danoeh.antennapod.core.ClientConfig;
+
+public class SynchronizationSettings {
+
+ public static final String LAST_SYNC_ATTEMPT_TIMESTAMP = "last_sync_attempt_timestamp";
+ private static final String NAME = "synchronization";
+ private static final String SELECTED_SYNC_PROVIDER = "selected_sync_provider";
+ private static final String LAST_SYNC_ATTEMPT_SUCCESS = "last_sync_attempt_success";
+ private static final String LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP = "last_episode_actions_sync_timestamp";
+ private static final String LAST_SUBSCRIPTION_SYNC_TIMESTAMP = "last_sync_timestamp";
+
+ public static boolean isProviderConnected() {
+ return getSelectedSyncProviderKey() != null;
+ }
+
+ public static void resetTimestamps() {
+ getSharedPreferences().edit()
+ .putLong(LAST_SUBSCRIPTION_SYNC_TIMESTAMP, 0)
+ .putLong(LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP, 0)
+ .putLong(LAST_SYNC_ATTEMPT_TIMESTAMP, 0)
+ .apply();
+ }
+
+ public static boolean isLastSyncSuccessful() {
+ return getSharedPreferences().getBoolean(LAST_SYNC_ATTEMPT_SUCCESS, false);
+ }
+
+ public static long getLastSyncAttempt() {
+ return getSharedPreferences().getLong(LAST_SYNC_ATTEMPT_TIMESTAMP, 0);
+ }
+
+ public static void setSelectedSyncProvider(SynchronizationProviderViewData provider) {
+ getSharedPreferences()
+ .edit()
+ .putString(SELECTED_SYNC_PROVIDER, provider == null ? null : provider.getIdentifier())
+ .apply();
+ }
+
+ public static String getSelectedSyncProviderKey() {
+ return getSharedPreferences().getString(SELECTED_SYNC_PROVIDER, null);
+ }
+
+ public static void updateLastSynchronizationAttempt() {
+ getSharedPreferences().edit()
+ .putLong(LAST_SYNC_ATTEMPT_TIMESTAMP, System.currentTimeMillis())
+ .apply();
+ }
+
+ public static void setLastSynchronizationAttemptSuccess(boolean isSuccess) {
+ getSharedPreferences().edit()
+ .putBoolean(LAST_SYNC_ATTEMPT_SUCCESS, isSuccess)
+ .apply();
+ }
+
+ public static long getLastSubscriptionSynchronizationTimestamp() {
+ return getSharedPreferences().getLong(LAST_SUBSCRIPTION_SYNC_TIMESTAMP, 0);
+ }
+
+ public static void setLastSubscriptionSynchronizationAttemptTimestamp(long newTimeStamp) {
+ getSharedPreferences().edit()
+ .putLong(LAST_SUBSCRIPTION_SYNC_TIMESTAMP, newTimeStamp).apply();
+ }
+
+ public static long getLastEpisodeActionSynchronizationTimestamp() {
+ return getSharedPreferences()
+ .getLong(LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP, 0);
+ }
+
+ public static void setLastEpisodeActionSynchronizationAttemptTimestamp(long timestamp) {
+ getSharedPreferences().edit()
+ .putLong(LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP, timestamp).apply();
+ }
+
+ private static SharedPreferences getSharedPreferences() {
+ return ClientConfig.applicationCallbacks.getApplicationInstance()
+ .getSharedPreferences(NAME, Context.MODE_PRIVATE);
+ }
+}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/queue/SynchronizationQueueSink.java b/core/src/main/java/de/danoeh/antennapod/core/sync/queue/SynchronizationQueueSink.java
new file mode 100644
index 000000000..445faf60f
--- /dev/null
+++ b/core/src/main/java/de/danoeh/antennapod/core/sync/queue/SynchronizationQueueSink.java
@@ -0,0 +1,67 @@
+package de.danoeh.antennapod.core.sync.queue;
+
+import android.content.Context;
+
+import de.danoeh.antennapod.core.sync.LockingAsyncExecutor;
+import de.danoeh.antennapod.core.sync.SyncService;
+import de.danoeh.antennapod.core.sync.SynchronizationSettings;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.net.sync.model.EpisodeAction;
+
+public class SynchronizationQueueSink {
+
+ public static void clearQueue(Context context) {
+ LockingAsyncExecutor.executeLockedAsync(new SynchronizationQueueStorage(context)::clearQueue);
+ }
+
+ public static void enqueueFeedAddedIfSynchronizationIsActive(Context context, String downloadUrl) {
+ if (!SynchronizationSettings.isProviderConnected()) {
+ return;
+ }
+ LockingAsyncExecutor.executeLockedAsync(() -> {
+ new SynchronizationQueueStorage(context).enqueueFeedAdded(downloadUrl);
+ SyncService.sync(context);
+ });
+ }
+
+ public static void enqueueFeedRemovedIfSynchronizationIsActive(Context context, String downloadUrl) {
+ if (!SynchronizationSettings.isProviderConnected()) {
+ return;
+ }
+ LockingAsyncExecutor.executeLockedAsync(() -> {
+ new SynchronizationQueueStorage(context).enqueueFeedRemoved(downloadUrl);
+ SyncService.sync(context);
+ });
+ }
+
+ public static void enqueueEpisodeActionIfSynchronizationIsActive(Context context, EpisodeAction action) {
+ if (!SynchronizationSettings.isProviderConnected()) {
+ return;
+ }
+ LockingAsyncExecutor.executeLockedAsync(() -> {
+ new SynchronizationQueueStorage(context).enqueueEpisodeAction(action);
+ SyncService.sync(context);
+ });
+ }
+
+ public static void enqueueEpisodePlayedIfSynchronizationIsActive(Context context, FeedMedia media,
+ boolean completed) {
+ if (!SynchronizationSettings.isProviderConnected()) {
+ return;
+ }
+ if (media.getItem() == null) {
+ return;
+ }
+ if (media.getStartPosition() < 0 || (!completed && media.getStartPosition() >= media.getPosition())) {
+ return;
+ }
+ EpisodeAction action = new EpisodeAction.Builder(media.getItem(), EpisodeAction.PLAY)
+ .currentTimestamp()
+ .started(media.getStartPosition() / 1000)
+ .position((completed ? media.getDuration() : media.getPosition()) / 1000)
+ .total(media.getDuration() / 1000)
+ .build();
+ enqueueEpisodeActionIfSynchronizationIsActive(context, action);
+ }
+
+}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/queue/SynchronizationQueueStorage.java b/core/src/main/java/de/danoeh/antennapod/core/sync/queue/SynchronizationQueueStorage.java
new file mode 100644
index 000000000..5c6d58fe3
--- /dev/null
+++ b/core/src/main/java/de/danoeh/antennapod/core/sync/queue/SynchronizationQueueStorage.java
@@ -0,0 +1,140 @@
+package de.danoeh.antennapod.core.sync.queue;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+
+import java.util.ArrayList;
+
+import de.danoeh.antennapod.core.sync.SynchronizationSettings;
+import de.danoeh.antennapod.net.sync.model.EpisodeAction;
+
+public class SynchronizationQueueStorage {
+
+ private static final String NAME = "synchronization";
+ private static final String QUEUED_EPISODE_ACTIONS = "sync_queued_episode_actions";
+ private static final String QUEUED_FEEDS_REMOVED = "sync_removed";
+ private static final String QUEUED_FEEDS_ADDED = "sync_added";
+ private final SharedPreferences sharedPreferences;
+
+ public SynchronizationQueueStorage(Context context) {
+ this.sharedPreferences = context.getSharedPreferences(NAME, Context.MODE_PRIVATE);
+ }
+
+ public ArrayList<EpisodeAction> getQueuedEpisodeActions() {
+ ArrayList<EpisodeAction> actions = new ArrayList<>();
+ try {
+ String json = getSharedPreferences()
+ .getString(QUEUED_EPISODE_ACTIONS, "[]");
+ JSONArray queue = new JSONArray(json);
+ for (int i = 0; i < queue.length(); i++) {
+ actions.add(EpisodeAction.readFromJsonObject(queue.getJSONObject(i)));
+ }
+ } catch (JSONException e) {
+ e.printStackTrace();
+ }
+ return actions;
+ }
+
+ public ArrayList<String> getQueuedRemovedFeeds() {
+ ArrayList<String> removedFeedUrls = new ArrayList<>();
+ try {
+ String json = getSharedPreferences()
+ .getString(QUEUED_FEEDS_REMOVED, "[]");
+ JSONArray queue = new JSONArray(json);
+ for (int i = 0; i < queue.length(); i++) {
+ removedFeedUrls.add(queue.getString(i));
+ }
+ } catch (JSONException e) {
+ e.printStackTrace();
+ }
+ return removedFeedUrls;
+
+ }
+
+ public ArrayList<String> getQueuedAddedFeeds() {
+ ArrayList<String> addedFeedUrls = new ArrayList<>();
+ try {
+ String json = getSharedPreferences()
+ .getString(QUEUED_FEEDS_ADDED, "[]");
+ JSONArray queue = new JSONArray(json);
+ for (int i = 0; i < queue.length(); i++) {
+ addedFeedUrls.add(queue.getString(i));
+ }
+ } catch (JSONException e) {
+ e.printStackTrace();
+ }
+ return addedFeedUrls;
+ }
+
+ public void clearEpisodeActionQueue() {
+ getSharedPreferences().edit()
+ .putString(QUEUED_EPISODE_ACTIONS, "[]").apply();
+
+ }
+
+ public void clearFeedQueues() {
+ getSharedPreferences().edit()
+ .putString(QUEUED_FEEDS_ADDED, "[]")
+ .putString(QUEUED_FEEDS_REMOVED, "[]")
+ .apply();
+ }
+
+ protected void clearQueue() {
+ SynchronizationSettings.resetTimestamps();
+ getSharedPreferences().edit()
+ .putString(QUEUED_EPISODE_ACTIONS, "[]")
+ .putString(QUEUED_FEEDS_ADDED, "[]")
+ .putString(QUEUED_FEEDS_REMOVED, "[]")
+ .apply();
+
+ }
+
+ protected void enqueueFeedAdded(String downloadUrl) {
+ SharedPreferences sharedPreferences = getSharedPreferences();
+ String json = sharedPreferences
+ .getString(QUEUED_FEEDS_ADDED, "[]");
+ try {
+ JSONArray queue = new JSONArray(json);
+ queue.put(downloadUrl);
+ sharedPreferences
+ .edit().putString(QUEUED_FEEDS_ADDED, queue.toString()).apply();
+
+ } catch (JSONException jsonException) {
+ jsonException.printStackTrace();
+ }
+ }
+
+ protected void enqueueFeedRemoved(String downloadUrl) {
+ SharedPreferences sharedPreferences = getSharedPreferences();
+ String json = sharedPreferences.getString(QUEUED_FEEDS_REMOVED, "[]");
+ try {
+ JSONArray queue = new JSONArray(json);
+ queue.put(downloadUrl);
+ sharedPreferences.edit().putString(QUEUED_FEEDS_REMOVED, queue.toString())
+ .apply();
+ } catch (JSONException jsonException) {
+ jsonException.printStackTrace();
+ }
+ }
+
+ protected void enqueueEpisodeAction(EpisodeAction action) {
+ SharedPreferences sharedPreferences = getSharedPreferences();
+ String json = sharedPreferences.getString(QUEUED_EPISODE_ACTIONS, "[]");
+ try {
+ JSONArray queue = new JSONArray(json);
+ queue.put(action.writeToJsonObject());
+ sharedPreferences.edit().putString(
+ QUEUED_EPISODE_ACTIONS, queue.toString()
+ ).apply();
+ } catch (JSONException jsonException) {
+ jsonException.printStackTrace();
+ }
+ }
+
+ private SharedPreferences getSharedPreferences() {
+ return sharedPreferences;
+ }
+}
diff --git a/core/src/main/res/drawable-nodpi/nextcloud_logo.png b/core/src/main/res/drawable-nodpi/nextcloud_logo.png
new file mode 100644
index 000000000..2164e37fb
--- /dev/null
+++ b/core/src/main/res/drawable-nodpi/nextcloud_logo.png
Binary files differ
diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml
index 5f509a9b6..7aa32abe3 100644
--- a/core/src/main/res/values/strings.xml
+++ b/core/src/main/res/values/strings.xml
@@ -356,7 +356,7 @@
<string name="storage_sum">Episode auto delete, Import, Export</string>
<string name="project_pref">Project</string>
<string name="synchronization_pref">Synchronization</string>
- <string name="synchronization_sum">Synchronize with other devices using gpodder.net</string>
+ <string name="synchronization_sum">Synchronize with other devices</string>
<string name="automation">Automation</string>
<string name="download_pref_details">Details</string>
<string name="import_export_pref">Import/Export</string>
@@ -447,17 +447,20 @@
<string name="pref_theme_title_dark">Dark</string>
<string name="pref_theme_title_trueblack">Black (AMOLED ready)</string>
<string name="pref_episode_cache_unlimited">Unlimited</string>
- <string name="pref_gpodnet_authenticate_title">Login</string>
- <string name="pref_gpodnet_authenticate_sum">Login with your gpodder.net account in order to sync your subscriptions.</string>
- <string name="pref_gpodnet_logout_title">Logout</string>
- <string name="pref_gpodnet_logout_toast">Logout was successful</string>
+ <string name="synchronization_logout">Logout</string>
+ <string name="pref_synchronization_logout_toast">Logout was successful</string>
<string name="pref_gpodnet_setlogin_information_title">Change login information</string>
<string name="pref_gpodnet_setlogin_information_sum">Change the login information for your gpodder.net account.</string>
- <string name="pref_gpodnet_sync_changes_title">Synchronize now</string>
- <string name="pref_gpodnet_sync_changes_sum">Sync subscription and episode state changes with gpodder.net.</string>
- <string name="pref_gpodnet_full_sync_title">Force full synchronization</string>
- <string name="pref_gpodnet_full_sync_sum">Sync all subscriptions and episode states with gpodder.net.</string>
- <string name="pref_gpodnet_login_status"><![CDATA[Logged in as <i>%1$s</i> with device <i>%2$s</i>]]></string>
+ <string name="synchronization_sync_changes_title">Synchronize now</string>
+ <string name="synchronization_full_sync_title">Force full synchronization</string>
+ <string name="synchronization_login_status"><![CDATA[Logged in as <i>%1$s</i> on <i>%2$s</i>. <br/><br/>You can choose your synchronization provider again once you have logged out]]></string>
+ <string name="synchronization_summary_unchoosen">You can choose from multiple providers to synchronize your subscriptions and episode play state with</string>
+ <string name="synchronization_summary_nextcloud">Gpoddersync is an open-source Nextcloud app that you can easily install on your own server. The app is independent of the AntennaPod project.</string>
+ <string name="synchronization_nextcloud_authenticate_browser">Grant access using the opened web browser and come back to AntennaPod.</string>
+ <string name="synchronization_choose_title">Choose synchronization provider</string>
+ <string name="synchronization_force_sync_summary">Re-synchronize all subscriptions and episode states</string>
+ <string name="synchronization_sync_summary">Synchronize subscription and episode state changes</string>
+ <string name="dialog_choose_sync_service_title">Choose synchronization provider</string>
<string name="pref_playback_speed_sum">Customize the speeds available for variable speed playback</string>
<string name="pref_feed_playback_speed_sum">The speed to use when starting audio playback for episodes in this podcast</string>
<string name="pref_feed_skip">Auto Skip</string>
diff --git a/core/src/test/java/de/danoeh/antennapod/core/sync/GuidValidatorTest.java b/core/src/test/java/de/danoeh/antennapod/core/sync/GuidValidatorTest.java
index 356a7f77e..552f7d70a 100644
--- a/core/src/test/java/de/danoeh/antennapod/core/sync/GuidValidatorTest.java
+++ b/core/src/test/java/de/danoeh/antennapod/core/sync/GuidValidatorTest.java
@@ -14,5 +14,6 @@ public class GuidValidatorTest extends TestCase {
assertFalse(GuidValidator.isValidGuid("\n"));
assertFalse(GuidValidator.isValidGuid(" \n"));
assertFalse(GuidValidator.isValidGuid(null));
+ assertFalse(GuidValidator.isValidGuid("null"));
}
} \ No newline at end of file