diff options
Diffstat (limited to 'net/sync')
31 files changed, 1111 insertions, 114 deletions
diff --git a/net/sync/gpoddernet/build.gradle b/net/sync/gpoddernet/build.gradle index ff1fc2f00..3eec12b58 100644 --- a/net/sync/gpoddernet/build.gradle +++ b/net/sync/gpoddernet/build.gradle @@ -8,7 +8,7 @@ android { } dependencies { - implementation project(':net:sync:model') + implementation project(':net:sync:service-interface') annotationProcessor "androidx.annotation:annotation:$annotationVersion" implementation "androidx.appcompat:appcompat:$appcompatVersion" diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetService.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetService.java index 7873ae4fe..b04e9bdc9 100644 --- a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetService.java +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetService.java @@ -5,6 +5,12 @@ import android.util.Log; import androidx.annotation.NonNull; import de.danoeh.antennapod.net.sync.HostnameParser; +import de.danoeh.antennapod.net.sync.serviceinterface.EpisodeAction; +import de.danoeh.antennapod.net.sync.serviceinterface.EpisodeActionChanges; +import de.danoeh.antennapod.net.sync.serviceinterface.ISyncService; +import de.danoeh.antennapod.net.sync.serviceinterface.SubscriptionChanges; +import de.danoeh.antennapod.net.sync.serviceinterface.SyncServiceException; +import de.danoeh.antennapod.net.sync.serviceinterface.UploadChangesResponse; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -27,12 +33,6 @@ import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetDevice; import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetEpisodeActionPostResponse; import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetPodcast; import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetUploadChangesResponse; -import de.danoeh.antennapod.net.sync.model.EpisodeAction; -import de.danoeh.antennapod.net.sync.model.EpisodeActionChanges; -import de.danoeh.antennapod.net.sync.model.ISyncService; -import de.danoeh.antennapod.net.sync.model.SubscriptionChanges; -import de.danoeh.antennapod.net.sync.model.SyncServiceException; -import de.danoeh.antennapod.net.sync.model.UploadChangesResponse; import okhttp3.Credentials; import okhttp3.MediaType; import okhttp3.OkHttpClient; @@ -79,36 +79,6 @@ public class GpodnetService implements ISyncService { } /** - * Searches the podcast directory for a given string. - * - * @param query The search query - * @param scaledLogoSize The size of the logos that are returned by the search query. - * Must be in range 1..256. If the value is out of range, the - * default value defined by the gpodder.net API will be used. - */ - public List<GpodnetPodcast> searchPodcasts(String query, int scaledLogoSize) throws GpodnetServiceException { - String parameters = (scaledLogoSize > 0 && scaledLogoSize <= 256) ? String - .format(Locale.US, "q=%s&scale_logo=%d", query, scaledLogoSize) : String - .format("q=%s", query); - try { - URL url = new URI(baseScheme, null, baseHost, basePort, "/search.json", - parameters, null).toURL(); - Request.Builder request = new Request.Builder().url(url); - String response = executeRequest(request); - - JSONArray jsonArray = new JSONArray(response); - return readPodcastListFromJsonArray(jsonArray); - - } catch (JSONException | MalformedURLException e) { - e.printStackTrace(); - throw new GpodnetServiceException(e); - } catch (URISyntaxException e) { - e.printStackTrace(); - throw new IllegalStateException(e); - } - } - - /** * Returns all devices of a given user. * <p/> * This method requires authentication. @@ -157,7 +127,7 @@ public class GpodnetService implements ISyncService { } else { content = ""; } - RequestBody body = RequestBody.create(JSON, content); + RequestBody body = RequestBody.create(content, JSON); Request.Builder request = new Request.Builder().post(body).url(url); executeRequest(request); } catch (JSONException | MalformedURLException | URISyntaxException e) { @@ -167,38 +137,6 @@ public class GpodnetService implements ISyncService { } /** - * Uploads the subscriptions of a specific device. - * <p/> - * This method requires authentication. - * - * @param deviceId The ID of the device whose subscriptions should be updated. - * @param subscriptions A list of feed URLs containing all subscriptions of the - * device. - * @throws IllegalArgumentException If username, deviceId or subscriptions is null. - * @throws GpodnetServiceAuthenticationException If there is an authentication error. - */ - public void uploadSubscriptions(@NonNull String deviceId, @NonNull List<String> subscriptions) - throws GpodnetServiceException { - requireLoggedIn(); - try { - URL url = new URI(baseScheme, null, baseHost, basePort, - String.format("/subscriptions/%s/%s.txt", username, deviceId), null, null).toURL(); - StringBuilder builder = new StringBuilder(); - for (String s : subscriptions) { - builder.append(s); - builder.append("\n"); - } - RequestBody body = RequestBody.create(TEXT, builder.toString()); - Request.Builder request = new Request.Builder().put(body).url(url); - executeRequest(request); - } catch (MalformedURLException | URISyntaxException e) { - e.printStackTrace(); - throw new GpodnetServiceException(e); - } - - } - - /** * Updates the subscription list of a specific device. * <p/> * This method requires authentication. @@ -222,7 +160,7 @@ public class GpodnetService implements ISyncService { requestObject.put("add", new JSONArray(added)); requestObject.put("remove", new JSONArray(removed)); - RequestBody body = RequestBody.create(JSON, requestObject.toString()); + RequestBody body = RequestBody.create(requestObject.toString(), JSON); Request.Builder request = new Request.Builder().post(body).url(url); final String response = executeRequest(request); @@ -304,7 +242,7 @@ public class GpodnetService implements ISyncService { } } - RequestBody body = RequestBody.create(JSON, list.toString()); + RequestBody body = RequestBody.create(list.toString(), JSON); Request.Builder request = new Request.Builder().post(body).url(url); final String response = executeRequest(request); @@ -362,7 +300,7 @@ public class GpodnetService implements ISyncService { e.printStackTrace(); throw new GpodnetServiceException(e); } - RequestBody requestBody = RequestBody.create(TEXT, ""); + RequestBody requestBody = RequestBody.create("", TEXT); Request request = new Request.Builder().url(url).post(requestBody).build(); try { String credential = Credentials.basic(username, password, Charset.forName("UTF-8")); diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetServiceException.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetServiceException.java index 298c59073..5f16afbd1 100644 --- a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetServiceException.java +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetServiceException.java @@ -1,6 +1,6 @@ package de.danoeh.antennapod.net.sync.gpoddernet; -import de.danoeh.antennapod.net.sync.model.SyncServiceException; +import de.danoeh.antennapod.net.sync.serviceinterface.SyncServiceException; public class GpodnetServiceException extends SyncServiceException { private static final long serialVersionUID = 1L; diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/mapper/ResponseMapper.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/mapper/ResponseMapper.java index c8e607d74..2d2409eac 100644 --- a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/mapper/ResponseMapper.java +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/mapper/ResponseMapper.java @@ -2,6 +2,9 @@ package de.danoeh.antennapod.net.sync.gpoddernet.mapper; import androidx.annotation.NonNull; +import de.danoeh.antennapod.net.sync.serviceinterface.EpisodeAction; +import de.danoeh.antennapod.net.sync.serviceinterface.EpisodeActionChanges; +import de.danoeh.antennapod.net.sync.serviceinterface.SubscriptionChanges; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -10,10 +13,6 @@ import java.util.ArrayList; import java.util.LinkedList; import java.util.List; -import de.danoeh.antennapod.net.sync.model.EpisodeAction; -import de.danoeh.antennapod.net.sync.model.EpisodeActionChanges; -import de.danoeh.antennapod.net.sync.model.SubscriptionChanges; - public class ResponseMapper { public static SubscriptionChanges readSubscriptionChangesFromJsonObject(@NonNull JSONObject object) diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetEpisodeActionPostResponse.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetEpisodeActionPostResponse.java index 6d573ebfc..74e77ff44 100644 --- a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetEpisodeActionPostResponse.java +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetEpisodeActionPostResponse.java @@ -2,7 +2,7 @@ package de.danoeh.antennapod.net.sync.gpoddernet.model; import androidx.collection.ArrayMap; -import de.danoeh.antennapod.net.sync.model.UploadChangesResponse; +import de.danoeh.antennapod.net.sync.serviceinterface.UploadChangesResponse; import org.apache.commons.lang3.builder.ToStringBuilder; import org.apache.commons.lang3.builder.ToStringStyle; import org.json.JSONArray; diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetUploadChangesResponse.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetUploadChangesResponse.java index 7b09531a5..7d3f36fe4 100644 --- a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetUploadChangesResponse.java +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/model/GpodnetUploadChangesResponse.java @@ -3,7 +3,7 @@ package de.danoeh.antennapod.net.sync.gpoddernet.model; import androidx.collection.ArrayMap; import de.danoeh.antennapod.net.sync.gpoddernet.GpodnetService; -import de.danoeh.antennapod.net.sync.model.UploadChangesResponse; +import de.danoeh.antennapod.net.sync.serviceinterface.UploadChangesResponse; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudLoginFlow.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudLoginFlow.java index 3d2374acf..254be269f 100644 --- a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudLoginFlow.java +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudLoginFlow.java @@ -117,7 +117,7 @@ public class NextcloudLoginFlow { private JSONObject doRequest(URL url, String bodyContent) throws IOException, JSONException { RequestBody requestBody = RequestBody.create( - MediaType.get("application/x-www-form-urlencoded"), bodyContent); + bodyContent, MediaType.get("application/x-www-form-urlencoded")); Request request = new Request.Builder().url(url).method("POST", requestBody).build(); Response response = httpClient.newCall(request).execute(); if (response.code() != 200) { diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSyncService.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSyncService.java index 67b4ddab0..e98976c81 100644 --- a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSyncService.java +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSyncService.java @@ -3,12 +3,12 @@ package de.danoeh.antennapod.net.sync.nextcloud; import de.danoeh.antennapod.net.sync.HostnameParser; import de.danoeh.antennapod.net.sync.gpoddernet.mapper.ResponseMapper; import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetUploadChangesResponse; -import de.danoeh.antennapod.net.sync.model.EpisodeAction; -import de.danoeh.antennapod.net.sync.model.EpisodeActionChanges; -import de.danoeh.antennapod.net.sync.model.ISyncService; -import de.danoeh.antennapod.net.sync.model.SubscriptionChanges; -import de.danoeh.antennapod.net.sync.model.SyncServiceException; -import de.danoeh.antennapod.net.sync.model.UploadChangesResponse; +import de.danoeh.antennapod.net.sync.serviceinterface.EpisodeAction; +import de.danoeh.antennapod.net.sync.serviceinterface.EpisodeActionChanges; +import de.danoeh.antennapod.net.sync.serviceinterface.ISyncService; +import de.danoeh.antennapod.net.sync.serviceinterface.SubscriptionChanges; +import de.danoeh.antennapod.net.sync.serviceinterface.SyncServiceException; +import de.danoeh.antennapod.net.sync.serviceinterface.UploadChangesResponse; import okhttp3.Credentials; import okhttp3.HttpUrl; import okhttp3.MediaType; @@ -71,7 +71,7 @@ public class NextcloudSyncService implements ISyncService { requestObject.put("add", new JSONArray(addedFeeds)); requestObject.put("remove", new JSONArray(removedFeeds)); RequestBody requestBody = RequestBody.create( - MediaType.get("application/json"), requestObject.toString()); + requestObject.toString(), MediaType.get("application/json")); performRequest(url, "POST", requestBody); } catch (Exception e) { e.printStackTrace(); @@ -121,7 +121,7 @@ public class NextcloudSyncService implements ISyncService { } HttpUrl.Builder url = makeUrl("/index.php/apps/gpoddersync/episode_action/create"); RequestBody requestBody = RequestBody.create( - MediaType.get("application/json"), list.toString()); + list.toString(), MediaType.get("application/json")); performRequest(url, "POST", requestBody); } catch (Exception e) { e.printStackTrace(); diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSynchronizationServiceException.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSynchronizationServiceException.java index d907c229e..db66abce6 100644 --- a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSynchronizationServiceException.java +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSynchronizationServiceException.java @@ -1,6 +1,6 @@ package de.danoeh.antennapod.net.sync.nextcloud; -import de.danoeh.antennapod.net.sync.model.SyncServiceException; +import de.danoeh.antennapod.net.sync.serviceinterface.SyncServiceException; public class NextcloudSynchronizationServiceException extends SyncServiceException { public NextcloudSynchronizationServiceException(Throwable e) { diff --git a/net/sync/model/README.md b/net/sync/model/README.md deleted file mode 100644 index 21d842914..000000000 --- a/net/sync/model/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# :net:sync:model - -This module contains the basic interfaces for implementing a sync backend. diff --git a/net/sync/model/build.gradle b/net/sync/model/build.gradle deleted file mode 100644 index 8520b0a49..000000000 --- a/net/sync/model/build.gradle +++ /dev/null @@ -1,14 +0,0 @@ -plugins { - id("com.android.library") -} -apply from: "../../../common.gradle" - -android { - namespace "de.danoeh.antennapod.net.sync.model" -} - -dependencies { - implementation project(':model') - - annotationProcessor "androidx.annotation:annotation:$annotationVersion" -} diff --git a/net/sync/service-interface/README.md b/net/sync/service-interface/README.md new file mode 100644 index 000000000..2b6a3c412 --- /dev/null +++ b/net/sync/service-interface/README.md @@ -0,0 +1,3 @@ +# :net:sync:service-interface + +This module contains the interface for starting the sync service. diff --git a/net/sync/service-interface/build.gradle b/net/sync/service-interface/build.gradle new file mode 100644 index 000000000..fd170b36a --- /dev/null +++ b/net/sync/service-interface/build.gradle @@ -0,0 +1,17 @@ +plugins { + id("com.android.library") +} +apply from: "../../../common.gradle" + +android { + namespace "de.danoeh.antennapod.net.sync.serviceinterface" +} + +dependencies { + implementation project(':model') + implementation project(':storage:preferences') + + annotationProcessor "androidx.annotation:annotation:$annotationVersion" + implementation "io.reactivex.rxjava2:rxandroid:$rxAndroidVersion" + implementation "io.reactivex.rxjava2:rxjava:$rxJavaVersion" +} diff --git a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/EpisodeAction.java b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/EpisodeAction.java index 42fbdb310..3c3bd1418 100644 --- a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/EpisodeAction.java +++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/EpisodeAction.java @@ -1,4 +1,4 @@ -package de.danoeh.antennapod.net.sync.model; +package de.danoeh.antennapod.net.sync.serviceinterface; import android.text.TextUtils; import android.util.Log; @@ -239,7 +239,7 @@ public class EpisodeAction { private String guid; public Builder(FeedItem item, Action action) { - this(item.getFeed().getDownload_url(), item.getMedia().getDownload_url(), action); + this(item.getFeed().getDownloadUrl(), item.getMedia().getDownloadUrl(), action); this.guid(item.getItemIdentifier()); } diff --git a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/EpisodeActionChanges.java b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/EpisodeActionChanges.java index 570e012c5..d2b17b492 100644 --- a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/EpisodeActionChanges.java +++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/EpisodeActionChanges.java @@ -1,4 +1,4 @@ -package de.danoeh.antennapod.net.sync.model; +package de.danoeh.antennapod.net.sync.serviceinterface; import androidx.annotation.NonNull; diff --git a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/ISyncService.java b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/ISyncService.java index 9c75e5dac..29632ed1e 100644 --- a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/ISyncService.java +++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/ISyncService.java @@ -1,4 +1,4 @@ -package de.danoeh.antennapod.net.sync.model; +package de.danoeh.antennapod.net.sync.serviceinterface; import java.util.List; diff --git a/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/LockingAsyncExecutor.java b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/LockingAsyncExecutor.java new file mode 100644 index 000000000..2703cf018 --- /dev/null +++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/LockingAsyncExecutor.java @@ -0,0 +1,43 @@ +package de.danoeh.antennapod.net.sync.serviceinterface; + +import java.util.concurrent.locks.ReentrantLock; + +import io.reactivex.Completable; +import io.reactivex.schedulers.Schedulers; + +public class LockingAsyncExecutor { + + private static final ReentrantLock lock = new ReentrantLock(); + + /** + * Take the lock and execute runnable (to prevent changes to preferences being lost when enqueueing while sync is + * in progress). If the lock is free, the runnable is directly executed in the calling thread to prevent overhead. + */ + public static void executeLockedAsync(Runnable runnable) { + if (lock.tryLock()) { + try { + runnable.run(); + } finally { + lock.unlock(); + } + } else { + Completable.fromRunnable(() -> { + lock.lock(); + try { + runnable.run(); + } finally { + lock.unlock(); + } + }).subscribeOn(Schedulers.io()) + .subscribe(); + } + } + + public static void unlock() { + lock.unlock(); + } + + public static void lock() { + lock.lock(); + } +} diff --git a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/SubscriptionChanges.java b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SubscriptionChanges.java index 2fbc8b45e..c0c9f131d 100644 --- a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/SubscriptionChanges.java +++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SubscriptionChanges.java @@ -1,4 +1,4 @@ -package de.danoeh.antennapod.net.sync.model; +package de.danoeh.antennapod.net.sync.serviceinterface; import androidx.annotation.NonNull; diff --git a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/SyncServiceException.java b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SyncServiceException.java index 57262db17..5ccedd785 100644 --- a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/SyncServiceException.java +++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SyncServiceException.java @@ -1,4 +1,4 @@ -package de.danoeh.antennapod.net.sync.model; +package de.danoeh.antennapod.net.sync.serviceinterface; public class SyncServiceException extends Exception { private static final long serialVersionUID = 1L; diff --git a/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationProvider.java b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationProvider.java new file mode 100644 index 000000000..8c4047b6c --- /dev/null +++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationProvider.java @@ -0,0 +1,25 @@ +package de.danoeh.antennapod.net.sync.serviceinterface; + +public enum SynchronizationProvider { + GPODDER_NET("GPODDER_NET"), + NEXTCLOUD_GPODDER("NEXTCLOUD_GPODDER"); + + public static SynchronizationProvider fromIdentifier(String provider) { + for (SynchronizationProvider synchronizationProvider : SynchronizationProvider.values()) { + if (synchronizationProvider.getIdentifier().equals(provider)) { + return synchronizationProvider; + } + } + return null; + } + + private final String identifier; + + SynchronizationProvider(String identifier) { + this.identifier = identifier; + } + + public String getIdentifier() { + return identifier; + } +} diff --git a/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationQueueSink.java b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationQueueSink.java new file mode 100644 index 000000000..ad235130a --- /dev/null +++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationQueueSink.java @@ -0,0 +1,80 @@ +package de.danoeh.antennapod.net.sync.serviceinterface; + +import android.content.Context; + +import de.danoeh.antennapod.storage.preferences.SynchronizationSettings; +import de.danoeh.antennapod.model.feed.FeedMedia; + +public class SynchronizationQueueSink { + // To avoid a dependency loop of every class to SyncService, and from SyncService back to every class. + private static Runnable serviceStarterImpl = () -> { }; + + public static void setServiceStarterImpl(Runnable serviceStarter) { + serviceStarterImpl = serviceStarter; + } + + public static void syncNow() { + serviceStarterImpl.run(); + } + + public static void syncNowIfNotSyncedRecently() { + if (System.currentTimeMillis() - SynchronizationSettings.getLastSyncAttempt() > 1000 * 60 * 10) { + syncNow(); + } + } + + public static void clearQueue(Context context) { + LockingAsyncExecutor.executeLockedAsync(new SynchronizationQueueStorage(context)::clearQueue); + } + + public static void enqueueFeedAddedIfSynchronizationIsActive(Context context, String downloadUrl) { + if (!SynchronizationSettings.isProviderConnected()) { + return; + } + LockingAsyncExecutor.executeLockedAsync(() -> { + new SynchronizationQueueStorage(context).enqueueFeedAdded(downloadUrl); + syncNow(); + }); + } + + public static void enqueueFeedRemovedIfSynchronizationIsActive(Context context, String downloadUrl) { + if (!SynchronizationSettings.isProviderConnected()) { + return; + } + LockingAsyncExecutor.executeLockedAsync(() -> { + new SynchronizationQueueStorage(context).enqueueFeedRemoved(downloadUrl); + syncNow(); + }); + } + + public static void enqueueEpisodeActionIfSynchronizationIsActive(Context context, EpisodeAction action) { + if (!SynchronizationSettings.isProviderConnected()) { + return; + } + LockingAsyncExecutor.executeLockedAsync(() -> { + new SynchronizationQueueStorage(context).enqueueEpisodeAction(action); + syncNow(); + }); + } + + public static void enqueueEpisodePlayedIfSynchronizationIsActive(Context context, FeedMedia media, + boolean completed) { + if (!SynchronizationSettings.isProviderConnected()) { + return; + } + if (media.getItem() == null || media.getItem().getFeed().isLocalFeed()) { + return; + } + if (media.getStartPosition() < 0 || (!completed && media.getStartPosition() >= media.getPosition())) { + return; + } + EpisodeAction action = new EpisodeAction.Builder(media.getItem(), EpisodeAction.PLAY) + .currentTimestamp() + .started(media.getStartPosition() / 1000) + .position((completed ? media.getDuration() : media.getPosition()) / 1000) + .total(media.getDuration() / 1000) + .build(); + enqueueEpisodeActionIfSynchronizationIsActive(context, action); + } + +} diff --git a/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationQueueStorage.java b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationQueueStorage.java new file mode 100644 index 000000000..55dc07ae8 --- /dev/null +++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationQueueStorage.java @@ -0,0 +1,157 @@ +package de.danoeh.antennapod.net.sync.serviceinterface; + +import android.content.Context; +import android.content.SharedPreferences; + +import org.json.JSONArray; +import org.json.JSONException; + +import java.util.ArrayList; + +import de.danoeh.antennapod.storage.preferences.SynchronizationSettings; + +public class SynchronizationQueueStorage { + + private static final String NAME = "synchronization"; + private static final String QUEUED_EPISODE_ACTIONS = "sync_queued_episode_actions"; + private static final String QUEUED_FEEDS_REMOVED = "sync_removed"; + private static final String QUEUED_FEEDS_ADDED = "sync_added"; + private final SharedPreferences sharedPreferences; + + public SynchronizationQueueStorage(Context context) { + this.sharedPreferences = context.getSharedPreferences(NAME, Context.MODE_PRIVATE); + } + + public ArrayList<EpisodeAction> getQueuedEpisodeActions() { + ArrayList<EpisodeAction> actions = new ArrayList<>(); + try { + String json = getSharedPreferences() + .getString(QUEUED_EPISODE_ACTIONS, "[]"); + JSONArray queue = new JSONArray(json); + for (int i = 0; i < queue.length(); i++) { + actions.add(EpisodeAction.readFromJsonObject(queue.getJSONObject(i))); + } + } catch (JSONException e) { + e.printStackTrace(); + } + return actions; + } + + public ArrayList<String> getQueuedRemovedFeeds() { + ArrayList<String> removedFeedUrls = new ArrayList<>(); + try { + String json = getSharedPreferences() + .getString(QUEUED_FEEDS_REMOVED, "[]"); + JSONArray queue = new JSONArray(json); + for (int i = 0; i < queue.length(); i++) { + removedFeedUrls.add(queue.getString(i)); + } + } catch (JSONException e) { + e.printStackTrace(); + } + return removedFeedUrls; + + } + + public ArrayList<String> getQueuedAddedFeeds() { + ArrayList<String> addedFeedUrls = new ArrayList<>(); + try { + String json = getSharedPreferences() + .getString(QUEUED_FEEDS_ADDED, "[]"); + JSONArray queue = new JSONArray(json); + for (int i = 0; i < queue.length(); i++) { + addedFeedUrls.add(queue.getString(i)); + } + } catch (JSONException e) { + e.printStackTrace(); + } + return addedFeedUrls; + } + + public void clearEpisodeActionQueue() { + getSharedPreferences().edit() + .putString(QUEUED_EPISODE_ACTIONS, "[]").apply(); + + } + + public void clearFeedQueues() { + getSharedPreferences().edit() + .putString(QUEUED_FEEDS_ADDED, "[]") + .putString(QUEUED_FEEDS_REMOVED, "[]") + .apply(); + } + + protected void clearQueue() { + SynchronizationSettings.resetTimestamps(); + getSharedPreferences().edit() + .putString(QUEUED_EPISODE_ACTIONS, "[]") + .putString(QUEUED_FEEDS_ADDED, "[]") + .putString(QUEUED_FEEDS_REMOVED, "[]") + .apply(); + + } + + protected void enqueueFeedAdded(String downloadUrl) { + SharedPreferences sharedPreferences = getSharedPreferences(); + try { + JSONArray addedQueue = new JSONArray(sharedPreferences.getString(QUEUED_FEEDS_ADDED, "[]")); + addedQueue.put(downloadUrl); + JSONArray removedQueue = new JSONArray(sharedPreferences.getString(QUEUED_FEEDS_REMOVED, "[]")); + removedQueue.remove(indexOf(downloadUrl, removedQueue)); + sharedPreferences.edit() + .putString(QUEUED_FEEDS_ADDED, addedQueue.toString()) + .putString(QUEUED_FEEDS_REMOVED, removedQueue.toString()) + .apply(); + + } catch (JSONException jsonException) { + jsonException.printStackTrace(); + } + } + + protected void enqueueFeedRemoved(String downloadUrl) { + SharedPreferences sharedPreferences = getSharedPreferences(); + try { + JSONArray removedQueue = new JSONArray(sharedPreferences.getString(QUEUED_FEEDS_REMOVED, "[]")); + removedQueue.put(downloadUrl); + JSONArray addedQueue = new JSONArray(sharedPreferences.getString(QUEUED_FEEDS_ADDED, "[]")); + addedQueue.remove(indexOf(downloadUrl, addedQueue)); + sharedPreferences.edit() + .putString(QUEUED_FEEDS_ADDED, addedQueue.toString()) + .putString(QUEUED_FEEDS_REMOVED, removedQueue.toString()) + .apply(); + } catch (JSONException jsonException) { + jsonException.printStackTrace(); + } + } + + private int indexOf(String string, JSONArray array) { + try { + for (int i = 0; i < array.length(); i++) { + if (array.getString(i).equals(string)) { + return i; + } + } + } catch (JSONException jsonException) { + jsonException.printStackTrace(); + } + return -1; + } + + protected void enqueueEpisodeAction(EpisodeAction action) { + SharedPreferences sharedPreferences = getSharedPreferences(); + String json = sharedPreferences.getString(QUEUED_EPISODE_ACTIONS, "[]"); + try { + JSONArray queue = new JSONArray(json); + queue.put(action.writeToJsonObject()); + sharedPreferences.edit().putString( + QUEUED_EPISODE_ACTIONS, queue.toString() + ).apply(); + } catch (JSONException jsonException) { + jsonException.printStackTrace(); + } + } + + private SharedPreferences getSharedPreferences() { + return sharedPreferences; + } +} diff --git a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/UploadChangesResponse.java b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/UploadChangesResponse.java index 7503f429b..64bddc260 100644 --- a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/UploadChangesResponse.java +++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/UploadChangesResponse.java @@ -1,4 +1,4 @@ -package de.danoeh.antennapod.net.sync.model; +package de.danoeh.antennapod.net.sync.serviceinterface; public abstract class UploadChangesResponse { diff --git a/net/sync/service/README.md b/net/sync/service/README.md new file mode 100644 index 000000000..e4ce70c58 --- /dev/null +++ b/net/sync/service/README.md @@ -0,0 +1,3 @@ +# :net:sync:service + +This module contains the sync service. diff --git a/net/sync/service/build.gradle b/net/sync/service/build.gradle new file mode 100644 index 000000000..03b81a39c --- /dev/null +++ b/net/sync/service/build.gradle @@ -0,0 +1,35 @@ +plugins { + id("com.android.library") +} +apply from: "../../../common.gradle" +apply from: "../../../playFlavor.gradle" + +android { + namespace "de.danoeh.antennapod.net.sync.service" +} + +dependencies { + implementation project(':event') + implementation project(':model') + implementation project(':net:common') + implementation project(':net:sync:gpoddernet') + implementation project(':net:sync:service-interface') + implementation project(':storage:database') + implementation project(':storage:preferences') + implementation project(':ui:notifications') + implementation project(':ui:i18n') + implementation project(':net:download:service-interface') + + annotationProcessor "androidx.annotation:annotation:$annotationVersion" + implementation "androidx.core:core:$coreVersion" + implementation "androidx.work:work-runtime:$workManagerVersion" + + implementation "org.greenrobot:eventbus:$eventbusVersion" + implementation "org.apache.commons:commons-lang3:$commonslangVersion" + implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" + implementation "io.reactivex.rxjava2:rxandroid:$rxAndroidVersion" + implementation "io.reactivex.rxjava2:rxjava:$rxJavaVersion" + implementation "com.google.guava:guava:31.0.1-android" + + testImplementation "junit:junit:$junitVersion" +} diff --git a/net/sync/service/src/main/java/de/danoeh/antennapod/net/sync/service/EpisodeActionFilter.java b/net/sync/service/src/main/java/de/danoeh/antennapod/net/sync/service/EpisodeActionFilter.java new file mode 100644 index 000000000..17ea15ae8 --- /dev/null +++ b/net/sync/service/src/main/java/de/danoeh/antennapod/net/sync/service/EpisodeActionFilter.java @@ -0,0 +1,77 @@ +package de.danoeh.antennapod.net.sync.service; + +import android.util.Log; + +import androidx.collection.ArrayMap; +import androidx.core.util.Pair; + +import java.util.List; +import java.util.Map; + +import de.danoeh.antennapod.net.sync.serviceinterface.EpisodeAction; + +public class EpisodeActionFilter { + + public static final String TAG = "EpisodeActionFilter"; + + public static Map<Pair<String, String>, EpisodeAction> getRemoteActionsOverridingLocalActions( + List<EpisodeAction> remoteActions, + List<EpisodeAction> queuedEpisodeActions) { + // make sure more recent local actions are not overwritten by older remote actions + Map<Pair<String, String>, EpisodeAction> remoteActionsThatOverrideLocalActions = new ArrayMap<>(); + Map<Pair<String, String>, EpisodeAction> localMostRecentPlayActions = + createUniqueLocalMostRecentPlayActions(queuedEpisodeActions); + for (EpisodeAction remoteAction : remoteActions) { + Pair<String, String> key = new Pair<>(remoteAction.getPodcast(), remoteAction.getEpisode()); + switch (remoteAction.getAction()) { + case NEW: + case DOWNLOAD: + break; + case PLAY: + EpisodeAction localMostRecent = localMostRecentPlayActions.get(key); + if (secondActionOverridesFirstAction(remoteAction, localMostRecent)) { + break; + } + EpisodeAction remoteMostRecentAction = remoteActionsThatOverrideLocalActions.get(key); + if (secondActionOverridesFirstAction(remoteAction, remoteMostRecentAction)) { + break; + } + remoteActionsThatOverrideLocalActions.put(key, remoteAction); + break; + case DELETE: + // NEVER EVER call DBWriter.deleteFeedMediaOfItem() here, leads to an infinite loop + break; + default: + Log.e(TAG, "Unknown remoteAction: " + remoteAction); + break; + } + } + + return remoteActionsThatOverrideLocalActions; + } + + private static Map<Pair<String, String>, EpisodeAction> createUniqueLocalMostRecentPlayActions( + List<EpisodeAction> queuedEpisodeActions) { + Map<Pair<String, String>, EpisodeAction> localMostRecentPlayAction; + localMostRecentPlayAction = new ArrayMap<>(); + for (EpisodeAction action : queuedEpisodeActions) { + Pair<String, String> key = new Pair<>(action.getPodcast(), action.getEpisode()); + EpisodeAction mostRecent = localMostRecentPlayAction.get(key); + if (mostRecent == null || mostRecent.getTimestamp() == null) { + localMostRecentPlayAction.put(key, action); + } else if (mostRecent.getTimestamp().before(action.getTimestamp())) { + localMostRecentPlayAction.put(key, action); + } + } + return localMostRecentPlayAction; + } + + private static boolean secondActionOverridesFirstAction(EpisodeAction firstAction, + EpisodeAction secondAction) { + return secondAction != null + && secondAction.getTimestamp() != null + && (firstAction.getTimestamp() == null + || secondAction.getTimestamp().after(firstAction.getTimestamp())); + } + +} diff --git a/net/sync/service/src/main/java/de/danoeh/antennapod/net/sync/service/GuidValidator.java b/net/sync/service/src/main/java/de/danoeh/antennapod/net/sync/service/GuidValidator.java new file mode 100644 index 000000000..98ae8037e --- /dev/null +++ b/net/sync/service/src/main/java/de/danoeh/antennapod/net/sync/service/GuidValidator.java @@ -0,0 +1,11 @@ +package de.danoeh.antennapod.net.sync.service; + +public class GuidValidator { + + public static boolean isValidGuid(String guid) { + return guid != null + && !guid.trim().isEmpty() + && !guid.equals("null"); + } +} + diff --git a/net/sync/service/src/main/java/de/danoeh/antennapod/net/sync/service/SyncService.java b/net/sync/service/src/main/java/de/danoeh/antennapod/net/sync/service/SyncService.java new file mode 100644 index 000000000..97921e7f8 --- /dev/null +++ b/net/sync/service/src/main/java/de/danoeh/antennapod/net/sync/service/SyncService.java @@ -0,0 +1,390 @@ +package de.danoeh.antennapod.net.sync.service; + +import android.Manifest; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Build; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.core.app.NotificationCompat; +import androidx.core.content.ContextCompat; +import androidx.core.util.Pair; +import androidx.work.BackoffPolicy; +import androidx.work.Constraints; +import androidx.work.ExistingWorkPolicy; +import androidx.work.NetworkType; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkManager; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +import de.danoeh.antennapod.event.FeedUpdateRunningEvent; +import de.danoeh.antennapod.event.MessageEvent; +import de.danoeh.antennapod.model.feed.FeedItemFilter; +import de.danoeh.antennapod.model.feed.SortOrder; +import de.danoeh.antennapod.net.download.serviceinterface.FeedUpdateManager; +import de.danoeh.antennapod.net.sync.serviceinterface.LockingAsyncExecutor; +import de.danoeh.antennapod.net.sync.serviceinterface.SynchronizationProvider; +import de.danoeh.antennapod.net.sync.serviceinterface.SynchronizationQueueStorage; +import de.danoeh.antennapod.storage.database.DBWriter; +import de.danoeh.antennapod.storage.database.FeedDatabaseWriter; +import de.danoeh.antennapod.storage.preferences.SynchronizationCredentials; +import de.danoeh.antennapod.storage.preferences.SynchronizationSettings; +import de.danoeh.antennapod.ui.notifications.NotificationUtils; +import org.apache.commons.lang3.StringUtils; +import org.greenrobot.eventbus.EventBus; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import de.danoeh.antennapod.event.SyncServiceEvent; +import de.danoeh.antennapod.storage.preferences.UserPreferences; +import de.danoeh.antennapod.net.common.AntennapodHttpClient; +import de.danoeh.antennapod.storage.database.DBReader; +import de.danoeh.antennapod.storage.database.LongList; +import de.danoeh.antennapod.net.common.UrlChecker; +import de.danoeh.antennapod.model.feed.Feed; +import de.danoeh.antennapod.model.feed.FeedItem; +import de.danoeh.antennapod.model.feed.FeedMedia; +import de.danoeh.antennapod.net.sync.gpoddernet.GpodnetService; +import de.danoeh.antennapod.net.sync.serviceinterface.EpisodeAction; +import de.danoeh.antennapod.net.sync.serviceinterface.EpisodeActionChanges; +import de.danoeh.antennapod.net.sync.serviceinterface.ISyncService; +import de.danoeh.antennapod.net.sync.serviceinterface.SubscriptionChanges; +import de.danoeh.antennapod.net.sync.serviceinterface.SyncServiceException; +import de.danoeh.antennapod.net.sync.serviceinterface.UploadChangesResponse; +import de.danoeh.antennapod.net.sync.nextcloud.NextcloudSyncService; + +public class SyncService extends Worker { + public static final String TAG = "SyncService"; + private static final String WORK_ID_SYNC = "SyncServiceWorkId"; + + private static boolean isCurrentlyActive = false; + private final SynchronizationQueueStorage synchronizationQueueStorage; + + public SyncService(@NonNull Context context, @NonNull WorkerParameters params) { + super(context, params); + synchronizationQueueStorage = new SynchronizationQueueStorage(context); + } + + @Override + @NonNull + public Result doWork() { + ISyncService activeSyncProvider = getActiveSyncProvider(); + if (activeSyncProvider == null) { + return Result.success(); + } + + SynchronizationSettings.updateLastSynchronizationAttempt(); + setCurrentlyActive(true); + try { + activeSyncProvider.login(); + syncSubscriptions(activeSyncProvider); + waitForDownloadServiceCompleted(); + syncEpisodeActions(activeSyncProvider); + activeSyncProvider.logout(); + clearErrorNotifications(); + EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_success)); + SynchronizationSettings.setLastSynchronizationAttemptSuccess(true); + return Result.success(); + } catch (Exception e) { + EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_error)); + SynchronizationSettings.setLastSynchronizationAttemptSuccess(false); + Log.e(TAG, Log.getStackTraceString(e)); + + if (e instanceof SyncServiceException) { + if (getRunAttemptCount() % 3 == 2) { + // Do not spam users with notification and retry before notifying + updateErrorNotification(e); + } + return Result.retry(); + } else { + updateErrorNotification(e); + return Result.failure(); + } + } finally { + setCurrentlyActive(false); + } + } + + private static void setCurrentlyActive(boolean active) { + isCurrentlyActive = active; + } + + public static void sync(Context context) { + OneTimeWorkRequest workRequest = getWorkRequest().build(); + WorkManager.getInstance(context).enqueueUniqueWork(WORK_ID_SYNC, ExistingWorkPolicy.REPLACE, workRequest); + } + + public static void syncImmediately(Context context) { + OneTimeWorkRequest workRequest = getWorkRequest() + .setInitialDelay(0L, TimeUnit.SECONDS) + .build(); + WorkManager.getInstance(context).enqueueUniqueWork(WORK_ID_SYNC, ExistingWorkPolicy.REPLACE, workRequest); + } + + public static void fullSync(Context context) { + LockingAsyncExecutor.executeLockedAsync(() -> { + SynchronizationSettings.resetTimestamps(); + OneTimeWorkRequest workRequest = getWorkRequest() + .setInitialDelay(0L, TimeUnit.SECONDS) + .build(); + WorkManager.getInstance(context).enqueueUniqueWork(WORK_ID_SYNC, ExistingWorkPolicy.REPLACE, workRequest); + }); + } + + private void syncSubscriptions(ISyncService syncServiceImpl) throws SyncServiceException { + final long lastSync = SynchronizationSettings.getLastSubscriptionSynchronizationTimestamp(); + EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_subscriptions)); + final List<String> localSubscriptions = DBReader.getFeedListDownloadUrls(); + SubscriptionChanges subscriptionChanges = syncServiceImpl.getSubscriptionChanges(lastSync); + long newTimeStamp = subscriptionChanges.getTimestamp(); + + List<String> queuedRemovedFeeds = synchronizationQueueStorage.getQueuedRemovedFeeds(); + List<String> queuedAddedFeeds = synchronizationQueueStorage.getQueuedAddedFeeds(); + + Log.d(TAG, "Downloaded subscription changes: " + subscriptionChanges); + for (String downloadUrl : subscriptionChanges.getAdded()) { + if (!downloadUrl.startsWith("http")) { // Also matches https + Log.d(TAG, "Skipping url: " + downloadUrl); + continue; + } + if (!UrlChecker.containsUrl(localSubscriptions, downloadUrl) && !queuedRemovedFeeds.contains(downloadUrl)) { + Feed feed = new Feed(downloadUrl, null, "Unknown podcast"); + feed.setItems(Collections.emptyList()); + Feed newFeed = FeedDatabaseWriter.updateFeed(getApplicationContext(), feed, false); + FeedUpdateManager.getInstance().runOnce(getApplicationContext(), newFeed); + } + } + + // remove subscription if not just subscribed (again) + for (String downloadUrl : subscriptionChanges.getRemoved()) { + if (!queuedAddedFeeds.contains(downloadUrl)) { + DBWriter.removeFeedWithDownloadUrl(getApplicationContext(), downloadUrl); + } + } + + if (lastSync == 0) { + Log.d(TAG, "First sync. Adding all local subscriptions."); + queuedAddedFeeds = localSubscriptions; + queuedAddedFeeds.removeAll(subscriptionChanges.getAdded()); + queuedRemovedFeeds.removeAll(subscriptionChanges.getRemoved()); + } + + if (queuedAddedFeeds.size() > 0 || queuedRemovedFeeds.size() > 0) { + Log.d(TAG, "Added: " + StringUtils.join(queuedAddedFeeds, ", ")); + Log.d(TAG, "Removed: " + StringUtils.join(queuedRemovedFeeds, ", ")); + + LockingAsyncExecutor.lock(); + try { + UploadChangesResponse uploadResponse = syncServiceImpl + .uploadSubscriptionChanges(queuedAddedFeeds, queuedRemovedFeeds); + synchronizationQueueStorage.clearFeedQueues(); + newTimeStamp = uploadResponse.timestamp; + } finally { + LockingAsyncExecutor.unlock(); + } + } + SynchronizationSettings.setLastSubscriptionSynchronizationAttemptTimestamp(newTimeStamp); + } + + private void waitForDownloadServiceCompleted() { + EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_wait_for_downloads)); + try { + while (true) { + //noinspection BusyWait + Thread.sleep(1000); + FeedUpdateRunningEvent event = EventBus.getDefault().getStickyEvent(FeedUpdateRunningEvent.class); + if (event == null || !event.isFeedUpdateRunning) { + return; + } + } + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + + private void syncEpisodeActions(ISyncService syncServiceImpl) throws SyncServiceException { + final long lastSync = SynchronizationSettings.getLastEpisodeActionSynchronizationTimestamp(); + EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_episodes_download)); + EpisodeActionChanges getResponse = syncServiceImpl.getEpisodeActionChanges(lastSync); + long newTimeStamp = getResponse.getTimestamp(); + List<EpisodeAction> remoteActions = getResponse.getEpisodeActions(); + processEpisodeActions(remoteActions); + + // upload local actions + EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_episodes_upload)); + List<EpisodeAction> queuedEpisodeActions = synchronizationQueueStorage.getQueuedEpisodeActions(); + if (lastSync == 0) { + EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_upload_played)); + List<FeedItem> readItems = DBReader.getEpisodes(0, Integer.MAX_VALUE, + new FeedItemFilter(FeedItemFilter.PLAYED), SortOrder.DATE_NEW_OLD); + Log.d(TAG, "First sync. Upload state for all " + readItems.size() + " played episodes"); + for (FeedItem item : readItems) { + FeedMedia media = item.getMedia(); + if (media == null) { + continue; + } + EpisodeAction played = new EpisodeAction.Builder(item, EpisodeAction.PLAY) + .currentTimestamp() + .started(media.getDuration() / 1000) + .position(media.getDuration() / 1000) + .total(media.getDuration() / 1000) + .build(); + queuedEpisodeActions.add(played); + } + } + if (!queuedEpisodeActions.isEmpty()) { + LockingAsyncExecutor.lock(); + try { + Log.d(TAG, "Uploading " + queuedEpisodeActions.size() + " actions: " + + StringUtils.join(queuedEpisodeActions, ", ")); + UploadChangesResponse postResponse = syncServiceImpl.uploadEpisodeActions(queuedEpisodeActions); + newTimeStamp = postResponse.timestamp; + Log.d(TAG, "Upload episode response: " + postResponse); + synchronizationQueueStorage.clearEpisodeActionQueue(); + } finally { + LockingAsyncExecutor.unlock(); + } + } + SynchronizationSettings.setLastEpisodeActionSynchronizationAttemptTimestamp(newTimeStamp); + } + + private synchronized void processEpisodeActions(List<EpisodeAction> remoteActions) { + Log.d(TAG, "Processing " + remoteActions.size() + " actions"); + if (remoteActions.size() == 0) { + return; + } + + Map<Pair<String, String>, EpisodeAction> playActionsToUpdate = EpisodeActionFilter + .getRemoteActionsOverridingLocalActions(remoteActions, + synchronizationQueueStorage.getQueuedEpisodeActions()); + LongList queueToBeRemoved = new LongList(); + List<FeedItem> updatedItems = new ArrayList<>(); + for (EpisodeAction action : playActionsToUpdate.values()) { + String guid = GuidValidator.isValidGuid(action.getGuid()) ? action.getGuid() : null; + FeedItem feedItem = DBReader.getFeedItemByGuidOrEpisodeUrl(guid, action.getEpisode()); + if (feedItem == null) { + Log.i(TAG, "Unknown feed item: " + action); + continue; + } + if (feedItem.getMedia() == null) { + Log.i(TAG, "Feed item has no media: " + action); + continue; + } + FeedMedia media = feedItem.getMedia(); + media.setPosition(action.getPosition() * 1000); + int smartMarkAsPlayedSecs = UserPreferences.getSmartMarkAsPlayedSecs(); + boolean almostEnded = media.getDuration() > 0 + && media.getPosition() >= media.getDuration() - smartMarkAsPlayedSecs * 1000; + if (almostEnded) { + Log.d(TAG, "Marking as played: " + action); + feedItem.setPlayed(true); + media.setPosition(0); + queueToBeRemoved.add(feedItem.getId()); + } else { + Log.d(TAG, "Setting position: " + action); + } + updatedItems.add(feedItem); + } + DBWriter.removeQueueItem(getApplicationContext(), false, queueToBeRemoved.toArray()); + DBReader.loadAdditionalFeedItemListData(updatedItems); + DBWriter.setItemList(updatedItems); + } + + private void clearErrorNotifications() { + NotificationManager nm = (NotificationManager) getApplicationContext() + .getSystemService(Context.NOTIFICATION_SERVICE); + nm.cancel(R.id.notification_gpodnet_sync_error); + nm.cancel(R.id.notification_gpodnet_sync_autherror); + } + + private void updateErrorNotification(Exception exception) { + Log.d(TAG, "Posting sync error notification"); + final String description = getApplicationContext().getString(R.string.gpodnetsync_error_descr) + + exception.getMessage(); + + if (!UserPreferences.gpodnetNotificationsEnabled()) { + Log.d(TAG, "Skipping sync error notification because of user setting"); + return; + } + if (EventBus.getDefault().hasSubscriberForEvent(MessageEvent.class)) { + EventBus.getDefault().post(new MessageEvent(description)); + return; + } + + Intent intent = getApplicationContext().getPackageManager().getLaunchIntentForPackage( + getApplicationContext().getPackageName()); + PendingIntent pendingIntent = PendingIntent.getActivity(getApplicationContext(), + R.id.pending_intent_sync_error, intent, PendingIntent.FLAG_UPDATE_CURRENT + | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0)); + Notification notification = new NotificationCompat.Builder(getApplicationContext(), + NotificationUtils.CHANNEL_ID_SYNC_ERROR) + .setContentTitle(getApplicationContext().getString(R.string.gpodnetsync_error_title)) + .setContentText(description) + .setStyle(new NotificationCompat.BigTextStyle().bigText(description)) + .setContentIntent(pendingIntent) + .setSmallIcon(R.drawable.ic_notification_sync_error) + .setAutoCancel(true) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .build(); + NotificationManager nm = (NotificationManager) getApplicationContext() + .getSystemService(Context.NOTIFICATION_SERVICE); + if (ContextCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED) { + nm.notify(R.id.notification_gpodnet_sync_error, notification); + } + } + + private static OneTimeWorkRequest.Builder getWorkRequest() { + Constraints.Builder constraints = new Constraints.Builder(); + if (UserPreferences.isAllowMobileSync()) { + constraints.setRequiredNetworkType(NetworkType.CONNECTED); + } else { + constraints.setRequiredNetworkType(NetworkType.UNMETERED); + } + + OneTimeWorkRequest.Builder builder = new OneTimeWorkRequest.Builder(SyncService.class) + .setConstraints(constraints.build()) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.MINUTES); + + if (isCurrentlyActive) { + // Debounce: don't start sync again immediately after it was finished. + builder.setInitialDelay(2L, TimeUnit.MINUTES); + } else { + // Give it some time, so other possible actions can be queued. + builder.setInitialDelay(20L, TimeUnit.SECONDS); + EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_started)); + } + return builder; + } + + private ISyncService getActiveSyncProvider() { + String selectedSyncProviderKey = SynchronizationSettings.getSelectedSyncProviderKey(); + SynchronizationProvider selectedService = SynchronizationProvider + .fromIdentifier(selectedSyncProviderKey); + if (selectedService == null) { + return null; + } + switch (selectedService) { + case GPODDER_NET: + return new GpodnetService(AntennapodHttpClient.getHttpClient(), + SynchronizationCredentials.getHosturl(), SynchronizationCredentials.getDeviceId(), + SynchronizationCredentials.getUsername(), SynchronizationCredentials.getPassword()); + case NEXTCLOUD_GPODDER: + return new NextcloudSyncService(AntennapodHttpClient.getHttpClient(), + SynchronizationCredentials.getHosturl(), SynchronizationCredentials.getUsername(), + SynchronizationCredentials.getPassword()); + default: + return null; + } + } +} diff --git a/net/sync/service/src/main/res/values/ids.xml b/net/sync/service/src/main/res/values/ids.xml new file mode 100644 index 000000000..842e421ea --- /dev/null +++ b/net/sync/service/src/main/res/values/ids.xml @@ -0,0 +1,5 @@ +<resources> + <item name="notification_gpodnet_sync_error" type="id"/> + <item name="notification_gpodnet_sync_autherror" type="id"/> + <item name="pending_intent_sync_error" type="id"/> +</resources> diff --git a/net/sync/service/src/test/java/de/danoeh/antennapod/net/sync/service/EpisodeActionFilterTest.java b/net/sync/service/src/test/java/de/danoeh/antennapod/net/sync/service/EpisodeActionFilterTest.java new file mode 100644 index 000000000..38f5bdc4a --- /dev/null +++ b/net/sync/service/src/test/java/de/danoeh/antennapod/net/sync/service/EpisodeActionFilterTest.java @@ -0,0 +1,212 @@ +package de.danoeh.antennapod.net.sync.service; + + +import androidx.core.util.Pair; + +import junit.framework.TestCase; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import de.danoeh.antennapod.net.sync.serviceinterface.EpisodeAction; + + +public class EpisodeActionFilterTest extends TestCase { + + EpisodeActionFilter episodeActionFilter = new EpisodeActionFilter(); + + public void testGetRemoteActionsHappeningAfterLocalActions() throws ParseException { + SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + Date morning = format.parse("2021-01-01 08:00:00"); + Date lateMorning = format.parse("2021-01-01 09:00:00"); + + List<EpisodeAction> episodeActions = new ArrayList<>(); + episodeActions.add(new EpisodeAction + .Builder("podcast.a", "episode.1", EpisodeAction.Action.PLAY) + .timestamp(morning) + .position(10) + .build() + ); + episodeActions.add(new EpisodeAction + .Builder("podcast.a", "episode.1", EpisodeAction.Action.PLAY) + .timestamp(lateMorning) + .position(20) + .build() + ); + episodeActions.add(new EpisodeAction + .Builder("podcast.a", "episode.2", EpisodeAction.Action.PLAY) + .timestamp(morning) + .position(5) + .build() + ); + + Date morningFiveMinutesLater = format.parse("2021-01-01 08:05:00"); + List<EpisodeAction> remoteActions = new ArrayList<>(); + remoteActions.add(new EpisodeAction + .Builder("podcast.a", "episode.1", EpisodeAction.Action.PLAY) + .timestamp(morningFiveMinutesLater) + .position(10) + .build() + ); + remoteActions.add(new EpisodeAction + .Builder("podcast.a", "episode.2", EpisodeAction.Action.PLAY) + .timestamp(morningFiveMinutesLater) + .position(5) + .build() + ); + + Map<Pair<String, String>, EpisodeAction> uniqueList = episodeActionFilter + .getRemoteActionsOverridingLocalActions(remoteActions, episodeActions); + assertSame(1, uniqueList.size()); + } + + public void testGetRemoteActionsHappeningBeforeLocalActions() throws ParseException { + SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + Date morning = format.parse("2021-01-01 08:00:00"); + Date lateMorning = format.parse("2021-01-01 09:00:00"); + + List<EpisodeAction> episodeActions = new ArrayList<>(); + episodeActions.add(new EpisodeAction + .Builder("podcast.a", "episode.1", EpisodeAction.Action.PLAY) + .timestamp(morning) + .position(10) + .build() + ); + episodeActions.add(new EpisodeAction + .Builder("podcast.a", "episode.1", EpisodeAction.Action.PLAY) + .timestamp(lateMorning) + .position(20) + .build() + ); + episodeActions.add(new EpisodeAction + .Builder("podcast.a", "episode.2", EpisodeAction.Action.PLAY) + .timestamp(morning) + .position(5) + .build() + ); + + Date morningFiveMinutesEarlier = format.parse("2021-01-01 07:55:00"); + List<EpisodeAction> remoteActions = new ArrayList<>(); + remoteActions.add(new EpisodeAction + .Builder("podcast.a", "episode.1", EpisodeAction.Action.PLAY) + .timestamp(morningFiveMinutesEarlier) + .position(10) + .build() + ); + remoteActions.add(new EpisodeAction + .Builder("podcast.a", "episode.2", EpisodeAction.Action.PLAY) + .timestamp(morningFiveMinutesEarlier) + .position(5) + .build() + ); + + Map<Pair<String, String>, EpisodeAction> uniqueList = episodeActionFilter + .getRemoteActionsOverridingLocalActions(remoteActions, episodeActions); + assertSame(0, uniqueList.size()); + } + + public void testGetMultipleRemoteActionsHappeningAfterLocalActions() throws ParseException { + SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + Date morning = format.parse("2021-01-01 08:00:00"); + + List<EpisodeAction> episodeActions = new ArrayList<>(); + episodeActions.add(new EpisodeAction + .Builder("podcast.a", "episode.1", EpisodeAction.Action.PLAY) + .timestamp(morning) + .position(10) + .build() + ); + episodeActions.add(new EpisodeAction + .Builder("podcast.a", "episode.2", EpisodeAction.Action.PLAY) + .timestamp(morning) + .position(5) + .build() + ); + + Date morningFiveMinutesLater = format.parse("2021-01-01 08:05:00"); + List<EpisodeAction> remoteActions = new ArrayList<>(); + remoteActions.add(new EpisodeAction + .Builder("podcast.a", "episode.1", EpisodeAction.Action.PLAY) + .timestamp(morningFiveMinutesLater) + .position(10) + .build() + ); + remoteActions.add(new EpisodeAction + .Builder("podcast.a", "episode.2", EpisodeAction.Action.PLAY) + .timestamp(morningFiveMinutesLater) + .position(5) + .build() + ); + + Map<Pair<String, String>, EpisodeAction> uniqueList = episodeActionFilter + .getRemoteActionsOverridingLocalActions(remoteActions, episodeActions); + assertEquals(2, uniqueList.size()); + } + + public void testGetMultipleRemoteActionsHappeningBeforeLocalActions() throws ParseException { + SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + Date morning = format.parse("2021-01-01 08:00:00"); + + List<EpisodeAction> episodeActions = new ArrayList<>(); + episodeActions.add(new EpisodeAction + .Builder("podcast.a", "episode.1", EpisodeAction.Action.PLAY) + .timestamp(morning) + .position(10) + .build() + ); + episodeActions.add(new EpisodeAction + .Builder("podcast.a", "episode.2", EpisodeAction.Action.PLAY) + .timestamp(morning) + .position(5) + .build() + ); + + Date morningFiveMinutesEarlier = format.parse("2021-01-01 07:55:00"); + List<EpisodeAction> remoteActions = new ArrayList<>(); + remoteActions.add(new EpisodeAction + .Builder("podcast.a", "episode.1", EpisodeAction.Action.PLAY) + .timestamp(morningFiveMinutesEarlier) + .position(10) + .build() + ); + remoteActions.add(new EpisodeAction + .Builder("podcast.a", "episode.2", EpisodeAction.Action.PLAY) + .timestamp(morningFiveMinutesEarlier) + .position(5) + .build() + ); + + Map<Pair<String, String>, EpisodeAction> uniqueList = episodeActionFilter + .getRemoteActionsOverridingLocalActions(remoteActions, episodeActions); + assertEquals(0, uniqueList.size()); + } + + public void testPresentRemoteTimestampOverridesMissingLocalTimestamp() throws ParseException { + SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + Date arbitraryTime = format.parse("2021-01-01 08:00:00"); + + List<EpisodeAction> episodeActions = new ArrayList<>(); + episodeActions.add(new EpisodeAction + .Builder("podcast.a", "episode.1", EpisodeAction.Action.PLAY) + // no timestamp + .position(10) + .build() + ); + + List<EpisodeAction> remoteActions = new ArrayList<>(); + remoteActions.add(new EpisodeAction + .Builder("podcast.a", "episode.1", EpisodeAction.Action.PLAY) + .timestamp(arbitraryTime) + .position(10) + .build() + ); + + Map<Pair<String, String>, EpisodeAction> uniqueList = episodeActionFilter + .getRemoteActionsOverridingLocalActions(remoteActions, episodeActions); + assertSame(1, uniqueList.size()); + } +} diff --git a/net/sync/service/src/test/java/de/danoeh/antennapod/net/sync/service/GuidValidatorTest.java b/net/sync/service/src/test/java/de/danoeh/antennapod/net/sync/service/GuidValidatorTest.java new file mode 100644 index 000000000..3e3d77a1f --- /dev/null +++ b/net/sync/service/src/test/java/de/danoeh/antennapod/net/sync/service/GuidValidatorTest.java @@ -0,0 +1,19 @@ +package de.danoeh.antennapod.net.sync.service; + +import junit.framework.TestCase; + +public class GuidValidatorTest extends TestCase { + + public void testIsValidGuid() { + assertTrue(GuidValidator.isValidGuid("skfjsdvgsd")); + } + + public void testIsInvalidGuid() { + assertFalse(GuidValidator.isValidGuid("")); + assertFalse(GuidValidator.isValidGuid(" ")); + assertFalse(GuidValidator.isValidGuid("\n")); + assertFalse(GuidValidator.isValidGuid(" \n")); + assertFalse(GuidValidator.isValidGuid(null)); + assertFalse(GuidValidator.isValidGuid("null")); + } +}
\ No newline at end of file |