summaryrefslogtreecommitdiff
path: root/net/sync
diff options
context:
space:
mode:
authorByteHamster <info@bytehamster.com>2021-04-24 16:18:02 +0200
committerByteHamster <info@bytehamster.com>2021-04-24 17:05:59 +0200
commite30533a810efa59bba6c101dd1405e776e1b451e (patch)
treecbeed0877116c02d04257dbc1380287bd0e3eb4b /net/sync
parent7b5f29e090f34c0aae60c5e3d7a1c9288a7eda7a (diff)
downloadAntennaPod-e30533a810efa59bba6c101dd1405e776e1b451e.zip
Moved synchronization to its own module
Diffstat (limited to 'net/sync')
-rw-r--r--net/sync/README.md3
-rw-r--r--net/sync/gpoddernet/README.md3
-rw-r--r--net/sync/gpoddernet/build.gradle57
-rw-r--r--net/sync/gpoddernet/src/main/AndroidManifest.xml1
-rw-r--r--net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetService.java818
-rw-r--r--net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetServiceAuthenticationException.java9
-rw-r--r--net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetServiceBadStatusCodeException.java12
-rw-r--r--net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetServiceException.java15
-rw-r--r--net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetDevice.java75
-rw-r--r--net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetEpisodeActionPostResponse.java49
-rw-r--r--net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetPodcast.java72
-rw-r--r--net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetTag.java66
-rw-r--r--net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetUploadChangesResponse.java53
-rw-r--r--net/sync/model/README.md3
-rw-r--r--net/sync/model/build.gradle54
-rw-r--r--net/sync/model/src/main/AndroidManifest.xml1
-rw-r--r--net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/EpisodeAction.java267
-rw-r--r--net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/EpisodeActionChanges.java34
-rw-r--r--net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/ISyncService.java20
-rw-r--r--net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/SubscriptionChanges.java39
-rw-r--r--net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/SyncServiceException.java13
-rw-r--r--net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/UploadChangesResponse.java13
22 files changed, 1677 insertions, 0 deletions
diff --git a/net/sync/README.md b/net/sync/README.md
new file mode 100644
index 000000000..ec3a5d3a7
--- /dev/null
+++ b/net/sync/README.md
@@ -0,0 +1,3 @@
+# :net:sync
+
+This folder contains modules related to external services for synchronization. The module `model` provides the basic interfaces for implementing a synchronization backend. The other modules contains backends for specific synchronization services.
diff --git a/net/sync/gpoddernet/README.md b/net/sync/gpoddernet/README.md
new file mode 100644
index 000000000..1f8bb95bd
--- /dev/null
+++ b/net/sync/gpoddernet/README.md
@@ -0,0 +1,3 @@
+# :net:sync:gpoddernet
+
+This module contains the sync backend for the open-source podcast synchronization service "Gpodder.net".
diff --git a/net/sync/gpoddernet/build.gradle b/net/sync/gpoddernet/build.gradle
new file mode 100644
index 000000000..cd6d8d04c
--- /dev/null
+++ b/net/sync/gpoddernet/build.gradle
@@ -0,0 +1,57 @@
+apply plugin: "com.android.library"
+
+android {
+ compileSdkVersion rootProject.ext.compileSdkVersion
+
+ defaultConfig {
+ minSdkVersion rootProject.ext.minSdkVersion
+ targetSdkVersion rootProject.ext.targetSdkVersion
+
+ multiDexEnabled false
+
+ testApplicationId "de.danoeh.antennapod.core.tests"
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile("proguard-android.txt")
+ }
+ debug {
+ // debug build has method count over 64k single-dex threshold.
+ // For building debug build to use on Android < 21 (pre-Android 5) devices,
+ // you need to manually change class
+ // de.danoeh.antennapod.PodcastApp to extend MultiDexApplication .
+ // See Issue #2813
+ multiDexEnabled true
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ testOptions {
+ unitTests {
+ includeAndroidResources = true
+ }
+ }
+
+ lintOptions {
+ disable 'GradleDependency'
+ checkDependencies true
+ warningsAsErrors true
+ abortOnError true
+ }
+}
+
+dependencies {
+ implementation project(':net:sync:model')
+
+ annotationProcessor "androidx.annotation:annotation:$annotationVersion"
+ implementation "androidx.appcompat:appcompat:$appcompatVersion"
+
+ implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
+ implementation "org.apache.commons:commons-lang3:$commonslangVersion"
+}
diff --git a/net/sync/gpoddernet/src/main/AndroidManifest.xml b/net/sync/gpoddernet/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..df37bed3c
--- /dev/null
+++ b/net/sync/gpoddernet/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+<manifest package="de.danoeh.antennapod.net.sync.gpoddernet" />
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
new file mode 100644
index 000000000..d41f613a7
--- /dev/null
+++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetService.java
@@ -0,0 +1,818 @@
+package de.danoeh.antennapod.net.sync.gpoddernet;
+
+import android.util.Log;
+import androidx.annotation.NonNull;
+import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetDevice;
+import de.danoeh.antennapod.net.sync.model.EpisodeAction;
+import de.danoeh.antennapod.net.sync.model.EpisodeActionChanges;
+import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetEpisodeActionPostResponse;
+import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetPodcast;
+import de.danoeh.antennapod.net.sync.model.ISyncService;
+import de.danoeh.antennapod.net.sync.model.SubscriptionChanges;
+import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetTag;
+import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetUploadChangesResponse;
+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;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Locale;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Communicates with the gpodder.net service.
+ */
+public class GpodnetService implements ISyncService {
+ public static final String TAG = "GpodnetService";
+ public static final String DEFAULT_BASE_HOST = "gpodder.net";
+ private static final int UPLOAD_BULK_SIZE = 30;
+ private static final MediaType TEXT = MediaType.parse("plain/text; charset=utf-8");
+ private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
+ private String baseScheme;
+ private int basePort;
+ private final String baseHost;
+ private final String deviceId;
+ private String username;
+ private String password;
+ private boolean loggedIn = false;
+
+ private final OkHttpClient httpClient;
+
+ // split into schema, host and port - missing parts are null
+ private static Pattern urlsplit_regex = Pattern.compile("(?:(https?)://)?([^:]+)(?::(\\d+))?");
+
+ public GpodnetService(OkHttpClient httpClient, String baseHosturl,
+ String deviceId, String username, String password) {
+ this.httpClient = httpClient;
+ this.deviceId = deviceId;
+ this.username = username;
+ this.password = password;
+
+ Matcher m = urlsplit_regex.matcher(baseHosturl);
+ if (m.matches()) {
+ this.baseScheme = m.group(1);
+ this.baseHost = m.group(2);
+ if (m.group(3) == null) {
+ this.basePort = -1;
+ } else {
+ this.basePort = Integer.parseInt(m.group(3)); // regex -> can only be digits
+ }
+ } else {
+ // URL does not match regex: use it anyway -> this will cause an exception on connect
+ this.baseScheme = "https";
+ this.baseHost = baseHosturl;
+ this.basePort = 443;
+ }
+
+ if (this.baseScheme == null) { // assume https
+ this.baseScheme = "https";
+ }
+
+ if (this.baseScheme.equals("https") && this.basePort == -1) {
+ this.basePort = 443;
+ }
+
+ if (this.baseScheme.equals("http") && this.basePort == -1) {
+ this.basePort = 80;
+ }
+ }
+
+ private void requireLoggedIn() {
+ if (!loggedIn) {
+ throw new IllegalStateException("Not logged in");
+ }
+ }
+
+ /**
+ * Returns the [count] most used tags.
+ */
+ public List<GpodnetTag> getTopTags(int count) throws GpodnetServiceException {
+ URL url;
+ try {
+ url = new URI(baseScheme, null, baseHost, basePort,
+ String.format(Locale.US, "/api/2/tags/%d.json", count), null, null).toURL();
+ } catch (MalformedURLException | URISyntaxException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+
+ Request.Builder request = new Request.Builder().url(url);
+ String response = executeRequest(request);
+ try {
+ JSONArray jsonTagList = new JSONArray(response);
+ List<GpodnetTag> tagList = new ArrayList<>(jsonTagList.length());
+ for (int i = 0; i < jsonTagList.length(); i++) {
+ JSONObject jsonObject = jsonTagList.getJSONObject(i);
+ String title = jsonObject.getString("title");
+ String tag = jsonObject.getString("tag");
+ int usage = jsonObject.getInt("usage");
+ tagList.add(new GpodnetTag(title, tag, usage));
+ }
+ return tagList;
+ } catch (JSONException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+ }
+
+ /**
+ * Returns the [count] most subscribed podcasts for the given tag.
+ *
+ * @throws IllegalArgumentException if tag is null
+ */
+ public List<GpodnetPodcast> getPodcastsForTag(@NonNull GpodnetTag tag, int count)
+ throws GpodnetServiceException {
+ try {
+ URL url = new URI(baseScheme, null, baseHost, basePort,
+ String.format(Locale.US, "/api/2/tag/%s/%d.json", tag.getTag(), count), null, 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 | URISyntaxException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+ }
+
+ /**
+ * Returns the toplist of podcast.
+ *
+ * @param count of elements that should be returned. Must be in range 1..100.
+ * @throws IllegalArgumentException if count is out of range.
+ */
+ public List<GpodnetPodcast> getPodcastToplist(int count) throws GpodnetServiceException {
+ if (count < 1 || count > 100) {
+ throw new IllegalArgumentException("Count must be in range 1..100");
+ }
+
+ try {
+ URL url = new URI(baseScheme, null, baseHost, basePort,
+ String.format(Locale.US, "/toplist/%d.json", count), null, 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 | URISyntaxException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+ }
+
+ /**
+ * Returns a list of suggested podcasts for the user that is currently
+ * logged in.
+ * <p/>
+ * This method requires authentication.
+ *
+ * @param count The
+ * number of elements that should be returned. Must be in range
+ * 1..100.
+ * @throws IllegalArgumentException if count is out of range.
+ * @throws GpodnetServiceAuthenticationException If there is an authentication error.
+ */
+ public List<GpodnetPodcast> getSuggestions(int count) throws GpodnetServiceException {
+ if (count < 1 || count > 100) {
+ throw new IllegalArgumentException("Count must be in range 1..100");
+ }
+
+ try {
+ URL url = new URI(baseScheme, null, baseHost, basePort,
+ String.format(Locale.US, "/suggestions/%d.json", count), null, 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 | URISyntaxException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+ }
+
+ /**
+ * 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.
+ *
+ * @throws GpodnetServiceAuthenticationException If there is an authentication error.
+ */
+ public List<GpodnetDevice> getDevices() throws GpodnetServiceException {
+ requireLoggedIn();
+ try {
+ URL url = new URI(baseScheme, null, baseHost, basePort,
+ String.format("/api/2/devices/%s.json", username), null, null).toURL();
+ Request.Builder request = new Request.Builder().url(url);
+ String response = executeRequest(request);
+ JSONArray devicesArray = new JSONArray(response);
+ return readDeviceListFromJsonArray(devicesArray);
+ } catch (JSONException | MalformedURLException | URISyntaxException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+ }
+
+ /**
+ * Returns synchronization status of devices.
+ * <p/>
+ * This method requires authentication.
+ *
+ * @throws GpodnetServiceAuthenticationException If there is an authentication error.
+ */
+ public List<List<String>> getSynchronizedDevices() throws GpodnetServiceException {
+ requireLoggedIn();
+ try {
+ URL url = new URI(baseScheme, null, baseHost, basePort,
+ String.format("/api/2/sync-devices/%s.json", username), null, null).toURL();
+ Request.Builder request = new Request.Builder().url(url);
+ String response = executeRequest(request);
+ JSONObject syncStatus = new JSONObject(response);
+ List<List<String>> result = new ArrayList<>();
+
+ JSONArray synchronizedDevices = syncStatus.getJSONArray("synchronized");
+ for (int i = 0; i < synchronizedDevices.length(); i++) {
+ JSONArray groupDevices = synchronizedDevices.getJSONArray(i);
+ List<String> group = new ArrayList<>();
+ for (int j = 0; j < groupDevices.length(); j++) {
+ group.add(groupDevices.getString(j));
+ }
+ result.add(group);
+ }
+
+ JSONArray notSynchronizedDevices = syncStatus.getJSONArray("not-synchronized");
+ for (int i = 0; i < notSynchronizedDevices.length(); i++) {
+ result.add(Collections.singletonList(notSynchronizedDevices.getString(i)));
+ }
+
+ return result;
+ } catch (JSONException | MalformedURLException | URISyntaxException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+ }
+
+ /**
+ * Configures the device of a given user.
+ * <p/>
+ * This method requires authentication.
+ *
+ * @param deviceId The ID of the device that should be configured.
+ * @throws GpodnetServiceAuthenticationException If there is an authentication error.
+ */
+ public void configureDevice(@NonNull String deviceId, String caption, GpodnetDevice.DeviceType type)
+ throws GpodnetServiceException {
+ requireLoggedIn();
+ try {
+ URL url = new URI(baseScheme, null, baseHost, basePort,
+ String.format("/api/2/devices/%s/%s.json", username, deviceId), null, null).toURL();
+ String content;
+ if (caption != null || type != null) {
+ JSONObject jsonContent = new JSONObject();
+ if (caption != null) {
+ jsonContent.put("caption", caption);
+ }
+ if (type != null) {
+ jsonContent.put("type", type.toString());
+ }
+ content = jsonContent.toString();
+ } else {
+ content = "";
+ }
+ RequestBody body = RequestBody.create(JSON, content);
+ Request.Builder request = new Request.Builder().post(body).url(url);
+ executeRequest(request);
+ } catch (JSONException | MalformedURLException | URISyntaxException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+ }
+
+ /**
+ * Links devices for synchronization.
+ * <p/>
+ * This method requires authentication.
+ *
+ * @throws GpodnetServiceAuthenticationException If there is an authentication error.
+ */
+ public void linkDevices(@NonNull List<String> deviceIds) throws GpodnetServiceException {
+ requireLoggedIn();
+ try {
+ final URL url = new URI(baseScheme, null, baseHost, basePort,
+ String.format("/api/2/sync-devices/%s.json", username), null, null).toURL();
+ JSONObject jsonContent = new JSONObject();
+ JSONArray group = new JSONArray();
+ for (String deviceId : deviceIds) {
+ group.put(deviceId);
+ }
+
+ JSONArray synchronizedGroups = new JSONArray();
+ synchronizedGroups.put(group);
+ jsonContent.put("synchronize", synchronizedGroups);
+ jsonContent.put("stop-synchronize", new JSONArray());
+
+ Log.d("aaaa", jsonContent.toString());
+ RequestBody body = RequestBody.create(JSON, jsonContent.toString());
+ Request.Builder request = new Request.Builder().post(body).url(url);
+ executeRequest(request);
+ } catch (JSONException | MalformedURLException | URISyntaxException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+ }
+
+ /**
+ * Returns the subscriptions of a specific device.
+ * <p/>
+ * This method requires authentication.
+ *
+ * @param deviceId The ID of the device whose subscriptions should be returned.
+ * @return A list of subscriptions in OPML format.
+ * @throws GpodnetServiceAuthenticationException If there is an authentication error.
+ */
+ public String getSubscriptionsOfDevice(@NonNull String deviceId) throws GpodnetServiceException {
+ requireLoggedIn();
+ try {
+ URL url = new URI(baseScheme, null, baseHost, basePort,
+ String.format("/subscriptions/%s/%s.opml", username, deviceId), null, null).toURL();
+ Request.Builder request = new Request.Builder().url(url);
+ return executeRequest(request);
+ } catch (MalformedURLException | URISyntaxException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+ }
+
+ /**
+ * Returns all subscriptions of a specific user.
+ * <p/>
+ * This method requires authentication.
+ *
+ * @return A list of subscriptions in OPML format.
+ * @throws IllegalArgumentException If username is null.
+ * @throws GpodnetServiceAuthenticationException If there is an authentication error.
+ */
+ public String getSubscriptionsOfUser() throws GpodnetServiceException {
+ requireLoggedIn();
+ try {
+ URL url = new URI(baseScheme, null, baseHost, basePort,
+ String.format("/subscriptions/%s.opml", username), null, null).toURL();
+ Request.Builder request = new Request.Builder().url(url);
+ return executeRequest(request);
+ } catch (MalformedURLException | URISyntaxException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+ }
+
+ /**
+ * 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.
+ *
+ * @param added Collection of feed URLs of added feeds. This Collection MUST NOT contain any duplicates
+ * @param removed Collection of feed URLs of removed feeds. This Collection MUST NOT contain any duplicates
+ * @return a GpodnetUploadChangesResponse. See {@link GpodnetUploadChangesResponse}
+ * for details.
+ * @throws GpodnetServiceException if added or removed contain duplicates or if there
+ * is an authentication error.
+ */
+ @Override
+ public UploadChangesResponse uploadSubscriptionChanges(List<String> added, List<String> removed)
+ throws GpodnetServiceException {
+ requireLoggedIn();
+ try {
+ URL url = new URI(baseScheme, null, baseHost, basePort,
+ String.format("/api/2/subscriptions/%s/%s.json", username, deviceId), null, null).toURL();
+
+ final JSONObject requestObject = new JSONObject();
+ requestObject.put("add", new JSONArray(added));
+ requestObject.put("remove", new JSONArray(removed));
+
+ RequestBody body = RequestBody.create(JSON, requestObject.toString());
+ Request.Builder request = new Request.Builder().post(body).url(url);
+
+ final String response = executeRequest(request);
+ return GpodnetUploadChangesResponse.fromJSONObject(response);
+ } catch (JSONException | MalformedURLException | URISyntaxException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+
+ }
+
+ /**
+ * Returns all subscription changes of a specific device.
+ * <p/>
+ * This method requires authentication.
+ *
+ * @param timestamp A timestamp that can be used to receive all changes since a
+ * specific point in time.
+ * @throws GpodnetServiceAuthenticationException If there is an authentication error.
+ */
+ @Override
+ public SubscriptionChanges getSubscriptionChanges(long timestamp) throws GpodnetServiceException {
+ requireLoggedIn();
+ String params = String.format(Locale.US, "since=%d", timestamp);
+ String path = String.format("/api/2/subscriptions/%s/%s.json", username, deviceId);
+ try {
+ URL url = new URI(baseScheme, null, baseHost, basePort, path, params, null).toURL();
+ Request.Builder request = new Request.Builder().url(url);
+
+ String response = executeRequest(request);
+ JSONObject changes = new JSONObject(response);
+ return readSubscriptionChangesFromJsonObject(changes);
+ } catch (URISyntaxException e) {
+ e.printStackTrace();
+ throw new IllegalStateException(e);
+ } catch (JSONException | MalformedURLException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+
+ }
+
+ /**
+ * Updates the episode actions
+ * <p/>
+ * This method requires authentication.
+ *
+ * @param episodeActions Collection of episode actions.
+ * @return a GpodnetUploadChangesResponse. See {@link GpodnetUploadChangesResponse}
+ * for details.
+ * @throws GpodnetServiceException if added or removed contain duplicates or if there
+ * is an authentication error.
+ */
+ @Override
+ public UploadChangesResponse uploadEpisodeActions(List<EpisodeAction> episodeActions) throws SyncServiceException {
+ requireLoggedIn();
+ UploadChangesResponse response = null;
+ for (int i = 0; i < episodeActions.size(); i += UPLOAD_BULK_SIZE) {
+ response = uploadEpisodeActionsPartial(episodeActions,
+ i, Math.min(episodeActions.size(), i + UPLOAD_BULK_SIZE));
+ }
+ return response;
+ }
+
+ private UploadChangesResponse uploadEpisodeActionsPartial(List<EpisodeAction> episodeActions, int from, int to)
+ throws SyncServiceException {
+ try {
+ Log.d(TAG, "Uploading partial actions " + from + " to " + to + " of " + episodeActions.size());
+ URL url = new URI(baseScheme, null, baseHost, basePort,
+ String.format("/api/2/episodes/%s.json", username), null, null).toURL();
+
+ final JSONArray list = new JSONArray();
+ for (int i = from; i < to; i++) {
+ EpisodeAction episodeAction = episodeActions.get(i);
+ JSONObject obj = episodeAction.writeToJsonObject();
+ if (obj != null) {
+ obj.put("device", deviceId);
+ list.put(obj);
+ }
+ }
+
+ RequestBody body = RequestBody.create(JSON, list.toString());
+ Request.Builder request = new Request.Builder().post(body).url(url);
+
+ final String response = executeRequest(request);
+ return GpodnetEpisodeActionPostResponse.fromJSONObject(response);
+ } catch (JSONException | MalformedURLException | URISyntaxException e) {
+ e.printStackTrace();
+ throw new SyncServiceException(e);
+ }
+ }
+
+ /**
+ * Returns all subscription changes of a specific device.
+ * <p/>
+ * This method requires authentication.
+ *
+ * @param timestamp A timestamp that can be used to receive all changes since a
+ * specific point in time.
+ * @throws SyncServiceException If there is an authentication error.
+ */
+ @Override
+ public EpisodeActionChanges getEpisodeActionChanges(long timestamp) throws SyncServiceException {
+ requireLoggedIn();
+ String params = String.format(Locale.US, "since=%d", timestamp);
+ String path = String.format("/api/2/episodes/%s.json", username);
+ try {
+ URL url = new URI(baseScheme, null, baseHost, basePort, path, params, null).toURL();
+ Request.Builder request = new Request.Builder().url(url);
+
+ String response = executeRequest(request);
+ JSONObject json = new JSONObject(response);
+ return readEpisodeActionsFromJsonObject(json);
+ } catch (URISyntaxException e) {
+ e.printStackTrace();
+ throw new IllegalStateException(e);
+ } catch (JSONException | MalformedURLException e) {
+ e.printStackTrace();
+ throw new SyncServiceException(e);
+ }
+
+ }
+
+
+ /**
+ * Logs in a specific user. This method must be called if any of the methods
+ * that require authentication is used.
+ *
+ * @throws IllegalArgumentException If username or password is null.
+ */
+ @Override
+ public void login() throws GpodnetServiceException {
+ URL url;
+ try {
+ url = new URI(baseScheme, null, baseHost, basePort,
+ String.format("/api/2/auth/%s/login.json", username), null, null).toURL();
+ } catch (MalformedURLException | URISyntaxException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+ 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"));
+ Request authRequest = request.newBuilder().header("Authorization", credential).build();
+ Response response = httpClient.newCall(authRequest).execute();
+ checkStatusCode(response);
+ response.body().close();
+ this.loggedIn = true;
+ } catch (Exception e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+ }
+
+ private String executeRequest(@NonNull Request.Builder requestB) throws GpodnetServiceException {
+ Request request = requestB.build();
+ String responseString;
+ Response response;
+ ResponseBody body = null;
+ try {
+
+ response = httpClient.newCall(request).execute();
+ checkStatusCode(response);
+ body = response.body();
+ responseString = getStringFromResponseBody(body);
+ } catch (IOException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ } finally {
+ if (body != null) {
+ body.close();
+ }
+ }
+ return responseString;
+ }
+
+ private String getStringFromResponseBody(@NonNull ResponseBody body) throws GpodnetServiceException {
+ ByteArrayOutputStream outputStream;
+ int contentLength = (int) body.contentLength();
+ if (contentLength > 0) {
+ outputStream = new ByteArrayOutputStream(contentLength);
+ } else {
+ outputStream = new ByteArrayOutputStream();
+ }
+ try {
+ byte[] buffer = new byte[8 * 1024];
+ InputStream in = body.byteStream();
+ int count;
+ while ((count = in.read(buffer)) > 0) {
+ outputStream.write(buffer, 0, count);
+ }
+ return outputStream.toString("UTF-8");
+ } catch (IOException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+ }
+
+ private void checkStatusCode(@NonNull Response response) throws GpodnetServiceException {
+ int responseCode = response.code();
+ if (responseCode != HttpURLConnection.HTTP_OK) {
+ if (responseCode == HttpURLConnection.HTTP_UNAUTHORIZED) {
+ throw new GpodnetServiceAuthenticationException("Wrong username or password");
+ } else {
+ if (BuildConfig.DEBUG) {
+ try {
+ Log.d(TAG, response.body().string());
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ throw new GpodnetServiceBadStatusCodeException("Bad response code: " + responseCode, responseCode);
+ }
+ }
+ }
+
+ private List<GpodnetPodcast> readPodcastListFromJsonArray(@NonNull JSONArray array) throws JSONException {
+ List<GpodnetPodcast> result = new ArrayList<>(array.length());
+ for (int i = 0; i < array.length(); i++) {
+ result.add(readPodcastFromJsonObject(array.getJSONObject(i)));
+ }
+ return result;
+ }
+
+ private GpodnetPodcast readPodcastFromJsonObject(JSONObject object) throws JSONException {
+ String url = object.getString("url");
+
+ String title;
+ Object titleObj = object.opt("title");
+ if (titleObj instanceof String) {
+ title = (String) titleObj;
+ } else {
+ title = url;
+ }
+
+ String description;
+ Object descriptionObj = object.opt("description");
+ if (descriptionObj instanceof String) {
+ description = (String) descriptionObj;
+ } else {
+ description = "";
+ }
+
+ int subscribers = object.getInt("subscribers");
+
+ Object logoUrlObj = object.opt("logo_url");
+ String logoUrl = (logoUrlObj instanceof String) ? (String) logoUrlObj : null;
+ if (logoUrl == null) {
+ Object scaledLogoUrl = object.opt("scaled_logo_url");
+ if (scaledLogoUrl instanceof String) {
+ logoUrl = (String) scaledLogoUrl;
+ }
+ }
+
+ String website = null;
+ Object websiteObj = object.opt("website");
+ if (websiteObj instanceof String) {
+ website = (String) websiteObj;
+ }
+ String mygpoLink = object.getString("mygpo_link");
+
+ String author = null;
+ Object authorObj = object.opt("author");
+ if (authorObj instanceof String) {
+ author = (String) authorObj;
+ }
+ return new GpodnetPodcast(url, title, description, subscribers, logoUrl, website, mygpoLink, author);
+ }
+
+ private List<GpodnetDevice> readDeviceListFromJsonArray(@NonNull JSONArray array) throws JSONException {
+ List<GpodnetDevice> result = new ArrayList<>(array.length());
+ for (int i = 0; i < array.length(); i++) {
+ result.add(readDeviceFromJsonObject(array.getJSONObject(i)));
+ }
+ return result;
+ }
+
+ private GpodnetDevice readDeviceFromJsonObject(JSONObject object) throws JSONException {
+ String id = object.getString("id");
+ String caption = object.getString("caption");
+ String type = object.getString("type");
+ int subscriptions = object.getInt("subscriptions");
+ return new GpodnetDevice(id, caption, type, subscriptions);
+ }
+
+ private SubscriptionChanges readSubscriptionChangesFromJsonObject(@NonNull JSONObject object)
+ throws JSONException {
+
+ List<String> added = new LinkedList<>();
+ JSONArray jsonAdded = object.getJSONArray("add");
+ for (int i = 0; i < jsonAdded.length(); i++) {
+ String addedUrl = jsonAdded.getString(i);
+ // gpodder escapes colons unnecessarily
+ addedUrl = addedUrl.replace("%3A", ":");
+ added.add(addedUrl);
+ }
+
+ List<String> removed = new LinkedList<>();
+ JSONArray jsonRemoved = object.getJSONArray("remove");
+ for (int i = 0; i < jsonRemoved.length(); i++) {
+ String removedUrl = jsonRemoved.getString(i);
+ // gpodder escapes colons unnecessarily
+ removedUrl = removedUrl.replace("%3A", ":");
+ removed.add(removedUrl);
+ }
+
+ long timestamp = object.getLong("timestamp");
+ return new SubscriptionChanges(added, removed, timestamp);
+ }
+
+ private EpisodeActionChanges readEpisodeActionsFromJsonObject(@NonNull JSONObject object)
+ throws JSONException {
+
+ List<EpisodeAction> episodeActions = new ArrayList<>();
+
+ long timestamp = object.getLong("timestamp");
+ JSONArray jsonActions = object.getJSONArray("actions");
+ for (int i = 0; i < jsonActions.length(); i++) {
+ JSONObject jsonAction = jsonActions.getJSONObject(i);
+ EpisodeAction episodeAction = EpisodeAction.readFromJsonObject(jsonAction);
+ if (episodeAction != null) {
+ episodeActions.add(episodeAction);
+ }
+ }
+ return new EpisodeActionChanges(episodeActions, timestamp);
+ }
+
+ @Override
+ public void logout() {
+
+ }
+
+ public void setCredentials(String username, String password) {
+ this.username = username;
+ this.password = password;
+ }
+}
diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetServiceAuthenticationException.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetServiceAuthenticationException.java
new file mode 100644
index 000000000..d0a9c2dd6
--- /dev/null
+++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetServiceAuthenticationException.java
@@ -0,0 +1,9 @@
+package de.danoeh.antennapod.net.sync.gpoddernet;
+
+public class GpodnetServiceAuthenticationException extends GpodnetServiceException {
+ private static final long serialVersionUID = 1L;
+
+ public GpodnetServiceAuthenticationException(String message) {
+ super(message);
+ }
+}
diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetServiceBadStatusCodeException.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetServiceBadStatusCodeException.java
new file mode 100644
index 000000000..e403d8fff
--- /dev/null
+++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetServiceBadStatusCodeException.java
@@ -0,0 +1,12 @@
+package de.danoeh.antennapod.net.sync.gpoddernet;
+
+class GpodnetServiceBadStatusCodeException extends GpodnetServiceException {
+ private static final long serialVersionUID = 1L;
+
+ private final int statusCode;
+
+ public GpodnetServiceBadStatusCodeException(String message, int statusCode) {
+ super(message);
+ this.statusCode = statusCode;
+ }
+}
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
new file mode 100644
index 000000000..298c59073
--- /dev/null
+++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetServiceException.java
@@ -0,0 +1,15 @@
+package de.danoeh.antennapod.net.sync.gpoddernet;
+
+import de.danoeh.antennapod.net.sync.model.SyncServiceException;
+
+public class GpodnetServiceException extends SyncServiceException {
+ private static final long serialVersionUID = 1L;
+
+ public GpodnetServiceException(String message) {
+ super(message);
+ }
+
+ public GpodnetServiceException(Throwable e) {
+ super(e);
+ }
+}
diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetDevice.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetDevice.java
new file mode 100644
index 000000000..ed7d80ee0
--- /dev/null
+++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetDevice.java
@@ -0,0 +1,75 @@
+package de.danoeh.antennapod.net.sync.gpoddernet.model;
+
+import androidx.annotation.NonNull;
+
+import java.util.Locale;
+
+public class GpodnetDevice {
+
+ private final String id;
+ private final String caption;
+ private final DeviceType type;
+ private final int subscriptions;
+
+ public GpodnetDevice(@NonNull String id,
+ String caption,
+ String type,
+ int subscriptions) {
+ this.id = id;
+ this.caption = caption;
+ this.type = DeviceType.fromString(type);
+ this.subscriptions = subscriptions;
+ }
+
+ @Override
+ public String toString() {
+ return "GpodnetDevice [id=" + id + ", caption=" + caption + ", type="
+ + type + ", subscriptions=" + subscriptions + "]";
+ }
+
+ public enum DeviceType {
+ DESKTOP, LAPTOP, MOBILE, SERVER, OTHER;
+
+ static DeviceType fromString(String s) {
+ if (s == null) {
+ return OTHER;
+ }
+
+ switch (s) {
+ case "desktop":
+ return DESKTOP;
+ case "laptop":
+ return LAPTOP;
+ case "mobile":
+ return MOBILE;
+ case "server":
+ return SERVER;
+ default:
+ return OTHER;
+ }
+ }
+
+ @Override
+ public String toString() {
+ return super.toString().toLowerCase(Locale.US);
+ }
+
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getCaption() {
+ return caption;
+ }
+
+ public DeviceType getType() {
+ return type;
+ }
+
+ public int getSubscriptions() {
+ return subscriptions;
+ }
+
+}
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
new file mode 100644
index 000000000..6d573ebfc
--- /dev/null
+++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetEpisodeActionPostResponse.java
@@ -0,0 +1,49 @@
+package de.danoeh.antennapod.net.sync.gpoddernet.model;
+
+import androidx.collection.ArrayMap;
+
+import de.danoeh.antennapod.net.sync.model.UploadChangesResponse;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.commons.lang3.builder.ToStringStyle;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Map;
+
+public class GpodnetEpisodeActionPostResponse extends UploadChangesResponse {
+ /**
+ * URLs that should be updated. The key of the map is the original URL, the value of the map
+ * is the sanitized URL.
+ */
+ private final Map<String, String> updatedUrls;
+
+ private GpodnetEpisodeActionPostResponse(long timestamp, Map<String, String> updatedUrls) {
+ super(timestamp);
+ this.updatedUrls = updatedUrls;
+ }
+
+ /**
+ * Creates a new GpodnetUploadChangesResponse-object from a JSON object that was
+ * returned by an uploadChanges call.
+ *
+ * @throws org.json.JSONException If the method could not parse the JSONObject.
+ */
+ public static GpodnetEpisodeActionPostResponse fromJSONObject(String objectString) throws JSONException {
+ final JSONObject object = new JSONObject(objectString);
+ final long timestamp = object.getLong("timestamp");
+ JSONArray urls = object.getJSONArray("update_urls");
+ Map<String, String> updatedUrls = new ArrayMap<>(urls.length());
+ for (int i = 0; i < urls.length(); i++) {
+ JSONArray urlPair = urls.getJSONArray(i);
+ updatedUrls.put(urlPair.getString(0), urlPair.getString(1));
+ }
+ return new GpodnetEpisodeActionPostResponse(timestamp, updatedUrls);
+ }
+
+ @Override
+ public String toString() {
+ return ToStringBuilder.reflectionToString(this, ToStringStyle.SHORT_PREFIX_STYLE);
+ }
+}
+
diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetPodcast.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetPodcast.java
new file mode 100644
index 000000000..f09ab1244
--- /dev/null
+++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetPodcast.java
@@ -0,0 +1,72 @@
+package de.danoeh.antennapod.net.sync.gpoddernet.model;
+
+import androidx.annotation.NonNull;
+
+public class GpodnetPodcast {
+ private final String url;
+ private final String title;
+ private final String description;
+ private final int subscribers;
+ private final String logoUrl;
+ private final String website;
+ private final String mygpoLink;
+ private final String author;
+
+ public GpodnetPodcast(@NonNull String url,
+ @NonNull String title,
+ @NonNull String description,
+ int subscribers,
+ String logoUrl,
+ String website,
+ String mygpoLink,
+ String author
+ ) {
+ this.url = url;
+ this.title = title;
+ this.description = description;
+ this.subscribers = subscribers;
+ this.logoUrl = logoUrl;
+ this.website = website;
+ this.mygpoLink = mygpoLink;
+ this.author = author;
+ }
+
+ @Override
+ public String toString() {
+ return "GpodnetPodcast [url=" + url + ", title=" + title
+ + ", description=" + description + ", subscribers="
+ + subscribers + ", logoUrl=" + logoUrl + ", website=" + website
+ + ", mygpoLink=" + mygpoLink + "]";
+ }
+
+ public String getUrl() {
+ return url;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public int getSubscribers() {
+ return subscribers;
+ }
+
+ public String getLogoUrl() {
+ return logoUrl;
+ }
+
+ public String getWebsite() {
+ return website;
+ }
+
+ public String getAuthor() { return author; }
+
+ public String getMygpoLink() {
+ return mygpoLink;
+ }
+
+}
diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetTag.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetTag.java
new file mode 100644
index 000000000..d3d683598
--- /dev/null
+++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetTag.java
@@ -0,0 +1,66 @@
+package de.danoeh.antennapod.net.sync.gpoddernet.model;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import androidx.annotation.NonNull;
+
+public class GpodnetTag implements Parcelable {
+
+ private final String title;
+ private final String tag;
+ private final int usage;
+
+ public GpodnetTag(@NonNull String title, @NonNull String tag, int usage) {
+ this.title = title;
+ this.tag = tag;
+ this.usage = usage;
+ }
+
+ private GpodnetTag(Parcel in) {
+ title = in.readString();
+ tag = in.readString();
+ usage = in.readInt();
+ }
+
+ @Override
+ public String toString() {
+ return "GpodnetTag [title="+title+", tag=" + tag + ", usage=" + usage + "]";
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public String getTag() {
+ return tag;
+ }
+
+ public int getUsage() {
+ return usage;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeString(title);
+ dest.writeString(tag);
+ dest.writeInt(usage);
+ }
+
+ public static final Creator<GpodnetTag> CREATOR = new Creator<GpodnetTag>() {
+ @Override
+ public GpodnetTag createFromParcel(Parcel in) {
+ return new GpodnetTag(in);
+ }
+
+ @Override
+ public GpodnetTag[] newArray(int size) {
+ return new GpodnetTag[size];
+ }
+ };
+
+}
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
new file mode 100644
index 000000000..7b09531a5
--- /dev/null
+++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetUploadChangesResponse.java
@@ -0,0 +1,53 @@
+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 org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Map;
+
+/**
+ * Object returned by {@link GpodnetService} in uploadChanges method.
+ */
+public class GpodnetUploadChangesResponse extends UploadChangesResponse {
+ /**
+ * URLs that should be updated. The key of the map is the original URL, the value of the map
+ * is the sanitized URL.
+ */
+ public final Map<String, String> updatedUrls;
+
+ public GpodnetUploadChangesResponse(long timestamp, Map<String, String> updatedUrls) {
+ super(timestamp);
+ this.updatedUrls = updatedUrls;
+ }
+
+ /**
+ * Creates a new GpodnetUploadChangesResponse-object from a JSON object that was
+ * returned by an uploadChanges call.
+ *
+ * @throws org.json.JSONException If the method could not parse the JSONObject.
+ */
+ public static GpodnetUploadChangesResponse fromJSONObject(String objectString) throws JSONException {
+ final JSONObject object = new JSONObject(objectString);
+ final long timestamp = object.getLong("timestamp");
+ Map<String, String> updatedUrls = new ArrayMap<>();
+ JSONArray urls = object.getJSONArray("update_urls");
+ for (int i = 0; i < urls.length(); i++) {
+ JSONArray urlPair = urls.getJSONArray(i);
+ updatedUrls.put(urlPair.getString(0), urlPair.getString(1));
+ }
+ return new GpodnetUploadChangesResponse(timestamp, updatedUrls);
+ }
+
+ @Override
+ public String toString() {
+ return "GpodnetUploadChangesResponse{" +
+ "timestamp=" + timestamp +
+ ", updatedUrls=" + updatedUrls +
+ '}';
+ }
+}
diff --git a/net/sync/model/README.md b/net/sync/model/README.md
new file mode 100644
index 000000000..21d842914
--- /dev/null
+++ b/net/sync/model/README.md
@@ -0,0 +1,3 @@
+# :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
new file mode 100644
index 000000000..299359602
--- /dev/null
+++ b/net/sync/model/build.gradle
@@ -0,0 +1,54 @@
+apply plugin: "com.android.library"
+
+android {
+ compileSdkVersion rootProject.ext.compileSdkVersion
+
+ defaultConfig {
+ minSdkVersion rootProject.ext.minSdkVersion
+ targetSdkVersion rootProject.ext.targetSdkVersion
+
+ multiDexEnabled false
+
+ testApplicationId "de.danoeh.antennapod.core.tests"
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile("proguard-android.txt")
+ }
+ debug {
+ // debug build has method count over 64k single-dex threshold.
+ // For building debug build to use on Android < 21 (pre-Android 5) devices,
+ // you need to manually change class
+ // de.danoeh.antennapod.PodcastApp to extend MultiDexApplication .
+ // See Issue #2813
+ multiDexEnabled true
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ testOptions {
+ unitTests {
+ includeAndroidResources = true
+ }
+ }
+
+ lintOptions {
+ disable 'GradleDependency'
+ checkDependencies true
+ warningsAsErrors true
+ abortOnError true
+ }
+}
+
+dependencies {
+ implementation project(':model')
+
+ annotationProcessor "androidx.annotation:annotation:$annotationVersion"
+ implementation "androidx.appcompat:appcompat:$appcompatVersion"
+}
diff --git a/net/sync/model/src/main/AndroidManifest.xml b/net/sync/model/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..a7bd1e34b
--- /dev/null
+++ b/net/sync/model/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+<manifest package="de.danoeh.antennapod.net.sync.model" />
diff --git a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/EpisodeAction.java b/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/EpisodeAction.java
new file mode 100644
index 000000000..1aae5c811
--- /dev/null
+++ b/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/EpisodeAction.java
@@ -0,0 +1,267 @@
+package de.danoeh.antennapod.net.sync.model;
+
+import android.text.TextUtils;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.core.util.ObjectsCompat;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+import java.util.TimeZone;
+
+public class EpisodeAction {
+ private static final String TAG = "EpisodeAction";
+ private static final String PATTERN_ISO_DATEFORMAT = "yyyy-MM-dd'T'HH:mm:ss";
+ public static final Action NEW = Action.NEW;
+ public static final Action DOWNLOAD = Action.DOWNLOAD;
+ public static final Action PLAY = Action.PLAY;
+ public static final Action DELETE = Action.DELETE;
+
+ private final String podcast;
+ private final String episode;
+ private final Action action;
+ private final Date timestamp;
+ private final int started;
+ private final int position;
+ private final int total;
+
+ private EpisodeAction(Builder builder) {
+ this.podcast = builder.podcast;
+ this.episode = builder.episode;
+ this.action = builder.action;
+ this.timestamp = builder.timestamp;
+ this.started = builder.started;
+ this.position = builder.position;
+ this.total = builder.total;
+ }
+
+ /**
+ * Create an episode action object from JSON representation. Mandatory fields are "podcast",
+ * "episode" and "action".
+ *
+ * @param object JSON representation
+ * @return episode action object, or null if mandatory values are missing
+ */
+ public static EpisodeAction readFromJsonObject(JSONObject object) {
+ String podcast = object.optString("podcast", null);
+ String episode = object.optString("episode", null);
+ String actionString = object.optString("action", null);
+ if (TextUtils.isEmpty(podcast) || TextUtils.isEmpty(episode) || TextUtils.isEmpty(actionString)) {
+ return null;
+ }
+ EpisodeAction.Action action;
+ try {
+ action = EpisodeAction.Action.valueOf(actionString.toUpperCase(Locale.US));
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ EpisodeAction.Builder builder = new EpisodeAction.Builder(podcast, episode, action);
+ String utcTimestamp = object.optString("timestamp", null);
+ if (!TextUtils.isEmpty(utcTimestamp)) {
+ try {
+ SimpleDateFormat parser = new SimpleDateFormat(PATTERN_ISO_DATEFORMAT, Locale.US);
+ parser.setTimeZone(TimeZone.getTimeZone("UTC"));
+ builder.timestamp(parser.parse(utcTimestamp));
+ } catch (ParseException e) {
+ e.printStackTrace();
+ }
+ }
+ if (action == EpisodeAction.Action.PLAY) {
+ int started = object.optInt("started", -1);
+ int position = object.optInt("position", -1);
+ int total = object.optInt("total", -1);
+ if (started >= 0 && position > 0 && total > 0) {
+ builder
+ .started(started)
+ .position(position)
+ .total(total);
+ }
+ }
+ return builder.build();
+ }
+
+ public String getPodcast() {
+ return this.podcast;
+ }
+
+ public String getEpisode() {
+ return this.episode;
+ }
+
+ public Action getAction() {
+ return this.action;
+ }
+
+ private String getActionString() {
+ return this.action.name().toLowerCase(Locale.US);
+ }
+
+ public Date getTimestamp() {
+ return this.timestamp;
+ }
+
+ /**
+ * Returns the position (in seconds) at which the client started playback.
+ *
+ * @return start position (in seconds)
+ */
+ public int getStarted() {
+ return this.started;
+ }
+
+ /**
+ * Returns the position (in seconds) at which the client stopped playback.
+ *
+ * @return stop position (in seconds)
+ */
+ public int getPosition() {
+ return this.position;
+ }
+
+ /**
+ * Returns the total length of the file in seconds.
+ *
+ * @return total length in seconds
+ */
+ public int getTotal() {
+ return this.total;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof EpisodeAction)) {
+ return false;
+ }
+
+ EpisodeAction that = (EpisodeAction) o;
+ return started == that.started && position == that.position && total == that.total && action != that.action
+ && ObjectsCompat.equals(podcast, that.podcast)
+ && ObjectsCompat.equals(episode, that.episode)
+ && ObjectsCompat.equals(timestamp, that.timestamp);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = podcast != null ? podcast.hashCode() : 0;
+ result = 31 * result + (episode != null ? episode.hashCode() : 0);
+ result = 31 * result + (action != null ? action.hashCode() : 0);
+ result = 31 * result + (timestamp != null ? timestamp.hashCode() : 0);
+ result = 31 * result + started;
+ result = 31 * result + position;
+ result = 31 * result + total;
+ return result;
+ }
+
+ /**
+ * Returns a JSON object representation of this object.
+ *
+ * @return JSON object representation, or null if the object is invalid
+ */
+ public JSONObject writeToJsonObject() {
+ JSONObject obj = new JSONObject();
+ try {
+ obj.putOpt("podcast", this.podcast);
+ obj.putOpt("episode", this.episode);
+ obj.put("action", this.getActionString());
+ SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US);
+ formatter.setTimeZone(TimeZone.getTimeZone("UTC"));
+ obj.put("timestamp", formatter.format(this.timestamp));
+ if (this.getAction() == Action.PLAY) {
+ obj.put("started", this.started);
+ obj.put("position", this.position);
+ obj.put("total", this.total);
+ }
+ } catch (JSONException e) {
+ Log.e(TAG, "writeToJSONObject(): " + e.getMessage());
+ return null;
+ }
+ return obj;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "EpisodeAction{"
+ + "podcast='" + podcast + '\''
+ + ", episode='" + episode + '\''
+ + ", action=" + action
+ + ", timestamp=" + timestamp
+ + ", started=" + started
+ + ", position=" + position
+ + ", total=" + total
+ + '}';
+ }
+
+ public enum Action {
+ NEW, DOWNLOAD, PLAY, DELETE
+ }
+
+ public static class Builder {
+
+ // mandatory
+ private final String podcast;
+ private final String episode;
+ private final Action action;
+
+ // optional
+ private Date timestamp;
+ private int started = -1;
+ private int position = -1;
+ private int total = -1;
+
+ public Builder(FeedItem item, Action action) {
+ this(item.getFeed().getDownload_url(), item.getMedia().getDownload_url(), action);
+ }
+
+ public Builder(String podcast, String episode, Action action) {
+ this.podcast = podcast;
+ this.episode = episode;
+ this.action = action;
+ }
+
+ public Builder timestamp(Date timestamp) {
+ this.timestamp = timestamp;
+ return this;
+ }
+
+ public Builder currentTimestamp() {
+ return timestamp(new Date());
+ }
+
+ public Builder started(int seconds) {
+ if (action == Action.PLAY) {
+ this.started = seconds;
+ }
+ return this;
+ }
+
+ public Builder position(int seconds) {
+ if (action == Action.PLAY) {
+ this.position = seconds;
+ }
+ return this;
+ }
+
+ public Builder total(int seconds) {
+ if (action == Action.PLAY) {
+ this.total = seconds;
+ }
+ return this;
+ }
+
+ public EpisodeAction build() {
+ return new EpisodeAction(this);
+ }
+
+ }
+
+}
diff --git a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/EpisodeActionChanges.java b/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/EpisodeActionChanges.java
new file mode 100644
index 000000000..570e012c5
--- /dev/null
+++ b/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/EpisodeActionChanges.java
@@ -0,0 +1,34 @@
+package de.danoeh.antennapod.net.sync.model;
+
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+
+public class EpisodeActionChanges {
+
+ private final List<EpisodeAction> episodeActions;
+ private final long timestamp;
+
+ public EpisodeActionChanges(@NonNull List<EpisodeAction> episodeActions, long timestamp) {
+ this.episodeActions = episodeActions;
+ this.timestamp = timestamp;
+ }
+
+ public List<EpisodeAction> getEpisodeActions() {
+ return this.episodeActions;
+ }
+
+ public long getTimestamp() {
+ return this.timestamp;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "EpisodeActionGetResponse{"
+ + "episodeActions=" + episodeActions
+ + ", timestamp=" + timestamp
+ + '}';
+ }
+}
diff --git a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/ISyncService.java b/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/ISyncService.java
new file mode 100644
index 000000000..9c75e5dac
--- /dev/null
+++ b/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/ISyncService.java
@@ -0,0 +1,20 @@
+package de.danoeh.antennapod.net.sync.model;
+
+import java.util.List;
+
+public interface ISyncService {
+
+ void login() throws SyncServiceException;
+
+ SubscriptionChanges getSubscriptionChanges(long lastSync) throws SyncServiceException;
+
+ UploadChangesResponse uploadSubscriptionChanges(
+ List<String> addedFeeds, List<String> removedFeeds) throws SyncServiceException;
+
+ EpisodeActionChanges getEpisodeActionChanges(long lastSync) throws SyncServiceException;
+
+ UploadChangesResponse uploadEpisodeActions(List<EpisodeAction> queuedEpisodeActions)
+ throws SyncServiceException;
+
+ void logout() throws SyncServiceException;
+}
diff --git a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/SubscriptionChanges.java b/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/SubscriptionChanges.java
new file mode 100644
index 000000000..2fbc8b45e
--- /dev/null
+++ b/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/SubscriptionChanges.java
@@ -0,0 +1,39 @@
+package de.danoeh.antennapod.net.sync.model;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+
+public class SubscriptionChanges {
+ private final List<String> added;
+ private final List<String> removed;
+ private final long timestamp;
+
+ public SubscriptionChanges(@NonNull List<String> added,
+ @NonNull List<String> removed,
+ long timestamp) {
+ this.added = added;
+ this.removed = removed;
+ this.timestamp = timestamp;
+ }
+
+ @Override
+ public String toString() {
+ return "SubscriptionChange [added=" + added.toString()
+ + ", removed=" + removed.toString() + ", timestamp="
+ + timestamp + "]";
+ }
+
+ public List<String> getAdded() {
+ return added;
+ }
+
+ public List<String> getRemoved() {
+ return removed;
+ }
+
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+}
diff --git a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/SyncServiceException.java b/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/SyncServiceException.java
new file mode 100644
index 000000000..57262db17
--- /dev/null
+++ b/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/SyncServiceException.java
@@ -0,0 +1,13 @@
+package de.danoeh.antennapod.net.sync.model;
+
+public class SyncServiceException extends Exception {
+ private static final long serialVersionUID = 1L;
+
+ public SyncServiceException(String message) {
+ super(message);
+ }
+
+ public SyncServiceException(Throwable cause) {
+ super(cause);
+ }
+}
diff --git a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/UploadChangesResponse.java b/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/UploadChangesResponse.java
new file mode 100644
index 000000000..7503f429b
--- /dev/null
+++ b/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/UploadChangesResponse.java
@@ -0,0 +1,13 @@
+package de.danoeh.antennapod.net.sync.model;
+
+public abstract class UploadChangesResponse {
+
+ /**
+ * timestamp/ID that can be used for requesting changes since this upload.
+ */
+ public final long timestamp;
+
+ public UploadChangesResponse(long timestamp) {
+ this.timestamp = timestamp;
+ }
+}