summaryrefslogtreecommitdiff
path: root/net/sync
diff options
context:
space:
mode:
Diffstat (limited to 'net/sync')
-rw-r--r--net/sync/gpoddernet/build.gradle2
-rw-r--r--net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetService.java82
-rw-r--r--net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetServiceException.java2
-rw-r--r--net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/mapper/ResponseMapper.java7
-rw-r--r--net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetEpisodeActionPostResponse.java2
-rw-r--r--net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetUploadChangesResponse.java2
-rw-r--r--net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudLoginFlow.java2
-rw-r--r--net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSyncService.java16
-rw-r--r--net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSynchronizationServiceException.java2
-rw-r--r--net/sync/model/README.md3
-rw-r--r--net/sync/model/build.gradle14
-rw-r--r--net/sync/service-interface/README.md3
-rw-r--r--net/sync/service-interface/build.gradle17
-rw-r--r--net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/EpisodeAction.java (renamed from net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/EpisodeAction.java)4
-rw-r--r--net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/EpisodeActionChanges.java (renamed from net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/EpisodeActionChanges.java)2
-rw-r--r--net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/ISyncService.java (renamed from net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/ISyncService.java)2
-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/SubscriptionChanges.java (renamed from net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/SubscriptionChanges.java)2
-rw-r--r--net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SyncServiceException.java (renamed from net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/SyncServiceException.java)2
-rw-r--r--net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationProvider.java25
-rw-r--r--net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationQueueSink.java80
-rw-r--r--net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationQueueStorage.java157
-rw-r--r--net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/UploadChangesResponse.java (renamed from net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/UploadChangesResponse.java)2
-rw-r--r--net/sync/service/README.md3
-rw-r--r--net/sync/service/build.gradle35
-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/main/res/values/ids.xml5
-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
31 files changed, 1111 insertions, 114 deletions
diff --git a/net/sync/gpoddernet/build.gradle b/net/sync/gpoddernet/build.gradle
index ff1fc2f00..3eec12b58 100644
--- a/net/sync/gpoddernet/build.gradle
+++ b/net/sync/gpoddernet/build.gradle
@@ -8,7 +8,7 @@ android {
}
dependencies {
- implementation project(':net:sync:model')
+ implementation project(':net:sync:service-interface')
annotationProcessor "androidx.annotation:annotation:$annotationVersion"
implementation "androidx.appcompat:appcompat:$appcompatVersion"
diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetService.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetService.java
index 7873ae4fe..b04e9bdc9 100644
--- a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetService.java
+++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetService.java
@@ -5,6 +5,12 @@ import android.util.Log;
import androidx.annotation.NonNull;
import de.danoeh.antennapod.net.sync.HostnameParser;
+import de.danoeh.antennapod.net.sync.serviceinterface.EpisodeAction;
+import de.danoeh.antennapod.net.sync.serviceinterface.EpisodeActionChanges;
+import de.danoeh.antennapod.net.sync.serviceinterface.ISyncService;
+import de.danoeh.antennapod.net.sync.serviceinterface.SubscriptionChanges;
+import de.danoeh.antennapod.net.sync.serviceinterface.SyncServiceException;
+import de.danoeh.antennapod.net.sync.serviceinterface.UploadChangesResponse;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
@@ -27,12 +33,6 @@ import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetDevice;
import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetEpisodeActionPostResponse;
import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetPodcast;
import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetUploadChangesResponse;
-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 okhttp3.Credentials;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
@@ -79,36 +79,6 @@ public class GpodnetService implements ISyncService {
}
/**
- * Searches the podcast directory for a given string.
- *
- * @param query The search query
- * @param scaledLogoSize The size of the logos that are returned by the search query.
- * Must be in range 1..256. If the value is out of range, the
- * default value defined by the gpodder.net API will be used.
- */
- public List<GpodnetPodcast> searchPodcasts(String query, int scaledLogoSize) throws GpodnetServiceException {
- String parameters = (scaledLogoSize > 0 && scaledLogoSize <= 256) ? String
- .format(Locale.US, "q=%s&scale_logo=%d", query, scaledLogoSize) : String
- .format("q=%s", query);
- try {
- URL url = new URI(baseScheme, null, baseHost, basePort, "/search.json",
- parameters, null).toURL();
- Request.Builder request = new Request.Builder().url(url);
- String response = executeRequest(request);
-
- JSONArray jsonArray = new JSONArray(response);
- return readPodcastListFromJsonArray(jsonArray);
-
- } catch (JSONException | MalformedURLException e) {
- e.printStackTrace();
- throw new GpodnetServiceException(e);
- } catch (URISyntaxException e) {
- e.printStackTrace();
- throw new IllegalStateException(e);
- }
- }
-
- /**
* Returns all devices of a given user.
* <p/>
* This method requires authentication.
@@ -157,7 +127,7 @@ public class GpodnetService implements ISyncService {
} else {
content = "";
}
- RequestBody body = RequestBody.create(JSON, content);
+ RequestBody body = RequestBody.create(content, JSON);
Request.Builder request = new Request.Builder().post(body).url(url);
executeRequest(request);
} catch (JSONException | MalformedURLException | URISyntaxException e) {
@@ -167,38 +137,6 @@ public class GpodnetService implements ISyncService {
}
/**
- * Uploads the subscriptions of a specific device.
- * <p/>
- * This method requires authentication.
- *
- * @param deviceId The ID of the device whose subscriptions should be updated.
- * @param subscriptions A list of feed URLs containing all subscriptions of the
- * device.
- * @throws IllegalArgumentException If username, deviceId or subscriptions is null.
- * @throws GpodnetServiceAuthenticationException If there is an authentication error.
- */
- public void uploadSubscriptions(@NonNull String deviceId, @NonNull List<String> subscriptions)
- throws GpodnetServiceException {
- requireLoggedIn();
- try {
- URL url = new URI(baseScheme, null, baseHost, basePort,
- String.format("/subscriptions/%s/%s.txt", username, deviceId), null, null).toURL();
- StringBuilder builder = new StringBuilder();
- for (String s : subscriptions) {
- builder.append(s);
- builder.append("\n");
- }
- RequestBody body = RequestBody.create(TEXT, builder.toString());
- Request.Builder request = new Request.Builder().put(body).url(url);
- executeRequest(request);
- } catch (MalformedURLException | URISyntaxException e) {
- e.printStackTrace();
- throw new GpodnetServiceException(e);
- }
-
- }
-
- /**
* Updates the subscription list of a specific device.
* <p/>
* This method requires authentication.
@@ -222,7 +160,7 @@ public class GpodnetService implements ISyncService {
requestObject.put("add", new JSONArray(added));
requestObject.put("remove", new JSONArray(removed));
- RequestBody body = RequestBody.create(JSON, requestObject.toString());
+ RequestBody body = RequestBody.create(requestObject.toString(), JSON);
Request.Builder request = new Request.Builder().post(body).url(url);
final String response = executeRequest(request);
@@ -304,7 +242,7 @@ public class GpodnetService implements ISyncService {
}
}
- RequestBody body = RequestBody.create(JSON, list.toString());
+ RequestBody body = RequestBody.create(list.toString(), JSON);
Request.Builder request = new Request.Builder().post(body).url(url);
final String response = executeRequest(request);
@@ -362,7 +300,7 @@ public class GpodnetService implements ISyncService {
e.printStackTrace();
throw new GpodnetServiceException(e);
}
- RequestBody requestBody = RequestBody.create(TEXT, "");
+ RequestBody requestBody = RequestBody.create("", TEXT);
Request request = new Request.Builder().url(url).post(requestBody).build();
try {
String credential = Credentials.basic(username, password, Charset.forName("UTF-8"));
diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetServiceException.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetServiceException.java
index 298c59073..5f16afbd1 100644
--- a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetServiceException.java
+++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetServiceException.java
@@ -1,6 +1,6 @@
package de.danoeh.antennapod.net.sync.gpoddernet;
-import de.danoeh.antennapod.net.sync.model.SyncServiceException;
+import de.danoeh.antennapod.net.sync.serviceinterface.SyncServiceException;
public class GpodnetServiceException extends SyncServiceException {
private static final long serialVersionUID = 1L;
diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/mapper/ResponseMapper.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/mapper/ResponseMapper.java
index c8e607d74..2d2409eac 100644
--- a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/mapper/ResponseMapper.java
+++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/mapper/ResponseMapper.java
@@ -2,6 +2,9 @@ package de.danoeh.antennapod.net.sync.gpoddernet.mapper;
import androidx.annotation.NonNull;
+import de.danoeh.antennapod.net.sync.serviceinterface.EpisodeAction;
+import de.danoeh.antennapod.net.sync.serviceinterface.EpisodeActionChanges;
+import de.danoeh.antennapod.net.sync.serviceinterface.SubscriptionChanges;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
@@ -10,10 +13,6 @@ import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
-import de.danoeh.antennapod.net.sync.model.EpisodeAction;
-import de.danoeh.antennapod.net.sync.model.EpisodeActionChanges;
-import de.danoeh.antennapod.net.sync.model.SubscriptionChanges;
-
public class ResponseMapper {
public static SubscriptionChanges readSubscriptionChangesFromJsonObject(@NonNull JSONObject object)
diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetEpisodeActionPostResponse.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetEpisodeActionPostResponse.java
index 6d573ebfc..74e77ff44 100644
--- a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetEpisodeActionPostResponse.java
+++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetEpisodeActionPostResponse.java
@@ -2,7 +2,7 @@ package de.danoeh.antennapod.net.sync.gpoddernet.model;
import androidx.collection.ArrayMap;
-import de.danoeh.antennapod.net.sync.model.UploadChangesResponse;
+import de.danoeh.antennapod.net.sync.serviceinterface.UploadChangesResponse;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import org.json.JSONArray;
diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetUploadChangesResponse.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetUploadChangesResponse.java
index 7b09531a5..7d3f36fe4 100644
--- a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetUploadChangesResponse.java
+++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetUploadChangesResponse.java
@@ -3,7 +3,7 @@ package de.danoeh.antennapod.net.sync.gpoddernet.model;
import androidx.collection.ArrayMap;
import de.danoeh.antennapod.net.sync.gpoddernet.GpodnetService;
-import de.danoeh.antennapod.net.sync.model.UploadChangesResponse;
+import de.danoeh.antennapod.net.sync.serviceinterface.UploadChangesResponse;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudLoginFlow.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudLoginFlow.java
index 3d2374acf..254be269f 100644
--- a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudLoginFlow.java
+++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudLoginFlow.java
@@ -117,7 +117,7 @@ public class NextcloudLoginFlow {
private JSONObject doRequest(URL url, String bodyContent) throws IOException, JSONException {
RequestBody requestBody = RequestBody.create(
- MediaType.get("application/x-www-form-urlencoded"), bodyContent);
+ bodyContent, MediaType.get("application/x-www-form-urlencoded"));
Request request = new Request.Builder().url(url).method("POST", requestBody).build();
Response response = httpClient.newCall(request).execute();
if (response.code() != 200) {
diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSyncService.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSyncService.java
index 67b4ddab0..e98976c81 100644
--- a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSyncService.java
+++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSyncService.java
@@ -3,12 +3,12 @@ package de.danoeh.antennapod.net.sync.nextcloud;
import de.danoeh.antennapod.net.sync.HostnameParser;
import de.danoeh.antennapod.net.sync.gpoddernet.mapper.ResponseMapper;
import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetUploadChangesResponse;
-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.serviceinterface.EpisodeAction;
+import de.danoeh.antennapod.net.sync.serviceinterface.EpisodeActionChanges;
+import de.danoeh.antennapod.net.sync.serviceinterface.ISyncService;
+import de.danoeh.antennapod.net.sync.serviceinterface.SubscriptionChanges;
+import de.danoeh.antennapod.net.sync.serviceinterface.SyncServiceException;
+import de.danoeh.antennapod.net.sync.serviceinterface.UploadChangesResponse;
import okhttp3.Credentials;
import okhttp3.HttpUrl;
import okhttp3.MediaType;
@@ -71,7 +71,7 @@ public class NextcloudSyncService implements ISyncService {
requestObject.put("add", new JSONArray(addedFeeds));
requestObject.put("remove", new JSONArray(removedFeeds));
RequestBody requestBody = RequestBody.create(
- MediaType.get("application/json"), requestObject.toString());
+ requestObject.toString(), MediaType.get("application/json"));
performRequest(url, "POST", requestBody);
} catch (Exception e) {
e.printStackTrace();
@@ -121,7 +121,7 @@ public class NextcloudSyncService implements ISyncService {
}
HttpUrl.Builder url = makeUrl("/index.php/apps/gpoddersync/episode_action/create");
RequestBody requestBody = RequestBody.create(
- MediaType.get("application/json"), list.toString());
+ list.toString(), MediaType.get("application/json"));
performRequest(url, "POST", requestBody);
} catch (Exception e) {
e.printStackTrace();
diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSynchronizationServiceException.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSynchronizationServiceException.java
index d907c229e..db66abce6 100644
--- a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSynchronizationServiceException.java
+++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSynchronizationServiceException.java
@@ -1,6 +1,6 @@
package de.danoeh.antennapod.net.sync.nextcloud;
-import de.danoeh.antennapod.net.sync.model.SyncServiceException;
+import de.danoeh.antennapod.net.sync.serviceinterface.SyncServiceException;
public class NextcloudSynchronizationServiceException extends SyncServiceException {
public NextcloudSynchronizationServiceException(Throwable e) {
diff --git a/net/sync/model/README.md b/net/sync/model/README.md
deleted file mode 100644
index 21d842914..000000000
--- a/net/sync/model/README.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# :net:sync:model
-
-This module contains the basic interfaces for implementing a sync backend.
diff --git a/net/sync/model/build.gradle b/net/sync/model/build.gradle
deleted file mode 100644
index 8520b0a49..000000000
--- a/net/sync/model/build.gradle
+++ /dev/null
@@ -1,14 +0,0 @@
-plugins {
- id("com.android.library")
-}
-apply from: "../../../common.gradle"
-
-android {
- namespace "de.danoeh.antennapod.net.sync.model"
-}
-
-dependencies {
- implementation project(':model')
-
- annotationProcessor "androidx.annotation:annotation:$annotationVersion"
-}
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..fd170b36a
--- /dev/null
+++ b/net/sync/service-interface/build.gradle
@@ -0,0 +1,17 @@
+plugins {
+ id("com.android.library")
+}
+apply from: "../../../common.gradle"
+
+android {
+ namespace "de.danoeh.antennapod.net.sync.serviceinterface"
+}
+
+dependencies {
+ implementation project(':model')
+ implementation project(':storage:preferences')
+
+ annotationProcessor "androidx.annotation:annotation:$annotationVersion"
+ implementation "io.reactivex.rxjava2:rxandroid:$rxAndroidVersion"
+ implementation "io.reactivex.rxjava2:rxjava:$rxJavaVersion"
+}
diff --git a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/EpisodeAction.java b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/EpisodeAction.java
index 42fbdb310..3c3bd1418 100644
--- a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/EpisodeAction.java
+++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/EpisodeAction.java
@@ -1,4 +1,4 @@
-package de.danoeh.antennapod.net.sync.model;
+package de.danoeh.antennapod.net.sync.serviceinterface;
import android.text.TextUtils;
import android.util.Log;
@@ -239,7 +239,7 @@ public class EpisodeAction {
private String guid;
public Builder(FeedItem item, Action action) {
- this(item.getFeed().getDownload_url(), item.getMedia().getDownload_url(), action);
+ this(item.getFeed().getDownloadUrl(), item.getMedia().getDownloadUrl(), action);
this.guid(item.getItemIdentifier());
}
diff --git a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/EpisodeActionChanges.java b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/EpisodeActionChanges.java
index 570e012c5..d2b17b492 100644
--- a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/EpisodeActionChanges.java
+++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/EpisodeActionChanges.java
@@ -1,4 +1,4 @@
-package de.danoeh.antennapod.net.sync.model;
+package de.danoeh.antennapod.net.sync.serviceinterface;
import androidx.annotation.NonNull;
diff --git a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/ISyncService.java b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/ISyncService.java
index 9c75e5dac..29632ed1e 100644
--- a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/ISyncService.java
+++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/ISyncService.java
@@ -1,4 +1,4 @@
-package de.danoeh.antennapod.net.sync.model;
+package de.danoeh.antennapod.net.sync.serviceinterface;
import java.util.List;
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/model/src/main/java/de/danoeh/antennapod/net/sync/model/SubscriptionChanges.java b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SubscriptionChanges.java
index 2fbc8b45e..c0c9f131d 100644
--- a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/SubscriptionChanges.java
+++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SubscriptionChanges.java
@@ -1,4 +1,4 @@
-package de.danoeh.antennapod.net.sync.model;
+package de.danoeh.antennapod.net.sync.serviceinterface;
import androidx.annotation.NonNull;
diff --git a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/SyncServiceException.java b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SyncServiceException.java
index 57262db17..5ccedd785 100644
--- a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/SyncServiceException.java
+++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SyncServiceException.java
@@ -1,4 +1,4 @@
-package de.danoeh.antennapod.net.sync.model;
+package de.danoeh.antennapod.net.sync.serviceinterface;
public class SyncServiceException extends Exception {
private static final long serialVersionUID = 1L;
diff --git a/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationProvider.java b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationProvider.java
new file mode 100644
index 000000000..8c4047b6c
--- /dev/null
+++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationProvider.java
@@ -0,0 +1,25 @@
+package de.danoeh.antennapod.net.sync.serviceinterface;
+
+public enum SynchronizationProvider {
+ GPODDER_NET("GPODDER_NET"),
+ NEXTCLOUD_GPODDER("NEXTCLOUD_GPODDER");
+
+ public static SynchronizationProvider fromIdentifier(String provider) {
+ for (SynchronizationProvider synchronizationProvider : SynchronizationProvider.values()) {
+ if (synchronizationProvider.getIdentifier().equals(provider)) {
+ return synchronizationProvider;
+ }
+ }
+ return null;
+ }
+
+ private final String identifier;
+
+ SynchronizationProvider(String identifier) {
+ this.identifier = identifier;
+ }
+
+ public String getIdentifier() {
+ return identifier;
+ }
+}
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..ad235130a
--- /dev/null
+++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationQueueSink.java
@@ -0,0 +1,80 @@
+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;
+
+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..55dc07ae8
--- /dev/null
+++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationQueueStorage.java
@@ -0,0 +1,157 @@
+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;
+
+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/model/src/main/java/de/danoeh/antennapod/net/sync/model/UploadChangesResponse.java b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/UploadChangesResponse.java
index 7503f429b..64bddc260 100644
--- a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/UploadChangesResponse.java
+++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/UploadChangesResponse.java
@@ -1,4 +1,4 @@
-package de.danoeh.antennapod.net.sync.model;
+package de.danoeh.antennapod.net.sync.serviceinterface;
public abstract class UploadChangesResponse {
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..03b81a39c
--- /dev/null
+++ b/net/sync/service/build.gradle
@@ -0,0 +1,35 @@
+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: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..17ea15ae8
--- /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.serviceinterface.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..97921e7f8
--- /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.SynchronizationProvider;
+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.serviceinterface.EpisodeAction;
+import de.danoeh.antennapod.net.sync.serviceinterface.EpisodeActionChanges;
+import de.danoeh.antennapod.net.sync.serviceinterface.ISyncService;
+import de.danoeh.antennapod.net.sync.serviceinterface.SubscriptionChanges;
+import de.danoeh.antennapod.net.sync.serviceinterface.SyncServiceException;
+import de.danoeh.antennapod.net.sync.serviceinterface.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();
+ SynchronizationProvider selectedService = SynchronizationProvider
+ .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/main/res/values/ids.xml b/net/sync/service/src/main/res/values/ids.xml
new file mode 100644
index 000000000..842e421ea
--- /dev/null
+++ b/net/sync/service/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/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..38f5bdc4a
--- /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.serviceinterface.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