summaryrefslogtreecommitdiff
path: root/net/sync
diff options
context:
space:
mode:
Diffstat (limited to 'net/sync')
-rw-r--r--net/sync/service-interface/README.md3
-rw-r--r--net/sync/service-interface/build.gradle19
-rw-r--r--net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/LockingAsyncExecutor.java43
-rw-r--r--net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationProviderViewData.java45
-rw-r--r--net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationQueueSink.java81
-rw-r--r--net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationQueueStorage.java158
-rw-r--r--net/sync/service-interface/src/main/res/drawable-nodpi/gpodder_icon.pngbin0 -> 32098 bytes
-rw-r--r--net/sync/service-interface/src/main/res/drawable-nodpi/nextcloud_logo.pngbin0 -> 3432 bytes
-rw-r--r--net/sync/service-interface/src/main/res/values/ids.xml5
-rw-r--r--net/sync/service/README.md3
-rw-r--r--net/sync/service/build.gradle36
-rw-r--r--net/sync/service/src/main/java/de/danoeh/antennapod/net/sync/service/EpisodeActionFilter.java77
-rw-r--r--net/sync/service/src/main/java/de/danoeh/antennapod/net/sync/service/GuidValidator.java11
-rw-r--r--net/sync/service/src/main/java/de/danoeh/antennapod/net/sync/service/SyncService.java390
-rw-r--r--net/sync/service/src/test/java/de/danoeh/antennapod/net/sync/service/EpisodeActionFilterTest.java212
-rw-r--r--net/sync/service/src/test/java/de/danoeh/antennapod/net/sync/service/GuidValidatorTest.java19
16 files changed, 1102 insertions, 0 deletions
diff --git a/net/sync/service-interface/README.md b/net/sync/service-interface/README.md
new file mode 100644
index 000000000..2b6a3c412
--- /dev/null
+++ b/net/sync/service-interface/README.md
@@ -0,0 +1,3 @@
+# :net:sync:service-interface
+
+This module contains the interface for starting the sync service.
diff --git a/net/sync/service-interface/build.gradle b/net/sync/service-interface/build.gradle
new file mode 100644
index 000000000..c1a559da3
--- /dev/null
+++ b/net/sync/service-interface/build.gradle
@@ -0,0 +1,19 @@
+plugins {
+ id("com.android.library")
+}
+apply from: "../../../common.gradle"
+
+android {
+ namespace "de.danoeh.antennapod.net.sync.serviceinterface"
+}
+
+dependencies {
+ implementation project(':model')
+ implementation project(':net:sync:model')
+ implementation project(':storage:preferences')
+ implementation project(':ui:i18n')
+
+ annotationProcessor "androidx.annotation:annotation:$annotationVersion"
+ implementation "io.reactivex.rxjava2:rxandroid:$rxAndroidVersion"
+ implementation "io.reactivex.rxjava2:rxjava:$rxJavaVersion"
+}
diff --git a/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/LockingAsyncExecutor.java b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/LockingAsyncExecutor.java
new file mode 100644
index 000000000..2703cf018
--- /dev/null
+++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/LockingAsyncExecutor.java
@@ -0,0 +1,43 @@
+package de.danoeh.antennapod.net.sync.serviceinterface;
+
+import java.util.concurrent.locks.ReentrantLock;
+
+import io.reactivex.Completable;
+import io.reactivex.schedulers.Schedulers;
+
+public class LockingAsyncExecutor {
+
+ private 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();
+ }
+ }
+
+ public static void unlock() {
+ lock.unlock();
+ }
+
+ public static void lock() {
+ lock.lock();
+ }
+}
diff --git a/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationProviderViewData.java b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationProviderViewData.java
new file mode 100644
index 000000000..19624a95a
--- /dev/null
+++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationProviderViewData.java
@@ -0,0 +1,45 @@
+package de.danoeh.antennapod.net.sync.serviceinterface;
+
+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/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationQueueSink.java b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationQueueSink.java
new file mode 100644
index 000000000..8c94c44e7
--- /dev/null
+++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationQueueSink.java
@@ -0,0 +1,81 @@
+package de.danoeh.antennapod.net.sync.serviceinterface;
+
+import android.content.Context;
+
+import de.danoeh.antennapod.storage.preferences.SynchronizationSettings;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.net.sync.model.EpisodeAction;
+
+public class SynchronizationQueueSink {
+ // To avoid a dependency loop of every class to SyncService, and from SyncService back to every class.
+ private static Runnable serviceStarterImpl = () -> { };
+
+ public static void setServiceStarterImpl(Runnable serviceStarter) {
+ serviceStarterImpl = serviceStarter;
+ }
+
+ public static void syncNow() {
+ serviceStarterImpl.run();
+ }
+
+ public static void syncNowIfNotSyncedRecently() {
+ if (System.currentTimeMillis() - SynchronizationSettings.getLastSyncAttempt() > 1000 * 60 * 10) {
+ syncNow();
+ }
+ }
+
+ 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);
+ syncNow();
+ });
+ }
+
+ public static void enqueueFeedRemovedIfSynchronizationIsActive(Context context, String downloadUrl) {
+ if (!SynchronizationSettings.isProviderConnected()) {
+ return;
+ }
+ LockingAsyncExecutor.executeLockedAsync(() -> {
+ new SynchronizationQueueStorage(context).enqueueFeedRemoved(downloadUrl);
+ syncNow();
+ });
+ }
+
+ public static void enqueueEpisodeActionIfSynchronizationIsActive(Context context, EpisodeAction action) {
+ if (!SynchronizationSettings.isProviderConnected()) {
+ return;
+ }
+ LockingAsyncExecutor.executeLockedAsync(() -> {
+ new SynchronizationQueueStorage(context).enqueueEpisodeAction(action);
+ syncNow();
+ });
+ }
+
+ public static void enqueueEpisodePlayedIfSynchronizationIsActive(Context context, FeedMedia media,
+ boolean completed) {
+ if (!SynchronizationSettings.isProviderConnected()) {
+ return;
+ }
+ if (media.getItem() == null || media.getItem().getFeed().isLocalFeed()) {
+ 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/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationQueueStorage.java b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationQueueStorage.java
new file mode 100644
index 000000000..0ae794ac8
--- /dev/null
+++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationQueueStorage.java
@@ -0,0 +1,158 @@
+package de.danoeh.antennapod.net.sync.serviceinterface;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+
+import java.util.ArrayList;
+
+import de.danoeh.antennapod.storage.preferences.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();
+ try {
+ JSONArray addedQueue = new JSONArray(sharedPreferences.getString(QUEUED_FEEDS_ADDED, "[]"));
+ addedQueue.put(downloadUrl);
+ JSONArray removedQueue = new JSONArray(sharedPreferences.getString(QUEUED_FEEDS_REMOVED, "[]"));
+ removedQueue.remove(indexOf(downloadUrl, removedQueue));
+ sharedPreferences.edit()
+ .putString(QUEUED_FEEDS_ADDED, addedQueue.toString())
+ .putString(QUEUED_FEEDS_REMOVED, removedQueue.toString())
+ .apply();
+
+ } catch (JSONException jsonException) {
+ jsonException.printStackTrace();
+ }
+ }
+
+ protected void enqueueFeedRemoved(String downloadUrl) {
+ SharedPreferences sharedPreferences = getSharedPreferences();
+ try {
+ JSONArray removedQueue = new JSONArray(sharedPreferences.getString(QUEUED_FEEDS_REMOVED, "[]"));
+ removedQueue.put(downloadUrl);
+ JSONArray addedQueue = new JSONArray(sharedPreferences.getString(QUEUED_FEEDS_ADDED, "[]"));
+ addedQueue.remove(indexOf(downloadUrl, addedQueue));
+ sharedPreferences.edit()
+ .putString(QUEUED_FEEDS_ADDED, addedQueue.toString())
+ .putString(QUEUED_FEEDS_REMOVED, removedQueue.toString())
+ .apply();
+ } catch (JSONException jsonException) {
+ jsonException.printStackTrace();
+ }
+ }
+
+ private int indexOf(String string, JSONArray array) {
+ try {
+ for (int i = 0; i < array.length(); i++) {
+ if (array.getString(i).equals(string)) {
+ return i;
+ }
+ }
+ } catch (JSONException jsonException) {
+ jsonException.printStackTrace();
+ }
+ return -1;
+ }
+
+ 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/net/sync/service-interface/src/main/res/drawable-nodpi/gpodder_icon.png b/net/sync/service-interface/src/main/res/drawable-nodpi/gpodder_icon.png
new file mode 100644
index 000000000..cd133aa98
--- /dev/null
+++ b/net/sync/service-interface/src/main/res/drawable-nodpi/gpodder_icon.png
Binary files differ
diff --git a/net/sync/service-interface/src/main/res/drawable-nodpi/nextcloud_logo.png b/net/sync/service-interface/src/main/res/drawable-nodpi/nextcloud_logo.png
new file mode 100644
index 000000000..2164e37fb
--- /dev/null
+++ b/net/sync/service-interface/src/main/res/drawable-nodpi/nextcloud_logo.png
Binary files differ
diff --git a/net/sync/service-interface/src/main/res/values/ids.xml b/net/sync/service-interface/src/main/res/values/ids.xml
new file mode 100644
index 000000000..842e421ea
--- /dev/null
+++ b/net/sync/service-interface/src/main/res/values/ids.xml
@@ -0,0 +1,5 @@
+<resources>
+ <item name="notification_gpodnet_sync_error" type="id"/>
+ <item name="notification_gpodnet_sync_autherror" type="id"/>
+ <item name="pending_intent_sync_error" type="id"/>
+</resources>
diff --git a/net/sync/service/README.md b/net/sync/service/README.md
new file mode 100644
index 000000000..e4ce70c58
--- /dev/null
+++ b/net/sync/service/README.md
@@ -0,0 +1,3 @@
+# :net:sync:service
+
+This module contains the sync service.
diff --git a/net/sync/service/build.gradle b/net/sync/service/build.gradle
new file mode 100644
index 000000000..7fa19d320
--- /dev/null
+++ b/net/sync/service/build.gradle
@@ -0,0 +1,36 @@
+plugins {
+ id("com.android.library")
+}
+apply from: "../../../common.gradle"
+apply from: "../../../playFlavor.gradle"
+
+android {
+ namespace "de.danoeh.antennapod.net.sync.service"
+}
+
+dependencies {
+ implementation project(':event')
+ implementation project(':model')
+ implementation project(':net:common')
+ implementation project(':net:sync:gpoddernet')
+ implementation project(':net:sync:model')
+ implementation project(':net:sync:service-interface')
+ implementation project(':storage:database')
+ implementation project(':storage:preferences')
+ implementation project(':ui:notifications')
+ implementation project(':ui:i18n')
+ implementation project(':net:download:service-interface')
+
+ annotationProcessor "androidx.annotation:annotation:$annotationVersion"
+ implementation "androidx.core:core:$coreVersion"
+ implementation "androidx.work:work-runtime:$workManagerVersion"
+
+ implementation "org.greenrobot:eventbus:$eventbusVersion"
+ implementation "org.apache.commons:commons-lang3:$commonslangVersion"
+ implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
+ implementation "io.reactivex.rxjava2:rxandroid:$rxAndroidVersion"
+ implementation "io.reactivex.rxjava2:rxjava:$rxJavaVersion"
+ implementation "com.google.guava:guava:31.0.1-android"
+
+ testImplementation "junit:junit:$junitVersion"
+}
diff --git a/net/sync/service/src/main/java/de/danoeh/antennapod/net/sync/service/EpisodeActionFilter.java b/net/sync/service/src/main/java/de/danoeh/antennapod/net/sync/service/EpisodeActionFilter.java
new file mode 100644
index 000000000..42fc1b7b9
--- /dev/null
+++ b/net/sync/service/src/main/java/de/danoeh/antennapod/net/sync/service/EpisodeActionFilter.java
@@ -0,0 +1,77 @@
+package de.danoeh.antennapod.net.sync.service;
+
+import android.util.Log;
+
+import androidx.collection.ArrayMap;
+import androidx.core.util.Pair;
+
+import java.util.List;
+import java.util.Map;
+
+import de.danoeh.antennapod.net.sync.model.EpisodeAction;
+
+public class EpisodeActionFilter {
+
+ public static final String TAG = "EpisodeActionFilter";
+
+ public static Map<Pair<String, String>, EpisodeAction> getRemoteActionsOverridingLocalActions(
+ List<EpisodeAction> remoteActions,
+ List<EpisodeAction> queuedEpisodeActions) {
+ // make sure more recent local actions are not overwritten by older remote actions
+ Map<Pair<String, String>, EpisodeAction> remoteActionsThatOverrideLocalActions = new ArrayMap<>();
+ Map<Pair<String, String>, EpisodeAction> localMostRecentPlayActions =
+ createUniqueLocalMostRecentPlayActions(queuedEpisodeActions);
+ for (EpisodeAction remoteAction : remoteActions) {
+ Pair<String, String> key = new Pair<>(remoteAction.getPodcast(), remoteAction.getEpisode());
+ switch (remoteAction.getAction()) {
+ case NEW:
+ case DOWNLOAD:
+ break;
+ case PLAY:
+ EpisodeAction localMostRecent = localMostRecentPlayActions.get(key);
+ if (secondActionOverridesFirstAction(remoteAction, localMostRecent)) {
+ break;
+ }
+ EpisodeAction remoteMostRecentAction = remoteActionsThatOverrideLocalActions.get(key);
+ if (secondActionOverridesFirstAction(remoteAction, remoteMostRecentAction)) {
+ break;
+ }
+ remoteActionsThatOverrideLocalActions.put(key, remoteAction);
+ break;
+ case DELETE:
+ // NEVER EVER call DBWriter.deleteFeedMediaOfItem() here, leads to an infinite loop
+ break;
+ default:
+ Log.e(TAG, "Unknown remoteAction: " + remoteAction);
+ break;
+ }
+ }
+
+ return remoteActionsThatOverrideLocalActions;
+ }
+
+ private static Map<Pair<String, String>, EpisodeAction> createUniqueLocalMostRecentPlayActions(
+ List<EpisodeAction> queuedEpisodeActions) {
+ Map<Pair<String, String>, EpisodeAction> localMostRecentPlayAction;
+ localMostRecentPlayAction = new ArrayMap<>();
+ for (EpisodeAction action : queuedEpisodeActions) {
+ Pair<String, String> key = new Pair<>(action.getPodcast(), action.getEpisode());
+ EpisodeAction mostRecent = localMostRecentPlayAction.get(key);
+ if (mostRecent == null || mostRecent.getTimestamp() == null) {
+ localMostRecentPlayAction.put(key, action);
+ } else if (mostRecent.getTimestamp().before(action.getTimestamp())) {
+ localMostRecentPlayAction.put(key, action);
+ }
+ }
+ return localMostRecentPlayAction;
+ }
+
+ private static boolean secondActionOverridesFirstAction(EpisodeAction firstAction,
+ EpisodeAction secondAction) {
+ return secondAction != null
+ && secondAction.getTimestamp() != null
+ && (firstAction.getTimestamp() == null
+ || secondAction.getTimestamp().after(firstAction.getTimestamp()));
+ }
+
+}
diff --git a/net/sync/service/src/main/java/de/danoeh/antennapod/net/sync/service/GuidValidator.java b/net/sync/service/src/main/java/de/danoeh/antennapod/net/sync/service/GuidValidator.java
new file mode 100644
index 000000000..98ae8037e
--- /dev/null
+++ b/net/sync/service/src/main/java/de/danoeh/antennapod/net/sync/service/GuidValidator.java
@@ -0,0 +1,11 @@
+package de.danoeh.antennapod.net.sync.service;
+
+public class GuidValidator {
+
+ public static boolean isValidGuid(String guid) {
+ return guid != null
+ && !guid.trim().isEmpty()
+ && !guid.equals("null");
+ }
+}
+
diff --git a/net/sync/service/src/main/java/de/danoeh/antennapod/net/sync/service/SyncService.java b/net/sync/service/src/main/java/de/danoeh/antennapod/net/sync/service/SyncService.java
new file mode 100644
index 000000000..f52c2b81d
--- /dev/null
+++ b/net/sync/service/src/main/java/de/danoeh/antennapod/net/sync/service/SyncService.java
@@ -0,0 +1,390 @@
+package de.danoeh.antennapod.net.sync.service;
+
+import android.Manifest;
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.os.Build;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.core.app.NotificationCompat;
+import androidx.core.content.ContextCompat;
+import androidx.core.util.Pair;
+import androidx.work.BackoffPolicy;
+import androidx.work.Constraints;
+import androidx.work.ExistingWorkPolicy;
+import androidx.work.NetworkType;
+import androidx.work.OneTimeWorkRequest;
+import androidx.work.WorkManager;
+import androidx.work.Worker;
+import androidx.work.WorkerParameters;
+
+import de.danoeh.antennapod.event.FeedUpdateRunningEvent;
+import de.danoeh.antennapod.event.MessageEvent;
+import de.danoeh.antennapod.model.feed.FeedItemFilter;
+import de.danoeh.antennapod.model.feed.SortOrder;
+import de.danoeh.antennapod.net.download.serviceinterface.FeedUpdateManager;
+import de.danoeh.antennapod.net.sync.serviceinterface.LockingAsyncExecutor;
+import de.danoeh.antennapod.net.sync.serviceinterface.SynchronizationProviderViewData;
+import de.danoeh.antennapod.net.sync.serviceinterface.SynchronizationQueueStorage;
+import de.danoeh.antennapod.storage.database.DBWriter;
+import de.danoeh.antennapod.storage.database.FeedDatabaseWriter;
+import de.danoeh.antennapod.storage.preferences.SynchronizationCredentials;
+import de.danoeh.antennapod.storage.preferences.SynchronizationSettings;
+import de.danoeh.antennapod.ui.notifications.NotificationUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.greenrobot.eventbus.EventBus;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import de.danoeh.antennapod.event.SyncServiceEvent;
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+import de.danoeh.antennapod.net.common.AntennapodHttpClient;
+import de.danoeh.antennapod.storage.database.DBReader;
+import de.danoeh.antennapod.storage.database.LongList;
+import de.danoeh.antennapod.net.common.UrlChecker;
+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;
+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 de.danoeh.antennapod.net.sync.nextcloud.NextcloudSyncService;
+
+public class SyncService extends Worker {
+ public static final String TAG = "SyncService";
+ private static final String WORK_ID_SYNC = "SyncServiceWorkId";
+
+ private static boolean isCurrentlyActive = false;
+ private final SynchronizationQueueStorage synchronizationQueueStorage;
+
+ public SyncService(@NonNull Context context, @NonNull WorkerParameters params) {
+ super(context, params);
+ synchronizationQueueStorage = new SynchronizationQueueStorage(context);
+ }
+
+ @Override
+ @NonNull
+ public Result doWork() {
+ ISyncService activeSyncProvider = getActiveSyncProvider();
+ if (activeSyncProvider == null) {
+ return Result.success();
+ }
+
+ SynchronizationSettings.updateLastSynchronizationAttempt();
+ setCurrentlyActive(true);
+ try {
+ activeSyncProvider.login();
+ syncSubscriptions(activeSyncProvider);
+ waitForDownloadServiceCompleted();
+ syncEpisodeActions(activeSyncProvider);
+ activeSyncProvider.logout();
+ clearErrorNotifications();
+ EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_success));
+ SynchronizationSettings.setLastSynchronizationAttemptSuccess(true);
+ return Result.success();
+ } catch (Exception e) {
+ EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_error));
+ SynchronizationSettings.setLastSynchronizationAttemptSuccess(false);
+ Log.e(TAG, Log.getStackTraceString(e));
+
+ 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();
+ }
+ } finally {
+ setCurrentlyActive(false);
+ }
+ }
+
+ private static void setCurrentlyActive(boolean active) {
+ isCurrentlyActive = active;
+ }
+
+ public static void sync(Context context) {
+ OneTimeWorkRequest workRequest = getWorkRequest().build();
+ WorkManager.getInstance(context).enqueueUniqueWork(WORK_ID_SYNC, ExistingWorkPolicy.REPLACE, workRequest);
+ }
+
+ public static void syncImmediately(Context context) {
+ OneTimeWorkRequest workRequest = getWorkRequest()
+ .setInitialDelay(0L, TimeUnit.SECONDS)
+ .build();
+ WorkManager.getInstance(context).enqueueUniqueWork(WORK_ID_SYNC, ExistingWorkPolicy.REPLACE, workRequest);
+ }
+
+ public static void fullSync(Context context) {
+ LockingAsyncExecutor.executeLockedAsync(() -> {
+ SynchronizationSettings.resetTimestamps();
+ OneTimeWorkRequest workRequest = getWorkRequest()
+ .setInitialDelay(0L, TimeUnit.SECONDS)
+ .build();
+ WorkManager.getInstance(context).enqueueUniqueWork(WORK_ID_SYNC, ExistingWorkPolicy.REPLACE, workRequest);
+ });
+ }
+
+ private void syncSubscriptions(ISyncService syncServiceImpl) throws SyncServiceException {
+ final long lastSync = SynchronizationSettings.getLastSubscriptionSynchronizationTimestamp();
+ EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_subscriptions));
+ final List<String> localSubscriptions = DBReader.getFeedListDownloadUrls();
+ SubscriptionChanges subscriptionChanges = syncServiceImpl.getSubscriptionChanges(lastSync);
+ long newTimeStamp = subscriptionChanges.getTimestamp();
+
+ List<String> queuedRemovedFeeds = synchronizationQueueStorage.getQueuedRemovedFeeds();
+ List<String> queuedAddedFeeds = synchronizationQueueStorage.getQueuedAddedFeeds();
+
+ Log.d(TAG, "Downloaded subscription changes: " + subscriptionChanges);
+ for (String downloadUrl : subscriptionChanges.getAdded()) {
+ if (!downloadUrl.startsWith("http")) { // Also matches https
+ Log.d(TAG, "Skipping url: " + downloadUrl);
+ continue;
+ }
+ if (!UrlChecker.containsUrl(localSubscriptions, downloadUrl) && !queuedRemovedFeeds.contains(downloadUrl)) {
+ Feed feed = new Feed(downloadUrl, null, "Unknown podcast");
+ feed.setItems(Collections.emptyList());
+ Feed newFeed = FeedDatabaseWriter.updateFeed(getApplicationContext(), feed, false);
+ FeedUpdateManager.getInstance().runOnce(getApplicationContext(), newFeed);
+ }
+ }
+
+ // remove subscription if not just subscribed (again)
+ for (String downloadUrl : subscriptionChanges.getRemoved()) {
+ if (!queuedAddedFeeds.contains(downloadUrl)) {
+ DBWriter.removeFeedWithDownloadUrl(getApplicationContext(), downloadUrl);
+ }
+ }
+
+ if (lastSync == 0) {
+ Log.d(TAG, "First sync. Adding all local subscriptions.");
+ queuedAddedFeeds = localSubscriptions;
+ queuedAddedFeeds.removeAll(subscriptionChanges.getAdded());
+ queuedRemovedFeeds.removeAll(subscriptionChanges.getRemoved());
+ }
+
+ if (queuedAddedFeeds.size() > 0 || queuedRemovedFeeds.size() > 0) {
+ Log.d(TAG, "Added: " + StringUtils.join(queuedAddedFeeds, ", "));
+ Log.d(TAG, "Removed: " + StringUtils.join(queuedRemovedFeeds, ", "));
+
+ LockingAsyncExecutor.lock();
+ try {
+ UploadChangesResponse uploadResponse = syncServiceImpl
+ .uploadSubscriptionChanges(queuedAddedFeeds, queuedRemovedFeeds);
+ synchronizationQueueStorage.clearFeedQueues();
+ newTimeStamp = uploadResponse.timestamp;
+ } finally {
+ LockingAsyncExecutor.unlock();
+ }
+ }
+ SynchronizationSettings.setLastSubscriptionSynchronizationAttemptTimestamp(newTimeStamp);
+ }
+
+ private void waitForDownloadServiceCompleted() {
+ EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_wait_for_downloads));
+ try {
+ while (true) {
+ //noinspection BusyWait
+ Thread.sleep(1000);
+ FeedUpdateRunningEvent event = EventBus.getDefault().getStickyEvent(FeedUpdateRunningEvent.class);
+ if (event == null || !event.isFeedUpdateRunning) {
+ return;
+ }
+ }
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ }
+ }
+
+ 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();
+ List<EpisodeAction> remoteActions = getResponse.getEpisodeActions();
+ processEpisodeActions(remoteActions);
+
+ // upload local actions
+ EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_episodes_upload));
+ List<EpisodeAction> queuedEpisodeActions = synchronizationQueueStorage.getQueuedEpisodeActions();
+ if (lastSync == 0) {
+ EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_upload_played));
+ List<FeedItem> readItems = DBReader.getEpisodes(0, Integer.MAX_VALUE,
+ new FeedItemFilter(FeedItemFilter.PLAYED), SortOrder.DATE_NEW_OLD);
+ Log.d(TAG, "First sync. Upload state for all " + readItems.size() + " played episodes");
+ for (FeedItem item : readItems) {
+ FeedMedia media = item.getMedia();
+ if (media == null) {
+ continue;
+ }
+ EpisodeAction played = new EpisodeAction.Builder(item, EpisodeAction.PLAY)
+ .currentTimestamp()
+ .started(media.getDuration() / 1000)
+ .position(media.getDuration() / 1000)
+ .total(media.getDuration() / 1000)
+ .build();
+ queuedEpisodeActions.add(played);
+ }
+ }
+ if (!queuedEpisodeActions.isEmpty()) {
+ LockingAsyncExecutor.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);
+ synchronizationQueueStorage.clearEpisodeActionQueue();
+ } finally {
+ LockingAsyncExecutor.unlock();
+ }
+ }
+ SynchronizationSettings.setLastEpisodeActionSynchronizationAttemptTimestamp(newTimeStamp);
+ }
+
+ private synchronized void processEpisodeActions(List<EpisodeAction> remoteActions) {
+ Log.d(TAG, "Processing " + remoteActions.size() + " actions");
+ if (remoteActions.size() == 0) {
+ return;
+ }
+
+ Map<Pair<String, String>, EpisodeAction> playActionsToUpdate = EpisodeActionFilter
+ .getRemoteActionsOverridingLocalActions(remoteActions,
+ synchronizationQueueStorage.getQueuedEpisodeActions());
+ LongList queueToBeRemoved = new LongList();
+ List<FeedItem> updatedItems = new ArrayList<>();
+ for (EpisodeAction action : playActionsToUpdate.values()) {
+ String guid = GuidValidator.isValidGuid(action.getGuid()) ? action.getGuid() : null;
+ FeedItem feedItem = DBReader.getFeedItemByGuidOrEpisodeUrl(guid, action.getEpisode());
+ if (feedItem == null) {
+ Log.i(TAG, "Unknown feed item: " + action);
+ continue;
+ }
+ if (feedItem.getMedia() == null) {
+ Log.i(TAG, "Feed item has no media: " + action);
+ continue;
+ }
+ FeedMedia media = feedItem.getMedia();
+ media.setPosition(action.getPosition() * 1000);
+ int smartMarkAsPlayedSecs = UserPreferences.getSmartMarkAsPlayedSecs();
+ boolean almostEnded = media.getDuration() > 0
+ && media.getPosition() >= media.getDuration() - smartMarkAsPlayedSecs * 1000;
+ if (almostEnded) {
+ Log.d(TAG, "Marking as played: " + action);
+ feedItem.setPlayed(true);
+ media.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);
+ DBWriter.setItemList(updatedItems);
+ }
+
+ private void clearErrorNotifications() {
+ NotificationManager nm = (NotificationManager) getApplicationContext()
+ .getSystemService(Context.NOTIFICATION_SERVICE);
+ nm.cancel(R.id.notification_gpodnet_sync_error);
+ nm.cancel(R.id.notification_gpodnet_sync_autherror);
+ }
+
+ private void updateErrorNotification(Exception exception) {
+ Log.d(TAG, "Posting sync error notification");
+ final String description = getApplicationContext().getString(R.string.gpodnetsync_error_descr)
+ + exception.getMessage();
+
+ if (!UserPreferences.gpodnetNotificationsEnabled()) {
+ Log.d(TAG, "Skipping sync error notification because of user setting");
+ return;
+ }
+ if (EventBus.getDefault().hasSubscriberForEvent(MessageEvent.class)) {
+ EventBus.getDefault().post(new MessageEvent(description));
+ return;
+ }
+
+ Intent intent = getApplicationContext().getPackageManager().getLaunchIntentForPackage(
+ getApplicationContext().getPackageName());
+ PendingIntent pendingIntent = PendingIntent.getActivity(getApplicationContext(),
+ R.id.pending_intent_sync_error, intent, PendingIntent.FLAG_UPDATE_CURRENT
+ | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0));
+ Notification notification = new NotificationCompat.Builder(getApplicationContext(),
+ 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)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .build();
+ NotificationManager nm = (NotificationManager) getApplicationContext()
+ .getSystemService(Context.NOTIFICATION_SERVICE);
+ if (ContextCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.POST_NOTIFICATIONS)
+ == PackageManager.PERMISSION_GRANTED) {
+ nm.notify(R.id.notification_gpodnet_sync_error, notification);
+ }
+ }
+
+ private static OneTimeWorkRequest.Builder getWorkRequest() {
+ Constraints.Builder constraints = new Constraints.Builder();
+ if (UserPreferences.isAllowMobileSync()) {
+ constraints.setRequiredNetworkType(NetworkType.CONNECTED);
+ } else {
+ constraints.setRequiredNetworkType(NetworkType.UNMETERED);
+ }
+
+ OneTimeWorkRequest.Builder builder = new OneTimeWorkRequest.Builder(SyncService.class)
+ .setConstraints(constraints.build())
+ .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.MINUTES);
+
+ if (isCurrentlyActive) {
+ // Debounce: don't start sync again immediately after it was finished.
+ builder.setInitialDelay(2L, TimeUnit.MINUTES);
+ } else {
+ // Give it some time, so other possible actions can be queued.
+ builder.setInitialDelay(20L, TimeUnit.SECONDS);
+ EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_started));
+ }
+ return builder;
+ }
+
+ private ISyncService getActiveSyncProvider() {
+ String selectedSyncProviderKey = SynchronizationSettings.getSelectedSyncProviderKey();
+ SynchronizationProviderViewData selectedService = SynchronizationProviderViewData
+ .fromIdentifier(selectedSyncProviderKey);
+ if (selectedService == null) {
+ return null;
+ }
+ 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/net/sync/service/src/test/java/de/danoeh/antennapod/net/sync/service/EpisodeActionFilterTest.java b/net/sync/service/src/test/java/de/danoeh/antennapod/net/sync/service/EpisodeActionFilterTest.java
new file mode 100644
index 000000000..22ea316d4
--- /dev/null
+++ b/net/sync/service/src/test/java/de/danoeh/antennapod/net/sync/service/EpisodeActionFilterTest.java
@@ -0,0 +1,212 @@
+package de.danoeh.antennapod.net.sync.service;
+
+
+import androidx.core.util.Pair;
+
+import junit.framework.TestCase;
+
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+import de.danoeh.antennapod.net.sync.model.EpisodeAction;
+
+
+public class EpisodeActionFilterTest extends TestCase {
+
+ EpisodeActionFilter episodeActionFilter = new EpisodeActionFilter();
+
+ public void testGetRemoteActionsHappeningAfterLocalActions() throws ParseException {
+ SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+ Date morning = format.parse("2021-01-01 08:00:00");
+ Date lateMorning = format.parse("2021-01-01 09:00:00");
+
+ List<EpisodeAction> episodeActions = new ArrayList<>();
+ episodeActions.add(new EpisodeAction
+ .Builder("podcast.a", "episode.1", EpisodeAction.Action.PLAY)
+ .timestamp(morning)
+ .position(10)
+ .build()
+ );
+ episodeActions.add(new EpisodeAction
+ .Builder("podcast.a", "episode.1", EpisodeAction.Action.PLAY)
+ .timestamp(lateMorning)
+ .position(20)
+ .build()
+ );
+ episodeActions.add(new EpisodeAction
+ .Builder("podcast.a", "episode.2", EpisodeAction.Action.PLAY)
+ .timestamp(morning)
+ .position(5)
+ .build()
+ );
+
+ Date morningFiveMinutesLater = format.parse("2021-01-01 08:05:00");
+ List<EpisodeAction> remoteActions = new ArrayList<>();
+ remoteActions.add(new EpisodeAction
+ .Builder("podcast.a", "episode.1", EpisodeAction.Action.PLAY)
+ .timestamp(morningFiveMinutesLater)
+ .position(10)
+ .build()
+ );
+ remoteActions.add(new EpisodeAction
+ .Builder("podcast.a", "episode.2", EpisodeAction.Action.PLAY)
+ .timestamp(morningFiveMinutesLater)
+ .position(5)
+ .build()
+ );
+
+ Map<Pair<String, String>, EpisodeAction> uniqueList = episodeActionFilter
+ .getRemoteActionsOverridingLocalActions(remoteActions, episodeActions);
+ assertSame(1, uniqueList.size());
+ }
+
+ public void testGetRemoteActionsHappeningBeforeLocalActions() throws ParseException {
+ SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+ Date morning = format.parse("2021-01-01 08:00:00");
+ Date lateMorning = format.parse("2021-01-01 09:00:00");
+
+ List<EpisodeAction> episodeActions = new ArrayList<>();
+ episodeActions.add(new EpisodeAction
+ .Builder("podcast.a", "episode.1", EpisodeAction.Action.PLAY)
+ .timestamp(morning)
+ .position(10)
+ .build()
+ );
+ episodeActions.add(new EpisodeAction
+ .Builder("podcast.a", "episode.1", EpisodeAction.Action.PLAY)
+ .timestamp(lateMorning)
+ .position(20)
+ .build()
+ );
+ episodeActions.add(new EpisodeAction
+ .Builder("podcast.a", "episode.2", EpisodeAction.Action.PLAY)
+ .timestamp(morning)
+ .position(5)
+ .build()
+ );
+
+ Date morningFiveMinutesEarlier = format.parse("2021-01-01 07:55:00");
+ List<EpisodeAction> remoteActions = new ArrayList<>();
+ remoteActions.add(new EpisodeAction
+ .Builder("podcast.a", "episode.1", EpisodeAction.Action.PLAY)
+ .timestamp(morningFiveMinutesEarlier)
+ .position(10)
+ .build()
+ );
+ remoteActions.add(new EpisodeAction
+ .Builder("podcast.a", "episode.2", EpisodeAction.Action.PLAY)
+ .timestamp(morningFiveMinutesEarlier)
+ .position(5)
+ .build()
+ );
+
+ Map<Pair<String, String>, EpisodeAction> uniqueList = episodeActionFilter
+ .getRemoteActionsOverridingLocalActions(remoteActions, episodeActions);
+ assertSame(0, uniqueList.size());
+ }
+
+ public void testGetMultipleRemoteActionsHappeningAfterLocalActions() throws ParseException {
+ SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+ Date morning = format.parse("2021-01-01 08:00:00");
+
+ List<EpisodeAction> episodeActions = new ArrayList<>();
+ episodeActions.add(new EpisodeAction
+ .Builder("podcast.a", "episode.1", EpisodeAction.Action.PLAY)
+ .timestamp(morning)
+ .position(10)
+ .build()
+ );
+ episodeActions.add(new EpisodeAction
+ .Builder("podcast.a", "episode.2", EpisodeAction.Action.PLAY)
+ .timestamp(morning)
+ .position(5)
+ .build()
+ );
+
+ Date morningFiveMinutesLater = format.parse("2021-01-01 08:05:00");
+ List<EpisodeAction> remoteActions = new ArrayList<>();
+ remoteActions.add(new EpisodeAction
+ .Builder("podcast.a", "episode.1", EpisodeAction.Action.PLAY)
+ .timestamp(morningFiveMinutesLater)
+ .position(10)
+ .build()
+ );
+ remoteActions.add(new EpisodeAction
+ .Builder("podcast.a", "episode.2", EpisodeAction.Action.PLAY)
+ .timestamp(morningFiveMinutesLater)
+ .position(5)
+ .build()
+ );
+
+ Map<Pair<String, String>, EpisodeAction> uniqueList = episodeActionFilter
+ .getRemoteActionsOverridingLocalActions(remoteActions, episodeActions);
+ assertEquals(2, uniqueList.size());
+ }
+
+ public void testGetMultipleRemoteActionsHappeningBeforeLocalActions() throws ParseException {
+ SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+ Date morning = format.parse("2021-01-01 08:00:00");
+
+ List<EpisodeAction> episodeActions = new ArrayList<>();
+ episodeActions.add(new EpisodeAction
+ .Builder("podcast.a", "episode.1", EpisodeAction.Action.PLAY)
+ .timestamp(morning)
+ .position(10)
+ .build()
+ );
+ episodeActions.add(new EpisodeAction
+ .Builder("podcast.a", "episode.2", EpisodeAction.Action.PLAY)
+ .timestamp(morning)
+ .position(5)
+ .build()
+ );
+
+ Date morningFiveMinutesEarlier = format.parse("2021-01-01 07:55:00");
+ List<EpisodeAction> remoteActions = new ArrayList<>();
+ remoteActions.add(new EpisodeAction
+ .Builder("podcast.a", "episode.1", EpisodeAction.Action.PLAY)
+ .timestamp(morningFiveMinutesEarlier)
+ .position(10)
+ .build()
+ );
+ remoteActions.add(new EpisodeAction
+ .Builder("podcast.a", "episode.2", EpisodeAction.Action.PLAY)
+ .timestamp(morningFiveMinutesEarlier)
+ .position(5)
+ .build()
+ );
+
+ Map<Pair<String, String>, EpisodeAction> uniqueList = episodeActionFilter
+ .getRemoteActionsOverridingLocalActions(remoteActions, episodeActions);
+ assertEquals(0, uniqueList.size());
+ }
+
+ public void testPresentRemoteTimestampOverridesMissingLocalTimestamp() throws ParseException {
+ SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+ Date arbitraryTime = format.parse("2021-01-01 08:00:00");
+
+ List<EpisodeAction> episodeActions = new ArrayList<>();
+ episodeActions.add(new EpisodeAction
+ .Builder("podcast.a", "episode.1", EpisodeAction.Action.PLAY)
+ // no timestamp
+ .position(10)
+ .build()
+ );
+
+ List<EpisodeAction> remoteActions = new ArrayList<>();
+ remoteActions.add(new EpisodeAction
+ .Builder("podcast.a", "episode.1", EpisodeAction.Action.PLAY)
+ .timestamp(arbitraryTime)
+ .position(10)
+ .build()
+ );
+
+ Map<Pair<String, String>, EpisodeAction> uniqueList = episodeActionFilter
+ .getRemoteActionsOverridingLocalActions(remoteActions, episodeActions);
+ assertSame(1, uniqueList.size());
+ }
+}
diff --git a/net/sync/service/src/test/java/de/danoeh/antennapod/net/sync/service/GuidValidatorTest.java b/net/sync/service/src/test/java/de/danoeh/antennapod/net/sync/service/GuidValidatorTest.java
new file mode 100644
index 000000000..3e3d77a1f
--- /dev/null
+++ b/net/sync/service/src/test/java/de/danoeh/antennapod/net/sync/service/GuidValidatorTest.java
@@ -0,0 +1,19 @@
+package de.danoeh.antennapod.net.sync.service;
+
+import junit.framework.TestCase;
+
+public class GuidValidatorTest extends TestCase {
+
+ public void testIsValidGuid() {
+ assertTrue(GuidValidator.isValidGuid("skfjsdvgsd"));
+ }
+
+ public void testIsInvalidGuid() {
+ assertFalse(GuidValidator.isValidGuid(""));
+ assertFalse(GuidValidator.isValidGuid(" "));
+ assertFalse(GuidValidator.isValidGuid("\n"));
+ assertFalse(GuidValidator.isValidGuid(" \n"));
+ assertFalse(GuidValidator.isValidGuid(null));
+ assertFalse(GuidValidator.isValidGuid("null"));
+ }
+} \ No newline at end of file