AntennaPod/net/sync/service/src/main/java/de/danoeh/antennapod/net/sync/service/SyncService.java

391 lines
18 KiB
Java

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;
}
}
}