diff options
Diffstat (limited to 'net/sync')
16 files changed, 1102 insertions, 0 deletions
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..c1a559da3 --- /dev/null +++ b/net/sync/service-interface/build.gradle @@ -0,0 +1,19 @@ +plugins { + id("com.android.library") +} +apply from: "../../../common.gradle" + +android { + namespace "de.danoeh.antennapod.net.sync.serviceinterface" +} + +dependencies { + implementation project(':model') + implementation project(':net:sync:model') + implementation project(':storage:preferences') + implementation project(':ui:i18n') + + annotationProcessor "androidx.annotation:annotation:$annotationVersion" + implementation "io.reactivex.rxjava2:rxandroid:$rxAndroidVersion" + implementation "io.reactivex.rxjava2:rxjava:$rxJavaVersion" +} 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/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationProviderViewData.java b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationProviderViewData.java new file mode 100644 index 000000000..19624a95a --- /dev/null +++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationProviderViewData.java @@ -0,0 +1,45 @@ +package de.danoeh.antennapod.net.sync.serviceinterface; + +public enum SynchronizationProviderViewData { + GPODDER_NET( + "GPODDER_NET", + R.string.gpodnet_description, + R.drawable.gpodder_icon + ), + NEXTCLOUD_GPODDER( + "NEXTCLOUD_GPODDER", + R.string.synchronization_summary_nextcloud, + R.drawable.nextcloud_logo + ); + + public static SynchronizationProviderViewData fromIdentifier(String provider) { + for (SynchronizationProviderViewData synchronizationProvider : SynchronizationProviderViewData.values()) { + if (synchronizationProvider.getIdentifier().equals(provider)) { + return synchronizationProvider; + } + } + return null; + } + + private final String identifier; + private final int iconResource; + private final int summaryResource; + + SynchronizationProviderViewData(String identifier, int summaryResource, int iconResource) { + this.identifier = identifier; + this.iconResource = iconResource; + this.summaryResource = summaryResource; + } + + public String getIdentifier() { + return identifier; + } + + public int getIconResource() { + return iconResource; + } + + public int getSummaryResource() { + return summaryResource; + } +} 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..8c94c44e7 --- /dev/null +++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationQueueSink.java @@ -0,0 +1,81 @@ +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; +import de.danoeh.antennapod.net.sync.model.EpisodeAction; + +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..0ae794ac8 --- /dev/null +++ b/net/sync/service-interface/src/main/java/de/danoeh/antennapod/net/sync/serviceinterface/SynchronizationQueueStorage.java @@ -0,0 +1,158 @@ +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; +import de.danoeh.antennapod.net.sync.model.EpisodeAction; + +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/service-interface/src/main/res/drawable-nodpi/gpodder_icon.png b/net/sync/service-interface/src/main/res/drawable-nodpi/gpodder_icon.png Binary files differnew file mode 100644 index 000000000..cd133aa98 --- /dev/null +++ b/net/sync/service-interface/src/main/res/drawable-nodpi/gpodder_icon.png diff --git a/net/sync/service-interface/src/main/res/drawable-nodpi/nextcloud_logo.png b/net/sync/service-interface/src/main/res/drawable-nodpi/nextcloud_logo.png Binary files differnew file mode 100644 index 000000000..2164e37fb --- /dev/null +++ b/net/sync/service-interface/src/main/res/drawable-nodpi/nextcloud_logo.png diff --git a/net/sync/service-interface/src/main/res/values/ids.xml b/net/sync/service-interface/src/main/res/values/ids.xml new file mode 100644 index 000000000..842e421ea --- /dev/null +++ b/net/sync/service-interface/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/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..7fa19d320 --- /dev/null +++ b/net/sync/service/build.gradle @@ -0,0 +1,36 @@ +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:model') + 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..42fc1b7b9 --- /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.model.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..f52c2b81d --- /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.SynchronizationProviderViewData; +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.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.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(); + SynchronizationProviderViewData selectedService = SynchronizationProviderViewData + .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/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..22ea316d4 --- /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.model.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 |