From bc85ebc806367d863973bc9434e7b0d9d5fd2168 Mon Sep 17 00:00:00 2001 From: thrillfall Date: Wed, 6 Oct 2021 22:12:47 +0200 Subject: Add synchronization with gPodder Nextcloud server app (#5243) --- net/sync/gpoddernet/build.gradle | 3 + .../danoeh/antennapod/net/sync/HostnameParser.java | 41 +++++ .../net/sync/gpoddernet/GpodnetService.java | 125 ++++----------- .../net/sync/gpoddernet/mapper/ResponseMapper.java | 60 ++++++++ .../net/sync/nextcloud/NextcloudLoginFlow.java | 107 +++++++++++++ .../net/sync/nextcloud/NextcloudSyncService.java | 169 +++++++++++++++++++++ .../NextcloudSynchronizationServiceException.java | 9 ++ 7 files changed, 417 insertions(+), 97 deletions(-) create mode 100644 net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/HostnameParser.java create mode 100644 net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/mapper/ResponseMapper.java create mode 100644 net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudLoginFlow.java create mode 100644 net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSyncService.java create mode 100644 net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSynchronizationServiceException.java (limited to 'net') diff --git a/net/sync/gpoddernet/build.gradle b/net/sync/gpoddernet/build.gradle index eb5af1b60..77e9ce0f3 100644 --- a/net/sync/gpoddernet/build.gradle +++ b/net/sync/gpoddernet/build.gradle @@ -9,4 +9,7 @@ dependencies { implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" implementation "org.apache.commons:commons-lang3:$commonslangVersion" + implementation 'commons-io:commons-io:2.5' + implementation "io.reactivex.rxjava2:rxandroid:$rxAndroidVersion" + implementation "io.reactivex.rxjava2:rxjava:$rxJavaVersion" } diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/HostnameParser.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/HostnameParser.java new file mode 100644 index 000000000..ebb415248 --- /dev/null +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/HostnameParser.java @@ -0,0 +1,41 @@ +package de.danoeh.antennapod.net.sync; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class HostnameParser { + public String scheme; + public int port; + public String host; + + // split into schema, host and port - missing parts are null + private static final Pattern URLSPLIT_REGEX = Pattern.compile("(?:(https?)://)?([^:]+)(?::(\\d+))?"); + + public HostnameParser(String hosturl) { + Matcher m = URLSPLIT_REGEX.matcher(hosturl); + if (m.matches()) { + scheme = m.group(1); + host = m.group(2); + if (m.group(3) == null) { + port = -1; + } else { + port = 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 + scheme = "https"; + host = hosturl; + port = 443; + } + + if (scheme == null) { // assume https + scheme = "https"; + } + + if (scheme.equals("https") && port == -1) { + port = 443; + } else if (scheme.equals("http") && port == -1) { + port = 80; + } + } +} diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetService.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetService.java index eb18da80b..439a528b7 100644 --- a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetService.java +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetService.java @@ -1,25 +1,10 @@ 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 de.danoeh.antennapod.net.sync.HostnameParser; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -35,12 +20,28 @@ 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; +import de.danoeh.antennapod.net.sync.gpoddernet.mapper.ResponseMapper; +import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetDevice; +import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetEpisodeActionPostResponse; +import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetPodcast; +import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetTag; +import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetUploadChangesResponse; +import de.danoeh.antennapod.net.sync.model.EpisodeAction; +import de.danoeh.antennapod.net.sync.model.EpisodeActionChanges; +import de.danoeh.antennapod.net.sync.model.ISyncService; +import de.danoeh.antennapod.net.sync.model.SubscriptionChanges; +import de.danoeh.antennapod.net.sync.model.SyncServiceException; +import de.danoeh.antennapod.net.sync.model.UploadChangesResponse; +import okhttp3.Credentials; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; /** * Communicates with the gpodder.net service. @@ -61,43 +62,16 @@ public class GpodnetService implements ISyncService { private final OkHttpClient httpClient; - // split into schema, host and port - missing parts are null - private static final 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; - } + HostnameParser hostname = new HostnameParser(baseHosturl == null ? DEFAULT_BASE_HOST : baseHosturl); + this.baseHost = hostname.host; + this.basePort = hostname.port; + this.baseScheme = hostname.scheme; } private void requireLoggedIn() { @@ -434,7 +408,7 @@ public class GpodnetService implements ISyncService { String response = executeRequest(request); JSONObject changes = new JSONObject(response); - return readSubscriptionChangesFromJsonObject(changes); + return ResponseMapper.readSubscriptionChangesFromJsonObject(changes); } catch (URISyntaxException e) { e.printStackTrace(); throw new IllegalStateException(e); @@ -515,7 +489,7 @@ public class GpodnetService implements ISyncService { String response = executeRequest(request); JSONObject json = new JSONObject(response); - return readEpisodeActionsFromJsonObject(json); + return ResponseMapper.readEpisodeActionsFromJsonObject(json); } catch (URISyntaxException e) { e.printStackTrace(); throw new IllegalStateException(e); @@ -526,7 +500,6 @@ public class GpodnetService implements ISyncService { } - /** * Logs in a specific user. This method must be called if any of the methods * that require authentication is used. @@ -689,48 +662,6 @@ public class GpodnetService implements ISyncService { 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() { diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/mapper/ResponseMapper.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/mapper/ResponseMapper.java new file mode 100644 index 000000000..c8e607d74 --- /dev/null +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/mapper/ResponseMapper.java @@ -0,0 +1,60 @@ +package de.danoeh.antennapod.net.sync.gpoddernet.mapper; + +import androidx.annotation.NonNull; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; + +import de.danoeh.antennapod.net.sync.model.EpisodeAction; +import de.danoeh.antennapod.net.sync.model.EpisodeActionChanges; +import de.danoeh.antennapod.net.sync.model.SubscriptionChanges; + +public class ResponseMapper { + + public static SubscriptionChanges readSubscriptionChangesFromJsonObject(@NonNull JSONObject object) + 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); + } + + public static 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); + } +} diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudLoginFlow.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudLoginFlow.java new file mode 100644 index 000000000..b66c44402 --- /dev/null +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudLoginFlow.java @@ -0,0 +1,107 @@ +package de.danoeh.antennapod.net.sync.nextcloud; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import de.danoeh.antennapod.net.sync.HostnameParser; +import io.reactivex.Observable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.json.JSONException; +import org.json.JSONObject; +import android.util.Log; + +import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.util.concurrent.TimeUnit; + +public class NextcloudLoginFlow { + private static final String TAG = "NextcloudLoginFlow"; + + private final OkHttpClient httpClient; + private final HostnameParser hostname; + private final Context context; + private final AuthenticationCallback callback; + private String token; + private String endpoint; + private Disposable startDisposable; + private Disposable pollDisposable; + + public NextcloudLoginFlow(OkHttpClient httpClient, String hostUrl, Context context, + AuthenticationCallback callback) { + this.httpClient = httpClient; + this.hostname = new HostnameParser(hostUrl); + this.context = context; + this.callback = callback; + } + + public void start() { + startDisposable = Observable.fromCallable(() -> { + URL url = new URI(hostname.scheme, null, hostname.host, hostname.port, + "/index.php/login/v2", null, null).toURL(); + JSONObject result = doRequest(url, ""); + String loginUrl = result.getString("login"); + this.token = result.getJSONObject("poll").getString("token"); + this.endpoint = result.getJSONObject("poll").getString("endpoint"); + return loginUrl; + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + result -> { + Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(result)); + context.startActivity(browserIntent); + poll(); + }, error -> { + Log.e(TAG, Log.getStackTraceString(error)); + callback.onNextcloudAuthError(error.getLocalizedMessage()); + }); + } + + private void poll() { + pollDisposable = Observable.fromCallable(() -> doRequest(URI.create(endpoint).toURL(), "token=" + token)) + .delay(1, TimeUnit.SECONDS) + .retry(60 * 10) // 10 minutes + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(result -> { + callback.onNextcloudAuthenticated(result.getString("server"), + result.getString("loginName"), result.getString("appPassword")); + }, Throwable::printStackTrace); + } + + public void cancel() { + if (startDisposable != null) { + startDisposable.dispose(); + } + if (pollDisposable != null) { + pollDisposable.dispose(); + } + } + + private JSONObject doRequest(URL url, String bodyContent) throws IOException, JSONException { + RequestBody requestBody = RequestBody.create( + MediaType.get("application/x-www-form-urlencoded"), bodyContent); + Request request = new Request.Builder().url(url).method("POST", requestBody).build(); + Response response = httpClient.newCall(request).execute(); + if (response.code() != 200) { + throw new IOException("Return code " + response.code()); + } + ResponseBody body = response.body(); + return new JSONObject(body.string()); + } + + public interface AuthenticationCallback { + void onNextcloudAuthenticated(String server, String username, String password); + + void onNextcloudAuthError(String errorMessage); + } +} diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSyncService.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSyncService.java new file mode 100644 index 000000000..647a9073c --- /dev/null +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSyncService.java @@ -0,0 +1,169 @@ +package de.danoeh.antennapod.net.sync.nextcloud; + +import de.danoeh.antennapod.net.sync.HostnameParser; +import de.danoeh.antennapod.net.sync.gpoddernet.mapper.ResponseMapper; +import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetUploadChangesResponse; +import de.danoeh.antennapod.net.sync.model.EpisodeAction; +import de.danoeh.antennapod.net.sync.model.EpisodeActionChanges; +import de.danoeh.antennapod.net.sync.model.ISyncService; +import de.danoeh.antennapod.net.sync.model.SubscriptionChanges; +import de.danoeh.antennapod.net.sync.model.SyncServiceException; +import de.danoeh.antennapod.net.sync.model.UploadChangesResponse; +import okhttp3.Credentials; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.util.HashMap; +import java.util.List; + +public class NextcloudSyncService implements ISyncService { + private static final int UPLOAD_BULK_SIZE = 30; + private final OkHttpClient httpClient; + private final String baseScheme; + private final int basePort; + private final String baseHost; + private final String username; + private final String password; + + public NextcloudSyncService(OkHttpClient httpClient, String baseHosturl, + String username, String password) { + this.httpClient = httpClient; + this.username = username; + this.password = password; + HostnameParser hostname = new HostnameParser(baseHosturl); + this.baseHost = hostname.host; + this.basePort = hostname.port; + this.baseScheme = hostname.scheme; + } + + @Override + public void login() { + } + + @Override + public SubscriptionChanges getSubscriptionChanges(long lastSync) throws SyncServiceException { + try { + HttpUrl.Builder url = makeUrl("/index.php/apps/gpoddersync/subscriptions"); + url.addQueryParameter("since", "" + lastSync); + String responseString = performRequest(url, "GET", null); + JSONObject json = new JSONObject(responseString); + return ResponseMapper.readSubscriptionChangesFromJsonObject(json); + } catch (JSONException | MalformedURLException e) { + e.printStackTrace(); + throw new SyncServiceException(e); + } catch (Exception e) { + e.printStackTrace(); + throw new SyncServiceException(e); + } + } + + @Override + public UploadChangesResponse uploadSubscriptionChanges(List addedFeeds, + List removedFeeds) + throws NextcloudSynchronizationServiceException { + try { + HttpUrl.Builder url = makeUrl("/index.php/apps/gpoddersync/subscription_change/create"); + final JSONObject requestObject = new JSONObject(); + requestObject.put("add", new JSONArray(addedFeeds)); + requestObject.put("remove", new JSONArray(removedFeeds)); + RequestBody requestBody = RequestBody.create( + MediaType.get("application/json"), requestObject.toString()); + performRequest(url, "POST", requestBody); + } catch (Exception e) { + e.printStackTrace(); + throw new NextcloudSynchronizationServiceException(e); + } + + return new GpodnetUploadChangesResponse(System.currentTimeMillis() / 1000, new HashMap<>()); + } + + @Override + public EpisodeActionChanges getEpisodeActionChanges(long timestamp) throws SyncServiceException { + try { + HttpUrl.Builder uri = makeUrl("/index.php/apps/gpoddersync/episode_action"); + uri.addQueryParameter("since", "" + timestamp); + String responseString = performRequest(uri, "GET", null); + JSONObject json = new JSONObject(responseString); + return ResponseMapper.readEpisodeActionsFromJsonObject(json); + } catch (JSONException | MalformedURLException e) { + e.printStackTrace(); + throw new SyncServiceException(e); + } catch (Exception e) { + e.printStackTrace(); + throw new SyncServiceException(e); + } + } + + @Override + public UploadChangesResponse uploadEpisodeActions(List queuedEpisodeActions) + throws NextcloudSynchronizationServiceException { + for (int i = 0; i < queuedEpisodeActions.size(); i += UPLOAD_BULK_SIZE) { + uploadEpisodeActionsPartial(queuedEpisodeActions, + i, Math.min(queuedEpisodeActions.size(), i + UPLOAD_BULK_SIZE)); + } + return new NextcloudGpodderEpisodeActionPostResponse(System.currentTimeMillis() / 1000); + } + + private void uploadEpisodeActionsPartial(List queuedEpisodeActions, int from, int to) + throws NextcloudSynchronizationServiceException { + try { + final JSONArray list = new JSONArray(); + for (int i = from; i < to; i++) { + EpisodeAction episodeAction = queuedEpisodeActions.get(i); + JSONObject obj = episodeAction.writeToJsonObject(); + if (obj != null) { + list.put(obj); + } + } + HttpUrl.Builder url = makeUrl("/index.php/apps/gpoddersync/episode_action/create"); + RequestBody requestBody = RequestBody.create( + MediaType.get("application/json"), list.toString()); + performRequest(url, "POST", requestBody); + } catch (Exception e) { + e.printStackTrace(); + throw new NextcloudSynchronizationServiceException(e); + } + } + + private String performRequest(HttpUrl.Builder url, String method, RequestBody body) throws IOException { + Request request = new Request.Builder() + .url(url.build()) + .header("Authorization", Credentials.basic(username, password)) + .header("Accept", "application/json") + .method(method, body) + .build(); + Response response = httpClient.newCall(request).execute(); + if (response.code() != 200) { + throw new IOException("Response code: " + response.code()); + } + return response.body().string(); + } + + private HttpUrl.Builder makeUrl(String path) { + return new HttpUrl.Builder() + .scheme(baseScheme) + .host(baseHost) + .port(basePort) + .addPathSegments(path); + } + + @Override + public void logout() { + } + + private static class NextcloudGpodderEpisodeActionPostResponse extends UploadChangesResponse { + public NextcloudGpodderEpisodeActionPostResponse(long epochSecond) { + super(epochSecond); + } + } +} + diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSynchronizationServiceException.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSynchronizationServiceException.java new file mode 100644 index 000000000..d907c229e --- /dev/null +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSynchronizationServiceException.java @@ -0,0 +1,9 @@ +package de.danoeh.antennapod.net.sync.nextcloud; + +import de.danoeh.antennapod.net.sync.model.SyncServiceException; + +public class NextcloudSynchronizationServiceException extends SyncServiceException { + public NextcloudSynchronizationServiceException(Throwable e) { + super(e); + } +} -- cgit v1.2.3