From e30533a810efa59bba6c101dd1405e776e1b451e Mon Sep 17 00:00:00 2001 From: ByteHamster Date: Sat, 24 Apr 2021 16:18:02 +0200 Subject: Moved synchronization to its own module --- net/sync/README.md | 3 + net/sync/gpoddernet/README.md | 3 + net/sync/gpoddernet/build.gradle | 57 ++ net/sync/gpoddernet/src/main/AndroidManifest.xml | 1 + .../net/sync/gpoddernet/GpodnetService.java | 818 +++++++++++++++++++++ .../GpodnetServiceAuthenticationException.java | 9 + .../GpodnetServiceBadStatusCodeException.java | 12 + .../sync/gpoddernet/GpodnetServiceException.java | 15 + .../net/sync/gpoddernet/model/GpodnetDevice.java | 75 ++ .../model/GpodnetEpisodeActionPostResponse.java | 49 ++ .../net/sync/gpoddernet/model/GpodnetPodcast.java | 72 ++ .../net/sync/gpoddernet/model/GpodnetTag.java | 66 ++ .../model/GpodnetUploadChangesResponse.java | 53 ++ net/sync/model/README.md | 3 + net/sync/model/build.gradle | 54 ++ net/sync/model/src/main/AndroidManifest.xml | 1 + .../antennapod/net/sync/model/EpisodeAction.java | 267 +++++++ .../net/sync/model/EpisodeActionChanges.java | 34 + .../antennapod/net/sync/model/ISyncService.java | 20 + .../net/sync/model/SubscriptionChanges.java | 39 + .../net/sync/model/SyncServiceException.java | 13 + .../net/sync/model/UploadChangesResponse.java | 13 + 22 files changed, 1677 insertions(+) create mode 100644 net/sync/README.md create mode 100644 net/sync/gpoddernet/README.md create mode 100644 net/sync/gpoddernet/build.gradle create mode 100644 net/sync/gpoddernet/src/main/AndroidManifest.xml create mode 100644 net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetService.java create mode 100644 net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetServiceAuthenticationException.java create mode 100644 net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetServiceBadStatusCodeException.java create mode 100644 net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetServiceException.java create mode 100644 net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetDevice.java create mode 100644 net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetEpisodeActionPostResponse.java create mode 100644 net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetPodcast.java create mode 100644 net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetTag.java create mode 100644 net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetUploadChangesResponse.java create mode 100644 net/sync/model/README.md create mode 100644 net/sync/model/build.gradle create mode 100644 net/sync/model/src/main/AndroidManifest.xml create mode 100644 net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/EpisodeAction.java create mode 100644 net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/EpisodeActionChanges.java create mode 100644 net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/ISyncService.java create mode 100644 net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/SubscriptionChanges.java create mode 100644 net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/SyncServiceException.java create mode 100644 net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/UploadChangesResponse.java (limited to 'net') 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 @@ + 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 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 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 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 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. + *

+ * 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 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 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. + *

+ * This method requires authentication. + * + * @throws GpodnetServiceAuthenticationException If there is an authentication error. + */ + public List 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. + *

+ * This method requires authentication. + * + * @throws GpodnetServiceAuthenticationException If there is an authentication error. + */ + public List> 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> result = new ArrayList<>(); + + JSONArray synchronizedDevices = syncStatus.getJSONArray("synchronized"); + for (int i = 0; i < synchronizedDevices.length(); i++) { + JSONArray groupDevices = synchronizedDevices.getJSONArray(i); + List 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. + *

+ * 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. + *

+ * This method requires authentication. + * + * @throws GpodnetServiceAuthenticationException If there is an authentication error. + */ + public void linkDevices(@NonNull List 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. + *

+ * 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. + *

+ * 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. + *

+ * 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 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. + *

+ * 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 added, List 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. + *

+ * 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 + *

+ * 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 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 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. + *

+ * 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 readPodcastListFromJsonArray(@NonNull JSONArray array) throws JSONException { + List 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 readDeviceListFromJsonArray(@NonNull JSONArray array) throws JSONException { + List 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 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 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 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 updatedUrls; + + private GpodnetEpisodeActionPostResponse(long timestamp, Map 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 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 CREATOR = new Creator() { + @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 updatedUrls; + + public GpodnetUploadChangesResponse(long timestamp, Map 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 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 @@ + 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 episodeActions; + private final long timestamp; + + public EpisodeActionChanges(@NonNull List episodeActions, long timestamp) { + this.episodeActions = episodeActions; + this.timestamp = timestamp; + } + + public List 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 addedFeeds, List removedFeeds) throws SyncServiceException; + + EpisodeActionChanges getEpisodeActionChanges(long lastSync) throws SyncServiceException; + + UploadChangesResponse uploadEpisodeActions(List 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 added; + private final List removed; + private final long timestamp; + + public SubscriptionChanges(@NonNull List added, + @NonNull List 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 getAdded() { + return added; + } + + public List 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; + } +} -- cgit v1.2.3