Add synchronization with gPodder Nextcloud server app (#5243)

This commit is contained in:
thrillfall 2021-10-06 22:12:47 +02:00 committed by GitHub
parent dab44b6843
commit bc85ebc806
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 1530 additions and 768 deletions

View File

@ -6,6 +6,7 @@ import android.os.Bundle;
import android.provider.Settings;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.inputmethod.InputMethodManager;
@ -21,13 +22,13 @@ import de.danoeh.antennapod.R;
import de.danoeh.antennapod.core.preferences.UserPreferences;
import de.danoeh.antennapod.databinding.SettingsActivityBinding;
import de.danoeh.antennapod.fragment.preferences.AutoDownloadPreferencesFragment;
import de.danoeh.antennapod.fragment.preferences.GpodderPreferencesFragment;
import de.danoeh.antennapod.fragment.preferences.ImportExportPreferencesFragment;
import de.danoeh.antennapod.fragment.preferences.MainPreferencesFragment;
import de.danoeh.antennapod.fragment.preferences.NetworkPreferencesFragment;
import de.danoeh.antennapod.fragment.preferences.NotificationPreferencesFragment;
import de.danoeh.antennapod.fragment.preferences.PlaybackPreferencesFragment;
import de.danoeh.antennapod.fragment.preferences.StoragePreferencesFragment;
import de.danoeh.antennapod.fragment.preferences.synchronization.SynchronizationPreferencesFragment;
import de.danoeh.antennapod.fragment.preferences.SwipePreferencesFragment;
import de.danoeh.antennapod.fragment.preferences.UserInterfacePreferencesFragment;
@ -76,8 +77,8 @@ public class PreferenceActivity extends AppCompatActivity implements SearchPrefe
prefFragment = new ImportExportPreferencesFragment();
} else if (screen == R.xml.preferences_autodownload) {
prefFragment = new AutoDownloadPreferencesFragment();
} else if (screen == R.xml.preferences_gpodder) {
prefFragment = new GpodderPreferencesFragment();
} else if (screen == R.xml.preferences_synchronization) {
prefFragment = new SynchronizationPreferencesFragment();
} else if (screen == R.xml.preferences_playback) {
prefFragment = new PlaybackPreferencesFragment();
} else if (screen == R.xml.preferences_notifications) {
@ -101,8 +102,8 @@ public class PreferenceActivity extends AppCompatActivity implements SearchPrefe
return R.string.import_export_pref;
} else if (preferences == R.xml.preferences_user_interface) {
return R.string.user_interface_label;
} else if (preferences == R.xml.preferences_gpodder) {
return R.string.gpodnet_main_label;
} else if (preferences == R.xml.preferences_synchronization) {
return R.string.synchronization_pref;
} else if (preferences == R.xml.preferences_notifications) {
return R.string.notification_pref_fragment;
} else if (preferences == R.xml.feed_settings) {

View File

@ -1,6 +1,6 @@
package de.danoeh.antennapod.discovery;
import de.danoeh.antennapod.core.preferences.GpodnetPreferences;
import de.danoeh.antennapod.core.sync.SynchronizationCredentials;
import de.danoeh.antennapod.core.service.download.AntennapodHttpClient;
import de.danoeh.antennapod.net.sync.gpoddernet.GpodnetService;
import de.danoeh.antennapod.net.sync.gpoddernet.GpodnetServiceException;
@ -18,8 +18,8 @@ public class GpodnetPodcastSearcher implements PodcastSearcher {
return Single.create((SingleOnSubscribe<List<PodcastSearchResult>>) subscriber -> {
try {
GpodnetService service = new GpodnetService(AntennapodHttpClient.getHttpClient(),
GpodnetPreferences.getHosturl(), GpodnetPreferences.getDeviceID(),
GpodnetPreferences.getUsername(), GpodnetPreferences.getPassword());
SynchronizationCredentials.getHosturl(), SynchronizationCredentials.getDeviceID(),
SynchronizationCredentials.getUsername(), SynchronizationCredentials.getPassword());
List<GpodnetPodcast> gpodnetPodcasts = service.searchPodcasts(query, 0);
List<PodcastSearchResult> results = new ArrayList<>();
for (GpodnetPodcast podcast : gpodnetPodcasts) {

View File

@ -15,7 +15,7 @@ import de.danoeh.antennapod.R;
import de.danoeh.antennapod.activity.MainActivity;
import de.danoeh.antennapod.activity.OnlineFeedViewActivity;
import de.danoeh.antennapod.adapter.gpodnet.PodcastListAdapter;
import de.danoeh.antennapod.core.preferences.GpodnetPreferences;
import de.danoeh.antennapod.core.sync.SynchronizationCredentials;
import de.danoeh.antennapod.core.service.download.AntennapodHttpClient;
import de.danoeh.antennapod.net.sync.gpoddernet.GpodnetService;
import de.danoeh.antennapod.net.sync.gpoddernet.GpodnetServiceException;
@ -76,8 +76,8 @@ public abstract class PodcastListFragment extends Fragment {
disposable = Observable.fromCallable(
() -> {
GpodnetService service = new GpodnetService(AntennapodHttpClient.getHttpClient(),
GpodnetPreferences.getHosturl(), GpodnetPreferences.getDeviceID(),
GpodnetPreferences.getUsername(), GpodnetPreferences.getPassword());
SynchronizationCredentials.getHosturl(), SynchronizationCredentials.getDeviceID(),
SynchronizationCredentials.getUsername(), SynchronizationCredentials.getPassword());
return loadPodcastData(service);
})
.subscribeOn(Schedulers.io())

View File

@ -8,7 +8,7 @@ import androidx.annotation.NonNull;
import androidx.fragment.app.ListFragment;
import de.danoeh.antennapod.activity.MainActivity;
import de.danoeh.antennapod.adapter.gpodnet.TagListAdapter;
import de.danoeh.antennapod.core.preferences.GpodnetPreferences;
import de.danoeh.antennapod.core.sync.SynchronizationCredentials;
import de.danoeh.antennapod.core.service.download.AntennapodHttpClient;
import de.danoeh.antennapod.net.sync.gpoddernet.GpodnetService;
import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetTag;
@ -51,8 +51,8 @@ public class TagListFragment extends ListFragment {
disposable = Observable.fromCallable(
() -> {
GpodnetService service = new GpodnetService(AntennapodHttpClient.getHttpClient(),
GpodnetPreferences.getHosturl(), GpodnetPreferences.getDeviceID(),
GpodnetPreferences.getUsername(), GpodnetPreferences.getPassword());
SynchronizationCredentials.getHosturl(), SynchronizationCredentials.getDeviceID(),
SynchronizationCredentials.getUsername(), SynchronizationCredentials.getPassword());
return service.getTopTags(COUNT);
})
.subscribeOn(Schedulers.io())

View File

@ -1,128 +0,0 @@
package de.danoeh.antennapod.fragment.preferences;
import android.app.Activity;
import android.os.Bundle;
import androidx.core.text.HtmlCompat;
import androidx.preference.PreferenceFragmentCompat;
import android.text.Spanned;
import android.text.format.DateUtils;
import com.google.android.material.snackbar.Snackbar;
import de.danoeh.antennapod.R;
import de.danoeh.antennapod.activity.PreferenceActivity;
import de.danoeh.antennapod.core.event.SyncServiceEvent;
import de.danoeh.antennapod.core.preferences.GpodnetPreferences;
import de.danoeh.antennapod.core.sync.SyncService;
import de.danoeh.antennapod.dialog.AuthenticationDialog;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
public class GpodderPreferencesFragment extends PreferenceFragmentCompat {
private static final String PREF_GPODNET_LOGIN = "pref_gpodnet_authenticate";
private static final String PREF_GPODNET_SETLOGIN_INFORMATION = "pref_gpodnet_setlogin_information";
private static final String PREF_GPODNET_SYNC = "pref_gpodnet_sync";
private static final String PREF_GPODNET_FORCE_FULL_SYNC = "pref_gpodnet_force_full_sync";
private static final String PREF_GPODNET_LOGOUT = "pref_gpodnet_logout";
@Override
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
addPreferencesFromResource(R.xml.preferences_gpodder);
setupGpodderScreen();
}
@Override
public void onStart() {
super.onStart();
((PreferenceActivity) getActivity()).getSupportActionBar().setTitle(R.string.gpodnet_main_label);
updateGpodnetPreferenceScreen();
EventBus.getDefault().register(this);
}
@Override
public void onStop() {
super.onStop();
EventBus.getDefault().unregister(this);
((PreferenceActivity) getActivity()).getSupportActionBar().setSubtitle("");
}
@Subscribe(threadMode = ThreadMode.MAIN, sticky = true)
public void syncStatusChanged(SyncServiceEvent event) {
updateGpodnetPreferenceScreen();
if (!GpodnetPreferences.loggedIn()) {
return;
}
if (event.getMessageResId() == R.string.sync_status_error
|| event.getMessageResId() == R.string.sync_status_success) {
updateLastGpodnetSyncReport(SyncService.isLastSyncSuccessful(getContext()),
SyncService.getLastSyncAttempt(getContext()));
} else {
((PreferenceActivity) getActivity()).getSupportActionBar().setSubtitle(event.getMessageResId());
}
}
private void setupGpodderScreen() {
final Activity activity = getActivity();
findPreference(PREF_GPODNET_LOGIN).setOnPreferenceClickListener(preference -> {
new GpodderAuthenticationFragment().show(getChildFragmentManager(), GpodderAuthenticationFragment.TAG);
return true;
});
findPreference(PREF_GPODNET_SETLOGIN_INFORMATION)
.setOnPreferenceClickListener(preference -> {
AuthenticationDialog dialog = new AuthenticationDialog(activity,
R.string.pref_gpodnet_setlogin_information_title, false, GpodnetPreferences.getUsername(),
null) {
@Override
protected void onConfirmed(String username, String password) {
GpodnetPreferences.setPassword(password);
}
};
dialog.show();
return true;
});
findPreference(PREF_GPODNET_SYNC).setOnPreferenceClickListener(preference -> {
SyncService.syncImmediately(getActivity().getApplicationContext());
return true;
});
findPreference(PREF_GPODNET_FORCE_FULL_SYNC).setOnPreferenceClickListener(preference -> {
SyncService.fullSync(getContext());
return true;
});
findPreference(PREF_GPODNET_LOGOUT).setOnPreferenceClickListener(preference -> {
GpodnetPreferences.logout();
Snackbar.make(getView(), R.string.pref_gpodnet_logout_toast, Snackbar.LENGTH_LONG).show();
updateGpodnetPreferenceScreen();
return true;
});
}
private void updateGpodnetPreferenceScreen() {
final boolean loggedIn = GpodnetPreferences.loggedIn();
findPreference(PREF_GPODNET_LOGIN).setEnabled(!loggedIn);
findPreference(PREF_GPODNET_SETLOGIN_INFORMATION).setEnabled(loggedIn);
findPreference(PREF_GPODNET_SYNC).setEnabled(loggedIn);
findPreference(PREF_GPODNET_FORCE_FULL_SYNC).setEnabled(loggedIn);
findPreference(PREF_GPODNET_LOGOUT).setEnabled(loggedIn);
if (loggedIn) {
String format = getActivity().getString(R.string.pref_gpodnet_login_status);
String summary = String.format(format, GpodnetPreferences.getUsername(),
GpodnetPreferences.getDeviceID());
Spanned formattedSummary = HtmlCompat.fromHtml(summary, HtmlCompat.FROM_HTML_MODE_LEGACY);
findPreference(PREF_GPODNET_LOGOUT).setSummary(formattedSummary);
updateLastGpodnetSyncReport(SyncService.isLastSyncSuccessful(getContext()),
SyncService.getLastSyncAttempt(getContext()));
} else {
findPreference(PREF_GPODNET_LOGOUT).setSummary(null);
}
}
private void updateLastGpodnetSyncReport(boolean successful, long lastTime) {
String status = String.format("%1$s (%2$s)", getString(successful
? R.string.gpodnetsync_pref_report_successful : R.string.gpodnetsync_pref_report_failed),
DateUtils.getRelativeDateTimeString(getContext(),
lastTime, DateUtils.MINUTE_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, DateUtils.FORMAT_SHOW_TIME));
((PreferenceActivity) getActivity()).getSupportActionBar().setSubtitle(status);
}
}

View File

@ -17,12 +17,11 @@ import de.danoeh.antennapod.core.util.IntentUtils;
import de.danoeh.antennapod.fragment.preferences.about.AboutFragment;
public class MainPreferencesFragment extends PreferenceFragmentCompat {
private static final String TAG = "MainPreferencesFragment";
private static final String PREF_SCREEN_USER_INTERFACE = "prefScreenInterface";
private static final String PREF_SCREEN_PLAYBACK = "prefScreenPlayback";
private static final String PREF_SCREEN_NETWORK = "prefScreenNetwork";
private static final String PREF_SCREEN_GPODDER = "prefScreenGpodder";
private static final String PREF_SCREEN_SYNCHRONIZATION = "prefScreenSynchronization";
private static final String PREF_SCREEN_STORAGE = "prefScreenStorage";
private static final String PREF_DOCUMENTATION = "prefDocumentation";
private static final String PREF_VIEW_FORUM = "prefViewForum";
@ -74,8 +73,8 @@ public class MainPreferencesFragment extends PreferenceFragmentCompat {
((PreferenceActivity) getActivity()).openScreen(R.xml.preferences_network);
return true;
});
findPreference(PREF_SCREEN_GPODDER).setOnPreferenceClickListener(preference -> {
((PreferenceActivity) getActivity()).openScreen(R.xml.preferences_gpodder);
findPreference(PREF_SCREEN_SYNCHRONIZATION).setOnPreferenceClickListener(preference -> {
((PreferenceActivity) getActivity()).openScreen(R.xml.preferences_synchronization);
return true;
});
findPreference(PREF_SCREEN_STORAGE).setOnPreferenceClickListener(preference -> {
@ -142,8 +141,8 @@ public class MainPreferencesFragment extends PreferenceFragmentCompat {
.addBreadcrumb(PreferenceActivity.getTitleOfPage(R.xml.preferences_network))
.addBreadcrumb(R.string.automation)
.addBreadcrumb(PreferenceActivity.getTitleOfPage(R.xml.preferences_autodownload));
config.index(R.xml.preferences_gpodder)
.addBreadcrumb(PreferenceActivity.getTitleOfPage(R.xml.preferences_gpodder));
config.index(R.xml.preferences_synchronization)
.addBreadcrumb(PreferenceActivity.getTitleOfPage(R.xml.preferences_synchronization));
config.index(R.xml.preferences_notifications)
.addBreadcrumb(PreferenceActivity.getTitleOfPage(R.xml.preferences_notifications));
config.index(R.xml.feed_settings)

View File

@ -4,11 +4,10 @@ import android.os.Bundle;
import androidx.preference.PreferenceFragmentCompat;
import de.danoeh.antennapod.R;
import de.danoeh.antennapod.activity.PreferenceActivity;
import de.danoeh.antennapod.core.preferences.GpodnetPreferences;
import de.danoeh.antennapod.core.sync.SynchronizationSettings;
public class NotificationPreferencesFragment extends PreferenceFragmentCompat {
private static final String TAG = "NotificationPrefFragment";
private static final String PREF_GPODNET_NOTIFICATIONS = "pref_gpodnet_notifications";
@Override
@ -24,7 +23,6 @@ public class NotificationPreferencesFragment extends PreferenceFragmentCompat {
}
private void setUpScreen() {
final boolean loggedIn = GpodnetPreferences.loggedIn();
findPreference(PREF_GPODNET_NOTIFICATIONS).setEnabled(loggedIn);
findPreference(PREF_GPODNET_NOTIFICATIONS).setEnabled(SynchronizationSettings.isProviderConnected());
}
}

View File

@ -1,4 +1,4 @@
package de.danoeh.antennapod.fragment.preferences;
package de.danoeh.antennapod.fragment.preferences.synchronization;
import android.app.Dialog;
import android.content.Context;
@ -15,30 +15,35 @@ import android.widget.ProgressBar;
import android.widget.RadioGroup;
import android.widget.TextView;
import android.widget.ViewFlipper;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.DialogFragment;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.textfield.TextInputLayout;
import de.danoeh.antennapod.R;
import de.danoeh.antennapod.core.preferences.GpodnetPreferences;
import de.danoeh.antennapod.core.service.download.AntennapodHttpClient;
import de.danoeh.antennapod.core.sync.SyncService;
import de.danoeh.antennapod.net.sync.gpoddernet.GpodnetService;
import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetDevice;
import de.danoeh.antennapod.core.util.FileNameGenerator;
import de.danoeh.antennapod.core.util.IntentUtils;
import io.reactivex.Completable;
import io.reactivex.Observable;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.schedulers.Schedulers;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import de.danoeh.antennapod.R;
import de.danoeh.antennapod.core.sync.SynchronizationCredentials;
import de.danoeh.antennapod.core.service.download.AntennapodHttpClient;
import de.danoeh.antennapod.core.sync.SyncService;
import de.danoeh.antennapod.core.sync.SynchronizationProviderViewData;
import de.danoeh.antennapod.core.sync.SynchronizationSettings;
import de.danoeh.antennapod.core.util.FileNameGenerator;
import de.danoeh.antennapod.core.util.IntentUtils;
import de.danoeh.antennapod.net.sync.gpoddernet.GpodnetService;
import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetDevice;
import io.reactivex.Completable;
import io.reactivex.Observable;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.schedulers.Schedulers;
/**
* Guides the user through the authentication process.
*/
@ -83,23 +88,24 @@ public class GpodderAuthenticationFragment extends DialogFragment {
final RadioGroup serverRadioGroup = view.findViewById(R.id.serverRadioGroup);
final EditText serverUrlText = view.findViewById(R.id.serverUrlText);
if (!GpodnetService.DEFAULT_BASE_HOST.equals(GpodnetPreferences.getHosturl())) {
serverUrlText.setText(GpodnetPreferences.getHosturl());
if (!GpodnetService.DEFAULT_BASE_HOST.equals(SynchronizationCredentials.getHosturl())) {
serverUrlText.setText(SynchronizationCredentials.getHosturl());
}
final TextInputLayout serverUrlTextInput = view.findViewById(R.id.serverUrlTextInput);
serverRadioGroup.setOnCheckedChangeListener((group, checkedId) -> {
serverUrlTextInput.setVisibility(checkedId == R.id.customServerRadio ? View.VISIBLE : View.GONE);
});
selectHost.setOnClickListener(v -> {
SynchronizationCredentials.clear(getContext());
if (serverRadioGroup.getCheckedRadioButtonId() == R.id.customServerRadio) {
GpodnetPreferences.setHosturl(serverUrlText.getText().toString());
SynchronizationCredentials.setHosturl(serverUrlText.getText().toString());
} else {
GpodnetPreferences.setHosturl(GpodnetService.DEFAULT_BASE_HOST);
SynchronizationCredentials.setHosturl(GpodnetService.DEFAULT_BASE_HOST);
}
service = new GpodnetService(AntennapodHttpClient.getHttpClient(),
GpodnetPreferences.getHosturl(), GpodnetPreferences.getDeviceID(),
GpodnetPreferences.getUsername(), GpodnetPreferences.getPassword());
getDialog().setTitle(GpodnetPreferences.getHosturl());
SynchronizationCredentials.getHosturl(), SynchronizationCredentials.getDeviceID(),
SynchronizationCredentials.getUsername(), SynchronizationCredentials.getPassword());
getDialog().setTitle(SynchronizationCredentials.getHosturl());
advance();
});
}
@ -116,7 +122,7 @@ public class GpodderAuthenticationFragment extends DialogFragment {
createAccount.setPaintFlags(createAccount.getPaintFlags() | Paint.UNDERLINE_TEXT_FLAG);
createAccount.setOnClickListener(v -> IntentUtils.openInBrowser(getContext(), "https://gpodder.net/register/"));
if (GpodnetPreferences.getHosturl().startsWith("http://")) {
if (SynchronizationCredentials.getHosturl().startsWith("http://")) {
createAccountWarning.setVisibility(View.VISIBLE);
}
password.setOnEditorActionListener((v, actionID, event) ->
@ -265,15 +271,8 @@ public class GpodderAuthenticationFragment extends DialogFragment {
});
}
private void writeLoginCredentials() {
GpodnetPreferences.setUsername(username);
GpodnetPreferences.setPassword(password);
GpodnetPreferences.setDeviceID(selectedDevice.getId());
}
private void advance() {
if (currentStep < STEP_FINISH) {
View view = viewFlipper.getChildAt(currentStep + 1);
if (currentStep == STEP_DEFAULT) {
setupHostView(view);
@ -289,7 +288,10 @@ public class GpodderAuthenticationFragment extends DialogFragment {
if (selectedDevice == null) {
throw new IllegalStateException("Device must not be null here");
} else {
writeLoginCredentials();
SynchronizationSettings.setSelectedSyncProvider(SynchronizationProviderViewData.GPODDER_NET);
SynchronizationCredentials.setUsername(username);
SynchronizationCredentials.setPassword(password);
SynchronizationCredentials.setDeviceID(selectedDevice.getId());
setupFinishView(view);
}
}

View File

@ -0,0 +1,92 @@
package de.danoeh.antennapod.fragment.preferences.synchronization;
import android.app.Dialog;
import android.content.DialogInterface;
import android.os.Bundle;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.DialogFragment;
import de.danoeh.antennapod.R;
import de.danoeh.antennapod.core.service.download.AntennapodHttpClient;
import de.danoeh.antennapod.core.sync.SyncService;
import de.danoeh.antennapod.core.sync.SynchronizationCredentials;
import de.danoeh.antennapod.core.sync.SynchronizationProviderViewData;
import de.danoeh.antennapod.core.sync.SynchronizationSettings;
import de.danoeh.antennapod.databinding.NextcloudAuthDialogBinding;
import de.danoeh.antennapod.net.sync.nextcloud.NextcloudLoginFlow;
/**
* Guides the user through the authentication process.
*/
public class NextcloudAuthenticationFragment extends DialogFragment
implements NextcloudLoginFlow.AuthenticationCallback {
public static final String TAG = "NextcloudAuthenticationFragment";
private NextcloudAuthDialogBinding viewBinding;
private NextcloudLoginFlow nextcloudLoginFlow;
private boolean shouldDismiss = false;
@NonNull
@Override
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
AlertDialog.Builder dialog = new AlertDialog.Builder(getContext());
dialog.setTitle(R.string.gpodnetauth_login_butLabel);
dialog.setNegativeButton(R.string.cancel_label, null);
dialog.setCancelable(false);
this.setCancelable(false);
viewBinding = NextcloudAuthDialogBinding.inflate(getLayoutInflater());
dialog.setView(viewBinding.getRoot());
viewBinding.loginButton.setOnClickListener(v -> {
viewBinding.errorText.setVisibility(View.GONE);
viewBinding.loginButton.setVisibility(View.GONE);
viewBinding.loginProgressContainer.setVisibility(View.VISIBLE);
nextcloudLoginFlow = new NextcloudLoginFlow(AntennapodHttpClient.getHttpClient(),
viewBinding.serverUrlText.getText().toString(), getContext(), this);
nextcloudLoginFlow.start();
});
return dialog.create();
}
@Override
public void onDismiss(@NonNull DialogInterface dialog) {
super.onDismiss(dialog);
if (nextcloudLoginFlow != null) {
nextcloudLoginFlow.cancel();
}
}
@Override
public void onResume() {
super.onResume();
if (shouldDismiss) {
dismiss();
}
}
@Override
public void onNextcloudAuthenticated(String server, String username, String password) {
SynchronizationSettings.setSelectedSyncProvider(SynchronizationProviderViewData.NEXTCLOUD_GPODDER);
SynchronizationCredentials.clear(getContext());
SynchronizationCredentials.setPassword(password);
SynchronizationCredentials.setHosturl(server);
SynchronizationCredentials.setUsername(username);
SyncService.fullSync(getContext());
if (isVisible()) {
dismiss();
} else {
shouldDismiss = true;
}
}
@Override
public void onNextcloudAuthError(String errorMessage) {
viewBinding.loginProgressContainer.setVisibility(View.GONE);
viewBinding.errorText.setVisibility(View.VISIBLE);
viewBinding.errorText.setText(errorMessage);
viewBinding.loginButton.setVisibility(View.VISIBLE);
}
}

View File

@ -0,0 +1,222 @@
package de.danoeh.antennapod.fragment.preferences.synchronization;
import android.app.Activity;
import android.os.Bundle;
import android.text.Spanned;
import android.text.format.DateUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.ImageView;
import android.widget.ListAdapter;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.core.text.HtmlCompat;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
import com.google.android.material.snackbar.Snackbar;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import de.danoeh.antennapod.R;
import de.danoeh.antennapod.activity.PreferenceActivity;
import de.danoeh.antennapod.core.event.SyncServiceEvent;
import de.danoeh.antennapod.core.sync.SynchronizationCredentials;
import de.danoeh.antennapod.core.sync.SyncService;
import de.danoeh.antennapod.core.sync.SynchronizationProviderViewData;
import de.danoeh.antennapod.core.sync.SynchronizationSettings;
import de.danoeh.antennapod.dialog.AuthenticationDialog;
public class SynchronizationPreferencesFragment extends PreferenceFragmentCompat {
private static final String PREFERENCE_SYNCHRONIZATION_DESCRIPTION = "preference_synchronization_description";
private static final String PREFERENCE_GPODNET_SETLOGIN_INFORMATION = "pref_gpodnet_setlogin_information";
private static final String PREFERENCE_SYNC = "pref_synchronization_sync";
private static final String PREFERENCE_FORCE_FULL_SYNC = "pref_synchronization_force_full_sync";
private static final String PREFERENCE_LOGOUT = "pref_synchronization_logout";
@Override
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
addPreferencesFromResource(R.xml.preferences_synchronization);
setupScreen();
updateScreen();
}
@Override
public void onStart() {
super.onStart();
((PreferenceActivity) getActivity()).getSupportActionBar().setTitle(R.string.synchronization_pref);
updateScreen();
EventBus.getDefault().register(this);
}
@Override
public void onStop() {
super.onStop();
EventBus.getDefault().unregister(this);
((PreferenceActivity) getActivity()).getSupportActionBar().setSubtitle("");
}
@Subscribe(threadMode = ThreadMode.MAIN, sticky = true)
public void syncStatusChanged(SyncServiceEvent event) {
if (!SynchronizationSettings.isProviderConnected()) {
return;
}
updateScreen();
if (event.getMessageResId() == R.string.sync_status_error
|| event.getMessageResId() == R.string.sync_status_success) {
updateLastSyncReport(SynchronizationSettings.isLastSyncSuccessful(),
SynchronizationSettings.getLastSyncAttempt());
} else {
((PreferenceActivity) getActivity()).getSupportActionBar().setSubtitle(event.getMessageResId());
}
}
private void setupScreen() {
final Activity activity = getActivity();
findPreference(PREFERENCE_GPODNET_SETLOGIN_INFORMATION)
.setOnPreferenceClickListener(preference -> {
AuthenticationDialog dialog = new AuthenticationDialog(activity,
R.string.pref_gpodnet_setlogin_information_title,
false, SynchronizationCredentials.getUsername(), null) {
@Override
protected void onConfirmed(String username, String password) {
SynchronizationCredentials.setPassword(password);
}
};
dialog.show();
return true;
});
findPreference(PREFERENCE_SYNC).setOnPreferenceClickListener(preference -> {
SyncService.syncImmediately(getActivity().getApplicationContext());
return true;
});
findPreference(PREFERENCE_FORCE_FULL_SYNC).setOnPreferenceClickListener(preference -> {
SyncService.fullSync(getContext());
return true;
});
findPreference(PREFERENCE_LOGOUT).setOnPreferenceClickListener(preference -> {
SynchronizationCredentials.clear(getContext());
Snackbar.make(getView(), R.string.pref_synchronization_logout_toast, Snackbar.LENGTH_LONG).show();
SynchronizationSettings.setSelectedSyncProvider(null);
updateScreen();
return true;
});
}
private void updateScreen() {
final boolean loggedIn = SynchronizationSettings.isProviderConnected();
Preference preferenceHeader = findPreference(PREFERENCE_SYNCHRONIZATION_DESCRIPTION);
if (loggedIn) {
SynchronizationProviderViewData selectedProvider =
SynchronizationProviderViewData.fromIdentifier(getSelectedSyncProviderKey());
preferenceHeader.setTitle("");
preferenceHeader.setSummary(selectedProvider.getSummaryResource());
preferenceHeader.setIcon(selectedProvider.getIconResource());
preferenceHeader.setOnPreferenceClickListener(null);
} else {
preferenceHeader.setTitle(R.string.synchronization_choose_title);
preferenceHeader.setSummary(R.string.synchronization_summary_unchoosen);
preferenceHeader.setIcon(R.drawable.ic_cloud);
preferenceHeader.setOnPreferenceClickListener((preference) -> {
chooseProviderAndLogin();
return true;
});
}
Preference gpodnetSetLoginPreference = findPreference(PREFERENCE_GPODNET_SETLOGIN_INFORMATION);
gpodnetSetLoginPreference.setVisible(isProviderSelected(SynchronizationProviderViewData.GPODDER_NET));
gpodnetSetLoginPreference.setEnabled(loggedIn);
findPreference(PREFERENCE_SYNC).setEnabled(loggedIn);
findPreference(PREFERENCE_FORCE_FULL_SYNC).setEnabled(loggedIn);
findPreference(PREFERENCE_LOGOUT).setEnabled(loggedIn);
if (loggedIn) {
String summary = getString(R.string.synchronization_login_status,
SynchronizationCredentials.getUsername(), SynchronizationCredentials.getHosturl());
Spanned formattedSummary = HtmlCompat.fromHtml(summary, HtmlCompat.FROM_HTML_MODE_LEGACY);
findPreference(PREFERENCE_LOGOUT).setSummary(formattedSummary);
updateLastSyncReport(SynchronizationSettings.isLastSyncSuccessful(),
SynchronizationSettings.getLastSyncAttempt());
} else {
findPreference(PREFERENCE_LOGOUT).setSummary(null);
((PreferenceActivity) getActivity()).getSupportActionBar().setSubtitle(null);
}
}
private void chooseProviderAndLogin() {
AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
builder.setTitle(R.string.dialog_choose_sync_service_title);
SynchronizationProviderViewData[] providers = SynchronizationProviderViewData.values();
ListAdapter adapter = new ArrayAdapter<SynchronizationProviderViewData>(
getContext(), R.layout.alertdialog_sync_provider_chooser, providers) {
ViewHolder holder;
class ViewHolder {
ImageView icon;
TextView title;
}
public View getView(int position, View convertView, ViewGroup parent) {
final LayoutInflater inflater = LayoutInflater.from(getContext());
if (convertView == null) {
convertView = inflater.inflate(
R.layout.alertdialog_sync_provider_chooser, null);
holder = new ViewHolder();
holder.icon = (ImageView) convertView.findViewById(R.id.icon);
holder.title = (TextView) convertView.findViewById(R.id.title);
convertView.setTag(holder);
} else {
holder = (ViewHolder) convertView.getTag();
}
SynchronizationProviderViewData synchronizationProviderViewData = getItem(position);
holder.title.setText(synchronizationProviderViewData.getSummaryResource());
holder.icon.setImageResource(synchronizationProviderViewData.getIconResource());
return convertView;
}
};
builder.setAdapter(adapter, (dialog, which) -> {
switch (providers[which]) {
case GPODDER_NET:
new GpodderAuthenticationFragment()
.show(getChildFragmentManager(), GpodderAuthenticationFragment.TAG);
break;
case NEXTCLOUD_GPODDER:
new NextcloudAuthenticationFragment()
.show(getChildFragmentManager(), NextcloudAuthenticationFragment.TAG);
break;
default:
break;
}
updateScreen();
});
AlertDialog dialog = builder.create();
dialog.show();
}
private boolean isProviderSelected(@NonNull SynchronizationProviderViewData provider) {
String selectedSyncProviderKey = getSelectedSyncProviderKey();
return provider.getIdentifier().equals(selectedSyncProviderKey);
}
private String getSelectedSyncProviderKey() {
return SynchronizationSettings.getSelectedSyncProviderKey();
}
private void updateLastSyncReport(boolean successful, long lastTime) {
String status = String.format("%1$s (%2$s)", getString(successful
? R.string.gpodnetsync_pref_report_successful : R.string.gpodnetsync_pref_report_failed),
DateUtils.getRelativeDateTimeString(getContext(),
lastTime, DateUtils.MINUTE_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, DateUtils.FORMAT_SHOW_TIME));
((PreferenceActivity) getActivity()).getSupportActionBar().setSubtitle(status);
}
}

View File

@ -13,12 +13,12 @@ import com.google.android.material.snackbar.Snackbar;
import de.danoeh.antennapod.R;
import de.danoeh.antennapod.activity.MainActivity;
import de.danoeh.antennapod.core.preferences.GpodnetPreferences;
import de.danoeh.antennapod.core.preferences.PlaybackPreferences;
import de.danoeh.antennapod.core.preferences.UserPreferences;
import de.danoeh.antennapod.core.service.playback.PlaybackService;
import de.danoeh.antennapod.core.storage.DBWriter;
import de.danoeh.antennapod.core.sync.SyncService;
import de.danoeh.antennapod.core.sync.SynchronizationSettings;
import de.danoeh.antennapod.core.sync.queue.SynchronizationQueueSink;
import de.danoeh.antennapod.core.util.FeedItemUtil;
import de.danoeh.antennapod.core.util.IntentUtils;
import de.danoeh.antennapod.core.util.ShareUtils;
@ -151,7 +151,7 @@ public class FeedItemMenuHandler {
} else if (menuItemId == R.id.mark_read_item) {
selectedItem.setPlayed(true);
DBWriter.markItemPlayed(selectedItem, FeedItem.PLAYED, true);
if (GpodnetPreferences.loggedIn()) {
if (SynchronizationSettings.isProviderConnected()) {
FeedMedia media = selectedItem.getMedia();
// not all items have media, Gpodder only cares about those that do
if (media != null) {
@ -161,17 +161,17 @@ public class FeedItemMenuHandler {
.position(media.getDuration() / 1000)
.total(media.getDuration() / 1000)
.build();
SyncService.enqueueEpisodeAction(context, actionPlay);
SynchronizationQueueSink.enqueueEpisodeActionIfSynchronizationIsActive(context, actionPlay);
}
}
} else if (menuItemId == R.id.mark_unread_item) {
selectedItem.setPlayed(false);
DBWriter.markItemPlayed(selectedItem, FeedItem.UNPLAYED, false);
if (GpodnetPreferences.loggedIn() && selectedItem.getMedia() != null) {
if (selectedItem.getMedia() != null) {
EpisodeAction actionNew = new EpisodeAction.Builder(selectedItem, EpisodeAction.NEW)
.currentTimestamp()
.build();
SyncService.enqueueEpisodeAction(context, actionNew);
SynchronizationQueueSink.enqueueEpisodeActionIfSynchronizationIsActive(context, actionNew);
}
} else if (menuItemId == R.id.add_to_queue_item) {
DBWriter.addQueueItem(context, selectedItem);

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp">
<ImageView
android:id="@+id/icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginRight="16dip"
android:layout_marginEnd="16dip"
android:layout_gravity="center_vertical" />
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=""
android:layout_gravity="center" />
</LinearLayout>

View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:orientation="vertical"
android:clipToPadding="false">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/serverUrlTextInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/serverUrlText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/gpodnetauth_host"
android:inputType="textNoSuggestions"
android:lines="1"
android:imeOptions="actionNext|flagNoFullscreen" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:id="@+id/loginProgressContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:orientation="horizontal"
android:layout_gravity="center_vertical">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/synchronization_nextcloud_authenticate_browser" />
</LinearLayout>
<TextView
android:id="@+id/errorText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
android:textColor="@color/download_failed_red"
android:layout_marginBottom="16dp" />
<Button
android:id="@+id/loginButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/gpodnetauth_login_butLabel" />
</LinearLayout>

View File

@ -28,7 +28,7 @@
android:icon="@drawable/ic_network" />
<Preference
android:key="prefScreenGpodder"
android:key="prefScreenSynchronization"
android:title="@string/synchronization_pref"
android:summary="@string/synchronization_sum"
android:icon="@drawable/ic_cloud" />

View File

@ -1,28 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android">
<Preference
android:key="pref_gpodnet_description"
android:icon="@drawable/gpodder_icon"
android:summary="@string/gpodnet_description"/>
<Preference
android:key="pref_gpodnet_authenticate"
android:title="@string/pref_gpodnet_authenticate_title"
android:summary="@string/pref_gpodnet_authenticate_sum"/>
<Preference
android:key="pref_gpodnet_setlogin_information"
android:title="@string/pref_gpodnet_setlogin_information_title"
android:summary="@string/pref_gpodnet_setlogin_information_sum"/>
<Preference
android:key="pref_gpodnet_sync"
android:title="@string/pref_gpodnet_sync_changes_title"
android:summary="@string/pref_gpodnet_sync_changes_sum"/>
<Preference
android:key="pref_gpodnet_force_full_sync"
android:title="@string/pref_gpodnet_full_sync_title"
android:summary="@string/pref_gpodnet_full_sync_sum"/>
<Preference
android:key="pref_gpodnet_logout"
android:title="@string/pref_gpodnet_logout_title"/>
</PreferenceScreen>

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<Preference
android:key="preference_synchronization_description"
android:icon="@drawable/ic_notification_sync"
android:summary="@string/synchronization_summary_unchoosen"/>
<Preference
android:key="pref_gpodnet_setlogin_information"
android:title="@string/pref_gpodnet_setlogin_information_title"
android:summary="@string/pref_gpodnet_setlogin_information_sum"
app:isPreferenceVisible="false"/>
<Preference
android:key="pref_synchronization_sync"
android:title="@string/synchronization_sync_changes_title"
android:summary="@string/synchronization_sync_summary"/>
<Preference
android:key="pref_synchronization_force_full_sync"
android:title="@string/synchronization_full_sync_title"
android:summary="@string/synchronization_force_sync_summary"/>
<Preference
android:key="pref_synchronization_logout"
android:title="@string/synchronization_logout"/>
</PreferenceScreen>

View File

@ -1,115 +0,0 @@
package de.danoeh.antennapod.core.preferences;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import de.danoeh.antennapod.core.BuildConfig;
import de.danoeh.antennapod.core.ClientConfig;
import de.danoeh.antennapod.core.sync.SyncService;
import de.danoeh.antennapod.net.sync.gpoddernet.GpodnetService;
/**
* Manages preferences for accessing gpodder.net service
*/
public class GpodnetPreferences {
private GpodnetPreferences(){}
private static final String TAG = "GpodnetPreferences";
private static final String PREF_NAME = "gpodder.net";
private static final String PREF_GPODNET_USERNAME = "de.danoeh.antennapod.preferences.gpoddernet.username";
private static final String PREF_GPODNET_PASSWORD = "de.danoeh.antennapod.preferences.gpoddernet.password";
private static final String PREF_GPODNET_DEVICEID = "de.danoeh.antennapod.preferences.gpoddernet.deviceID";
private static final String PREF_GPODNET_HOSTNAME = "prefGpodnetHostname";
private static String username;
private static String password;
private static String deviceID;
private static String hosturl;
private static boolean preferencesLoaded = false;
private static SharedPreferences getPreferences() {
return ClientConfig.applicationCallbacks.getApplicationInstance().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
}
private static synchronized void ensurePreferencesLoaded() {
if (!preferencesLoaded) {
SharedPreferences prefs = getPreferences();
username = prefs.getString(PREF_GPODNET_USERNAME, null);
password = prefs.getString(PREF_GPODNET_PASSWORD, null);
deviceID = prefs.getString(PREF_GPODNET_DEVICEID, null);
hosturl = prefs.getString(PREF_GPODNET_HOSTNAME, GpodnetService.DEFAULT_BASE_HOST);
preferencesLoaded = true;
}
}
private static void writePreference(String key, String value) {
SharedPreferences.Editor editor = getPreferences().edit();
editor.putString(key, value);
editor.apply();
}
public static String getUsername() {
ensurePreferencesLoaded();
return username;
}
public static void setUsername(String username) {
GpodnetPreferences.username = username;
writePreference(PREF_GPODNET_USERNAME, username);
}
public static String getPassword() {
ensurePreferencesLoaded();
return password;
}
public static void setPassword(String password) {
GpodnetPreferences.password = password;
writePreference(PREF_GPODNET_PASSWORD, password);
}
public static String getDeviceID() {
ensurePreferencesLoaded();
return deviceID;
}
public static void setDeviceID(String deviceID) {
GpodnetPreferences.deviceID = deviceID;
writePreference(PREF_GPODNET_DEVICEID, deviceID);
}
public static String getHosturl() {
ensurePreferencesLoaded();
return hosturl;
}
public static void setHosturl(String value) {
if (!value.equals(hosturl)) {
logout();
writePreference(PREF_GPODNET_HOSTNAME, value);
hosturl = value;
}
}
/**
* Returns true if device ID, username and password have a non-null value
*/
public static boolean loggedIn() {
ensurePreferencesLoaded();
return deviceID != null && username != null && password != null;
}
public static synchronized void logout() {
if (BuildConfig.DEBUG) Log.d(TAG, "Logout: Clearing preferences");
setUsername(null);
setPassword(null);
setDeviceID(null);
SyncService.clearQueue(ClientConfig.applicationCallbacks.getApplicationInstance());
UserPreferences.setGpodnetNotificationsEnabled();
}
}

View File

@ -6,21 +6,22 @@ import android.util.Log;
import androidx.annotation.NonNull;
import org.greenrobot.eventbus.EventBus;
import java.io.File;
import java.util.concurrent.ExecutionException;
import de.danoeh.antennapod.core.event.UnreadItemsUpdateEvent;
import de.danoeh.antennapod.model.feed.FeedItem;
import de.danoeh.antennapod.model.feed.FeedMedia;
import de.danoeh.antennapod.core.service.download.DownloadRequest;
import de.danoeh.antennapod.core.service.download.DownloadStatus;
import de.danoeh.antennapod.core.storage.DBReader;
import de.danoeh.antennapod.core.storage.DBWriter;
import de.danoeh.antennapod.core.sync.queue.SynchronizationQueueSink;
import de.danoeh.antennapod.core.util.ChapterUtils;
import de.danoeh.antennapod.core.util.DownloadError;
import de.danoeh.antennapod.core.sync.SyncService;
import de.danoeh.antennapod.model.feed.FeedItem;
import de.danoeh.antennapod.model.feed.FeedMedia;
import de.danoeh.antennapod.net.sync.model.EpisodeAction;
import org.greenrobot.eventbus.EventBus;
/**
* Handles a completed media download.
@ -103,7 +104,7 @@ public class MediaDownloadedHandler implements Runnable {
EpisodeAction action = new EpisodeAction.Builder(item, EpisodeAction.DOWNLOAD)
.currentTimestamp()
.build();
SyncService.enqueueEpisodeAction(context, action);
SynchronizationQueueSink.enqueueEpisodeActionIfSynchronizationIsActive(context, action);
}
}

View File

@ -1,5 +1,7 @@
package de.danoeh.antennapod.core.service.playback;
import static de.danoeh.antennapod.model.feed.FeedPreferences.SPEED_USE_GLOBAL;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
@ -21,13 +23,7 @@ import android.os.Build;
import android.os.Bundle;
import android.os.IBinder;
import android.os.Vibrator;
import androidx.preference.PreferenceManager;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import android.support.v4.media.MediaBrowserCompat;
import androidx.media.MediaBrowserServiceCompat;
import android.support.v4.media.MediaDescriptionCompat;
import android.support.v4.media.MediaMetadataCompat;
import android.support.v4.media.session.MediaSessionCompat;
@ -40,6 +36,17 @@ import android.view.SurfaceHolder;
import android.webkit.URLUtil;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.media.MediaBrowserServiceCompat;
import androidx.preference.PreferenceManager;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@ -52,12 +59,6 @@ import de.danoeh.antennapod.core.event.ServiceEvent;
import de.danoeh.antennapod.core.event.settings.SkipIntroEndingChangedEvent;
import de.danoeh.antennapod.core.event.settings.SpeedPresetChangedEvent;
import de.danoeh.antennapod.core.event.settings.VolumeAdaptionChangedEvent;
import de.danoeh.antennapod.model.feed.Chapter;
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.model.feed.FeedPreferences;
import de.danoeh.antennapod.model.playback.MediaType;
import de.danoeh.antennapod.core.preferences.PlaybackPreferences;
import de.danoeh.antennapod.core.preferences.SleepTimerPreferences;
import de.danoeh.antennapod.core.preferences.UserPreferences;
@ -66,15 +67,21 @@ import de.danoeh.antennapod.core.storage.DBReader;
import de.danoeh.antennapod.core.storage.DBTasks;
import de.danoeh.antennapod.core.storage.DBWriter;
import de.danoeh.antennapod.core.storage.FeedSearcher;
import de.danoeh.antennapod.core.sync.SyncService;
import de.danoeh.antennapod.core.sync.queue.SynchronizationQueueSink;
import de.danoeh.antennapod.core.util.FeedItemUtil;
import de.danoeh.antennapod.core.util.IntentUtils;
import de.danoeh.antennapod.core.util.NetworkUtils;
import de.danoeh.antennapod.core.util.gui.NotificationUtils;
import de.danoeh.antennapod.model.playback.Playable;
import de.danoeh.antennapod.core.util.playback.PlayableUtils;
import de.danoeh.antennapod.core.util.playback.PlaybackServiceStarter;
import de.danoeh.antennapod.core.widget.WidgetUpdater;
import de.danoeh.antennapod.model.feed.Chapter;
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.model.feed.FeedPreferences;
import de.danoeh.antennapod.model.playback.MediaType;
import de.danoeh.antennapod.model.playback.Playable;
import de.danoeh.antennapod.ui.appstartintent.MainActivityStarter;
import de.danoeh.antennapod.ui.appstartintent.VideoPlayerActivityStarter;
import io.reactivex.Completable;
@ -83,11 +90,6 @@ import io.reactivex.Single;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.Disposable;
import io.reactivex.schedulers.Schedulers;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import static de.danoeh.antennapod.model.feed.FeedPreferences.SPEED_USE_GLOBAL;
/**
* Controls the MediaPlayer that plays a FeedMedia-file
@ -966,7 +968,8 @@ public class PlaybackService extends MediaBrowserServiceCompat {
taskManager.cancelWidgetUpdater();
if (playable != null) {
if (playable instanceof FeedMedia) {
SyncService.enqueueEpisodePlayed(getApplicationContext(), (FeedMedia) playable, false);
SynchronizationQueueSink.enqueueEpisodePlayedIfSynchronizationIsActive(getApplicationContext(),
(FeedMedia) playable, false);
}
playable.onPlaybackPause(getApplicationContext());
}
@ -1110,10 +1113,12 @@ public class PlaybackService extends MediaBrowserServiceCompat {
}
if (ended || smartMarkAsPlayed) {
SyncService.enqueueEpisodePlayed(getApplicationContext(), media, true);
SynchronizationQueueSink.enqueueEpisodePlayedIfSynchronizationIsActive(
getApplicationContext(), media, true);
media.onPlaybackCompleted(getApplicationContext());
} else {
SyncService.enqueueEpisodePlayed(getApplicationContext(), media, false);
SynchronizationQueueSink.enqueueEpisodePlayedIfSynchronizationIsActive(
getApplicationContext(), media, false);
media.onPlaybackPause(getApplicationContext());
}

View File

@ -575,7 +575,6 @@ public final class DBReader {
@Nullable
private static FeedItem getFeedItemByGuidOrEpisodeUrl(final String guid, final String episodeUrl,
PodDBAdapter adapter) {
Log.d(TAG, "Loading feeditem with guid " + guid + " or episode url " + episodeUrl);
try (Cursor cursor = adapter.getFeedItemCursor(guid, episodeUrl)) {
if (!cursor.moveToNext()) {
return null;
@ -633,8 +632,6 @@ public final class DBReader {
* Does NOT load additional attributes like feed or queue state.
*/
public static FeedItem getFeedItemByGuidOrEpisodeUrl(final String guid, final String episodeUrl) {
Log.d(TAG, "getFeedItem() called with: " + "guid = [" + guid + "], episodeUrl = [" + episodeUrl + "]");
PodDBAdapter adapter = PodDBAdapter.getInstance();
adapter.open();
try {

View File

@ -1,5 +1,7 @@
package de.danoeh.antennapod.core.storage;
import static android.content.Context.MODE_PRIVATE;
import android.content.Context;
import android.content.SharedPreferences;
import android.database.Cursor;
@ -9,22 +11,6 @@ import android.util.Log;
import androidx.annotation.VisibleForTesting;
import de.danoeh.antennapod.core.R;
import de.danoeh.antennapod.core.event.FeedItemEvent;
import de.danoeh.antennapod.core.event.FeedListUpdateEvent;
import de.danoeh.antennapod.core.event.MessageEvent;
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.core.feed.LocalFeedUpdater;
import de.danoeh.antennapod.core.preferences.UserPreferences;
import de.danoeh.antennapod.core.service.download.DownloadStatus;
import de.danoeh.antennapod.core.storage.mapper.FeedCursorMapper;
import de.danoeh.antennapod.core.sync.SyncService;
import de.danoeh.antennapod.core.util.DownloadError;
import de.danoeh.antennapod.core.util.LongList;
import de.danoeh.antennapod.core.util.comparator.FeedItemPubdateComparator;
import de.danoeh.antennapod.net.sync.model.EpisodeAction;
import org.greenrobot.eventbus.EventBus;
import java.util.ArrayList;
@ -41,7 +27,23 @@ import java.util.concurrent.Future;
import java.util.concurrent.FutureTask;
import java.util.concurrent.atomic.AtomicBoolean;
import static android.content.Context.MODE_PRIVATE;
import de.danoeh.antennapod.core.R;
import de.danoeh.antennapod.core.event.FeedItemEvent;
import de.danoeh.antennapod.core.event.FeedListUpdateEvent;
import de.danoeh.antennapod.core.event.MessageEvent;
import de.danoeh.antennapod.core.feed.LocalFeedUpdater;
import de.danoeh.antennapod.core.preferences.UserPreferences;
import de.danoeh.antennapod.core.service.download.DownloadStatus;
import de.danoeh.antennapod.core.storage.mapper.FeedCursorMapper;
import de.danoeh.antennapod.core.sync.SyncService;
import de.danoeh.antennapod.core.sync.queue.SynchronizationQueueSink;
import de.danoeh.antennapod.core.util.DownloadError;
import de.danoeh.antennapod.core.util.LongList;
import de.danoeh.antennapod.core.util.comparator.FeedItemPubdateComparator;
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.model.EpisodeAction;
/**
* Provides methods for doing common tasks that use DBReader and DBWriter.
@ -482,7 +484,7 @@ public final class DBTasks {
.position(oldItem.getMedia().getDuration() / 1000)
.total(oldItem.getMedia().getDuration() / 1000)
.build();
SyncService.enqueueEpisodeAction(context, action);
SynchronizationQueueSink.enqueueEpisodeActionIfSynchronizationIsActive(context, action);
}
}
}

View File

@ -7,8 +7,6 @@ import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import de.danoeh.antennapod.core.sync.SyncService;
import de.danoeh.antennapod.net.sync.model.EpisodeAction;
import org.greenrobot.eventbus.EventBus;
import java.io.File;
@ -32,23 +30,24 @@ import de.danoeh.antennapod.core.event.MessageEvent;
import de.danoeh.antennapod.core.event.PlaybackHistoryEvent;
import de.danoeh.antennapod.core.event.QueueEvent;
import de.danoeh.antennapod.core.event.UnreadItemsUpdateEvent;
import de.danoeh.antennapod.model.feed.Feed;
import de.danoeh.antennapod.core.feed.FeedEvent;
import de.danoeh.antennapod.model.feed.FeedItem;
import de.danoeh.antennapod.model.feed.FeedMedia;
import de.danoeh.antennapod.model.feed.FeedPreferences;
import de.danoeh.antennapod.core.preferences.GpodnetPreferences;
import de.danoeh.antennapod.core.preferences.PlaybackPreferences;
import de.danoeh.antennapod.core.preferences.UserPreferences;
import de.danoeh.antennapod.core.service.download.DownloadStatus;
import de.danoeh.antennapod.core.service.playback.PlaybackService;
import de.danoeh.antennapod.core.sync.queue.SynchronizationQueueSink;
import de.danoeh.antennapod.core.util.FeedItemPermutors;
import de.danoeh.antennapod.core.util.IntentUtils;
import de.danoeh.antennapod.core.util.LongList;
import de.danoeh.antennapod.core.util.Permutor;
import de.danoeh.antennapod.core.util.playback.PlayableUtils;
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.model.feed.FeedPreferences;
import de.danoeh.antennapod.model.feed.SortOrder;
import de.danoeh.antennapod.model.playback.Playable;
import de.danoeh.antennapod.core.util.playback.PlayableUtils;
import de.danoeh.antennapod.net.sync.model.EpisodeAction;
/**
* Provides methods for writing data to AntennaPod's database.
@ -132,13 +131,11 @@ public class DBWriter {
}
// Gpodder: queue delete action for synchronization
if (GpodnetPreferences.loggedIn()) {
FeedItem item = media.getItem();
EpisodeAction action = new EpisodeAction.Builder(item, EpisodeAction.DELETE)
.currentTimestamp()
.build();
SyncService.enqueueEpisodeAction(context, action);
}
FeedItem item = media.getItem();
EpisodeAction action = new EpisodeAction.Builder(item, EpisodeAction.DELETE)
.currentTimestamp()
.build();
SynchronizationQueueSink.enqueueEpisodeActionIfSynchronizationIsActive(context, action);
}
EventBus.getDefault().post(FeedItemEvent.deletedMedia(Collections.singletonList(media.getItem())));
return true;
@ -170,7 +167,7 @@ public class DBWriter {
adapter.removeFeed(feed);
adapter.close();
SyncService.enqueueFeedRemoved(context, feed.getDownload_url());
SynchronizationQueueSink.enqueueFeedRemovedIfSynchronizationIsActive(context, feed.getDownload_url());
EventBus.getDefault().post(new FeedListUpdateEvent(feed));
});
}
@ -782,7 +779,7 @@ public class DBWriter {
adapter.close();
for (Feed feed : feeds) {
SyncService.enqueueFeedAdded(context, feed.getDownload_url());
SynchronizationQueueSink.enqueueFeedAddedIfSynchronizationIsActive(context, feed.getDownload_url());
}
BackupManager backupManager = new BackupManager(context);

View File

@ -1123,7 +1123,6 @@ public class PodDBAdapter {
+ " INNER JOIN " + TABLE_NAME_FEEDS
+ " ON " + TABLE_NAME_FEED_ITEMS + "." + KEY_FEED + "=" + TABLE_NAME_FEEDS + "." + KEY_ID
+ " WHERE " + whereClauseCondition;
Log.d(TAG, "SQL: " + query);
return db.rawQuery(query, null);
}

View File

@ -4,7 +4,8 @@ public class GuidValidator {
public static boolean isValidGuid(String guid) {
return guid != null
&& !guid.trim().isEmpty();
&& !guid.trim().isEmpty()
&& !guid.equals("null");
}
}

View File

@ -0,0 +1,35 @@
package de.danoeh.antennapod.core.sync;
import java.util.concurrent.locks.ReentrantLock;
import io.reactivex.Completable;
import io.reactivex.schedulers.Schedulers;
public class LockingAsyncExecutor {
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();
}
}
}

View File

@ -5,7 +5,6 @@ import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.annotation.NonNull;
@ -20,12 +19,16 @@ import androidx.work.WorkManager;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import org.apache.commons.lang3.StringUtils;
import org.greenrobot.eventbus.EventBus;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import de.danoeh.antennapod.core.R;
import de.danoeh.antennapod.core.event.SyncServiceEvent;
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.core.preferences.GpodnetPreferences;
import de.danoeh.antennapod.core.preferences.UserPreferences;
import de.danoeh.antennapod.core.service.download.AntennapodHttpClient;
import de.danoeh.antennapod.core.storage.DBReader;
@ -33,10 +36,14 @@ import de.danoeh.antennapod.core.storage.DBTasks;
import de.danoeh.antennapod.core.storage.DBWriter;
import de.danoeh.antennapod.core.storage.DownloadRequestException;
import de.danoeh.antennapod.core.storage.DownloadRequester;
import de.danoeh.antennapod.core.sync.queue.SynchronizationQueueStorage;
import de.danoeh.antennapod.core.util.FeedItemUtil;
import de.danoeh.antennapod.core.util.LongList;
import de.danoeh.antennapod.core.util.URLChecker;
import de.danoeh.antennapod.core.util.gui.NotificationUtils;
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;
@ -44,158 +51,56 @@ 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 io.reactivex.Completable;
import io.reactivex.schedulers.Schedulers;
import org.apache.commons.lang3.StringUtils;
import org.greenrobot.eventbus.EventBus;
import org.json.JSONArray;
import org.json.JSONException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import de.danoeh.antennapod.net.sync.nextcloud.NextcloudSyncService;
public class SyncService extends Worker {
private static final String PREF_NAME = "SyncService";
private static final String PREF_LAST_SUBSCRIPTION_SYNC_TIMESTAMP = "last_sync_timestamp";
private static final String PREF_LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP = "last_episode_actions_sync_timestamp";
private static final String PREF_QUEUED_FEEDS_ADDED = "sync_added";
private static final String PREF_QUEUED_FEEDS_REMOVED = "sync_removed";
private static final String PREF_QUEUED_EPISODE_ACTIONS = "sync_queued_episode_actions";
private static final String PREF_LAST_SYNC_ATTEMPT_TIMESTAMP = "last_sync_attempt_timestamp";
private static final String PREF_LAST_SYNC_ATTEMPT_SUCCESS = "last_sync_attempt_success";
private static final String TAG = "SyncService";
private static final String WORK_ID_SYNC = "SyncServiceWorkId";
private static final ReentrantLock lock = new ReentrantLock();
public static final String TAG = "SyncService";
private ISyncService syncServiceImpl;
private static final String WORK_ID_SYNC = "SyncServiceWorkId";
private final SynchronizationQueueStorage synchronizationQueueStorage;
public SyncService(@NonNull Context context, @NonNull WorkerParameters params) {
super(context, params);
synchronizationQueueStorage = new SynchronizationQueueStorage(context);
}
@Override
@NonNull
public Result doWork() {
if (!GpodnetPreferences.loggedIn()) {
ISyncService activeSyncProvider = getActiveSyncProvider();
if (activeSyncProvider == null) {
return Result.success();
}
syncServiceImpl = new GpodnetService(AntennapodHttpClient.getHttpClient(),
GpodnetPreferences.getHosturl(), GpodnetPreferences.getDeviceID(),
GpodnetPreferences.getUsername(), GpodnetPreferences.getPassword());
SharedPreferences.Editor prefs = getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
.edit();
prefs.putLong(PREF_LAST_SYNC_ATTEMPT_TIMESTAMP, System.currentTimeMillis()).apply();
SynchronizationSettings.updateLastSynchronizationAttempt();
try {
syncServiceImpl.login();
activeSyncProvider.login();
EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_subscriptions));
syncSubscriptions();
syncEpisodeActions();
syncServiceImpl.logout();
syncSubscriptions(activeSyncProvider);
syncEpisodeActions(activeSyncProvider);
activeSyncProvider.logout();
clearErrorNotifications();
EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_success));
prefs.putBoolean(PREF_LAST_SYNC_ATTEMPT_SUCCESS, true).apply();
SynchronizationSettings.setLastSynchronizationAttemptSuccess(true);
return Result.success();
} catch (SyncServiceException e) {
} catch (Exception e) {
EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_error));
prefs.putBoolean(PREF_LAST_SYNC_ATTEMPT_SUCCESS, false).apply();
SynchronizationSettings.setLastSynchronizationAttemptSuccess(false);
Log.e(TAG, Log.getStackTraceString(e));
if (getRunAttemptCount() % 3 == 2) {
// Do not spam users with notification and retry before notifying
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();
}
return Result.retry();
}
}
public static void clearQueue(Context context) {
executeLockedAsync(() ->
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE).edit()
.putLong(PREF_LAST_SUBSCRIPTION_SYNC_TIMESTAMP, 0)
.putLong(PREF_LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP, 0)
.putLong(PREF_LAST_SYNC_ATTEMPT_TIMESTAMP, 0)
.putString(PREF_QUEUED_EPISODE_ACTIONS, "[]")
.putString(PREF_QUEUED_FEEDS_ADDED, "[]")
.putString(PREF_QUEUED_FEEDS_REMOVED, "[]")
.apply());
}
public static void enqueueFeedAdded(Context context, String downloadUrl) {
if (!GpodnetPreferences.loggedIn()) {
return;
}
executeLockedAsync(() -> {
try {
SharedPreferences prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
String json = prefs.getString(PREF_QUEUED_FEEDS_ADDED, "[]");
JSONArray queue = new JSONArray(json);
queue.put(downloadUrl);
prefs.edit().putString(PREF_QUEUED_FEEDS_ADDED, queue.toString()).apply();
} catch (JSONException e) {
e.printStackTrace();
}
sync(context);
});
}
public static void enqueueFeedRemoved(Context context, String downloadUrl) {
if (!GpodnetPreferences.loggedIn()) {
return;
}
executeLockedAsync(() -> {
try {
SharedPreferences prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
String json = prefs.getString(PREF_QUEUED_FEEDS_REMOVED, "[]");
JSONArray queue = new JSONArray(json);
queue.put(downloadUrl);
prefs.edit().putString(PREF_QUEUED_FEEDS_REMOVED, queue.toString()).apply();
} catch (JSONException e) {
e.printStackTrace();
}
sync(context);
});
}
public static void enqueueEpisodeAction(Context context, EpisodeAction action) {
if (!GpodnetPreferences.loggedIn()) {
return;
}
executeLockedAsync(() -> {
try {
SharedPreferences prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
String json = prefs.getString(PREF_QUEUED_EPISODE_ACTIONS, "[]");
JSONArray queue = new JSONArray(json);
queue.put(action.writeToJsonObject());
prefs.edit().putString(PREF_QUEUED_EPISODE_ACTIONS, queue.toString()).apply();
} catch (JSONException e) {
e.printStackTrace();
}
sync(context);
});
}
public static void enqueueEpisodePlayed(Context context, FeedMedia media, boolean completed) {
if (!GpodnetPreferences.loggedIn()) {
return;
}
if (media.getItem() == null) {
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();
SyncService.enqueueEpisodeAction(context, action);
}
public static void sync(Context context) {
OneTimeWorkRequest workRequest = getWorkRequest().build();
WorkManager.getInstance(context).enqueueUniqueWork(WORK_ID_SYNC, ExistingWorkPolicy.REPLACE, workRequest);
@ -211,13 +116,8 @@ public class SyncService extends Worker {
}
public static void fullSync(Context context) {
executeLockedAsync(() -> {
context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE).edit()
.putLong(PREF_LAST_SUBSCRIPTION_SYNC_TIMESTAMP, 0)
.putLong(PREF_LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP, 0)
.putLong(PREF_LAST_SYNC_ATTEMPT_TIMESTAMP, 0)
.apply();
LockingAsyncExecutor.executeLockedAsync(() -> {
SynchronizationSettings.resetTimestamps();
OneTimeWorkRequest workRequest = getWorkRequest()
.setInitialDelay(0L, TimeUnit.SECONDS)
.build();
@ -226,108 +126,14 @@ public class SyncService extends Worker {
});
}
private static OneTimeWorkRequest.Builder getWorkRequest() {
Constraints.Builder constraints = new Constraints.Builder();
if (UserPreferences.isAllowMobileFeedRefresh()) {
constraints.setRequiredNetworkType(NetworkType.CONNECTED);
} else {
constraints.setRequiredNetworkType(NetworkType.UNMETERED);
}
return new OneTimeWorkRequest.Builder(SyncService.class)
.setConstraints(constraints.build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.MINUTES)
.setInitialDelay(5L, TimeUnit.SECONDS); // Give it some time, so other actions can be queued
}
/**
* 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.
*/
private 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 boolean isLastSyncSuccessful(Context context) {
return context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
.getBoolean(PREF_LAST_SYNC_ATTEMPT_SUCCESS, false);
}
public static long getLastSyncAttempt(Context context) {
return context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
.getLong(PREF_LAST_SYNC_ATTEMPT_TIMESTAMP, 0);
}
private List<EpisodeAction> getQueuedEpisodeActions() {
ArrayList<EpisodeAction> actions = new ArrayList<>();
try {
SharedPreferences prefs = getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
String json = prefs.getString(PREF_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;
}
private List<String> getQueuedRemovedFeeds() {
ArrayList<String> actions = new ArrayList<>();
try {
SharedPreferences prefs = getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
String json = prefs.getString(PREF_QUEUED_FEEDS_REMOVED, "[]");
JSONArray queue = new JSONArray(json);
for (int i = 0; i < queue.length(); i++) {
actions.add(queue.getString(i));
}
} catch (JSONException e) {
e.printStackTrace();
}
return actions;
}
private List<String> getQueuedAddedFeeds() {
ArrayList<String> actions = new ArrayList<>();
try {
SharedPreferences prefs = getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
String json = prefs.getString(PREF_QUEUED_FEEDS_ADDED, "[]");
JSONArray queue = new JSONArray(json);
for (int i = 0; i < queue.length(); i++) {
actions.add(queue.getString(i));
}
} catch (JSONException e) {
e.printStackTrace();
}
return actions;
}
private void syncSubscriptions() throws SyncServiceException {
final long lastSync = getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
.getLong(PREF_LAST_SUBSCRIPTION_SYNC_TIMESTAMP, 0);
private void syncSubscriptions(ISyncService syncServiceImpl) throws SyncServiceException {
final long lastSync = SynchronizationSettings.getLastSubscriptionSynchronizationTimestamp();
final List<String> localSubscriptions = DBReader.getFeedListDownloadUrls();
SubscriptionChanges subscriptionChanges = syncServiceImpl.getSubscriptionChanges(lastSync);
long newTimeStamp = subscriptionChanges.getTimestamp();
List<String> queuedRemovedFeeds = getQueuedRemovedFeeds();
List<String> queuedAddedFeeds = getQueuedAddedFeeds();
List<String> queuedRemovedFeeds = synchronizationQueueStorage.getQueuedRemovedFeeds();
List<String> queuedAddedFeeds = synchronizationQueueStorage.getQueuedAddedFeeds();
Log.d(TAG, "Downloaded subscription changes: " + subscriptionChanges);
for (String downloadUrl : subscriptionChanges.getAdded()) {
@ -359,26 +165,21 @@ public class SyncService extends Worker {
Log.d(TAG, "Added: " + StringUtils.join(queuedAddedFeeds, ", "));
Log.d(TAG, "Removed: " + StringUtils.join(queuedRemovedFeeds, ", "));
lock.lock();
LockingAsyncExecutor.lock.lock();
try {
UploadChangesResponse uploadResponse = syncServiceImpl
.uploadSubscriptionChanges(queuedAddedFeeds, queuedRemovedFeeds);
getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE).edit()
.putString(PREF_QUEUED_FEEDS_ADDED, "[]").apply();
getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE).edit()
.putString(PREF_QUEUED_FEEDS_REMOVED, "[]").apply();
synchronizationQueueStorage.clearFeedQueues();
newTimeStamp = uploadResponse.timestamp;
} finally {
lock.unlock();
LockingAsyncExecutor.lock.unlock();
}
}
getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE).edit()
.putLong(PREF_LAST_SUBSCRIPTION_SYNC_TIMESTAMP, newTimeStamp).apply();
SynchronizationSettings.setLastSubscriptionSynchronizationAttemptTimestamp(newTimeStamp);
}
private void syncEpisodeActions() throws SyncServiceException {
final long lastSync = getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
.getLong(PREF_LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP, 0);
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();
@ -387,7 +188,7 @@ public class SyncService extends Worker {
// upload local actions
EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_episodes_upload));
List<EpisodeAction> queuedEpisodeActions = getQueuedEpisodeActions();
List<EpisodeAction> queuedEpisodeActions = synchronizationQueueStorage.getQueuedEpisodeActions();
if (lastSync == 0) {
EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_upload_played));
List<FeedItem> readItems = DBReader.getPlayedItems();
@ -407,24 +208,21 @@ public class SyncService extends Worker {
}
}
if (queuedEpisodeActions.size() > 0) {
lock.lock();
LockingAsyncExecutor.lock.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);
getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE).edit()
.putString(PREF_QUEUED_EPISODE_ACTIONS, "[]").apply();
synchronizationQueueStorage.clearEpisodeActionQueue();
} finally {
lock.unlock();
LockingAsyncExecutor.lock.unlock();
}
}
getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE).edit()
.putLong(PREF_LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP, newTimeStamp).apply();
SynchronizationSettings.setLastEpisodeActionSynchronizationAttemptTimestamp(newTimeStamp);
}
private synchronized void processEpisodeActions(List<EpisodeAction> remoteActions) {
Log.d(TAG, "Processing " + remoteActions.size() + " actions");
if (remoteActions.size() == 0) {
@ -432,7 +230,8 @@ public class SyncService extends Worker {
}
Map<Pair<String, String>, EpisodeAction> playActionsToUpdate = EpisodeActionFilter
.getRemoteActionsOverridingLocalActions(remoteActions, getQueuedEpisodeActions());
.getRemoteActionsOverridingLocalActions(remoteActions,
synchronizationQueueStorage.getQueuedEpisodeActions());
LongList queueToBeRemoved = new LongList();
List<FeedItem> updatedItems = new ArrayList<>();
for (EpisodeAction action : playActionsToUpdate.values()) {
@ -442,20 +241,24 @@ public class SyncService extends Worker {
Log.i(TAG, "Unknown feed item: " + action);
continue;
}
if (feedItem.getMedia() == null) {
Log.i(TAG, "Feed item has no media: " + action);
continue;
}
if (action.getAction() == EpisodeAction.NEW) {
DBWriter.markItemPlayed(feedItem, FeedItem.UNPLAYED, true);
continue;
}
Log.d(TAG, "Most recent play action: " + action.toString());
FeedMedia media = feedItem.getMedia();
media.setPosition(action.getPosition() * 1000);
feedItem.getMedia().setPosition(action.getPosition() * 1000);
if (FeedItemUtil.hasAlmostEnded(feedItem.getMedia())) {
Log.d(TAG, "Marking as played");
Log.d(TAG, "Marking as played: " + action);
feedItem.setPlayed(true);
feedItem.getMedia().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);
@ -469,7 +272,7 @@ public class SyncService extends Worker {
nm.cancel(R.id.notification_gpodnet_sync_autherror);
}
private void updateErrorNotification(SyncServiceException exception) {
private void updateErrorNotification(Exception exception) {
if (!UserPreferences.gpodnetNotificationsEnabled()) {
Log.d(TAG, "Skipping sync error notification because of user setting");
return;
@ -486,6 +289,7 @@ public class SyncService extends Worker {
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)
@ -495,4 +299,36 @@ public class SyncService extends Worker {
.getSystemService(Context.NOTIFICATION_SERVICE);
nm.notify(R.id.notification_gpodnet_sync_error, notification);
}
private static OneTimeWorkRequest.Builder getWorkRequest() {
Constraints.Builder constraints = new Constraints.Builder();
if (UserPreferences.isAllowMobileFeedRefresh()) {
constraints.setRequiredNetworkType(NetworkType.CONNECTED);
} else {
constraints.setRequiredNetworkType(NetworkType.UNMETERED);
}
return new OneTimeWorkRequest.Builder(SyncService.class)
.setConstraints(constraints.build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.MINUTES)
.setInitialDelay(5L, TimeUnit.SECONDS); // Give it some time, so other actions can be queued
}
private ISyncService getActiveSyncProvider() {
String selectedSyncProviderKey = SynchronizationSettings.getSelectedSyncProviderKey();
SynchronizationProviderViewData selectedService = SynchronizationProviderViewData
.valueOf(selectedSyncProviderKey);
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;
}
}
}

View File

@ -0,0 +1,67 @@
package de.danoeh.antennapod.core.sync;
import android.content.Context;
import android.content.SharedPreferences;
import de.danoeh.antennapod.core.ClientConfig;
import de.danoeh.antennapod.core.preferences.UserPreferences;
import de.danoeh.antennapod.core.sync.queue.SynchronizationQueueSink;
/**
* Manages preferences for accessing gpodder.net service and other sync providers
*/
public class SynchronizationCredentials {
private SynchronizationCredentials() {
}
private static final String PREF_NAME = "gpodder.net";
private static final String PREF_USERNAME = "de.danoeh.antennapod.preferences.gpoddernet.username";
private static final String PREF_PASSWORD = "de.danoeh.antennapod.preferences.gpoddernet.password";
private static final String PREF_DEVICEID = "de.danoeh.antennapod.preferences.gpoddernet.deviceID";
private static final String PREF_HOSTNAME = "prefGpodnetHostname";
private static SharedPreferences getPreferences() {
return ClientConfig.applicationCallbacks.getApplicationInstance()
.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
}
public static String getUsername() {
return getPreferences().getString(PREF_USERNAME, null);
}
public static void setUsername(String username) {
getPreferences().edit().putString(PREF_USERNAME, username).apply();
}
public static String getPassword() {
return getPreferences().getString(PREF_PASSWORD, null);
}
public static void setPassword(String password) {
getPreferences().edit().putString(PREF_PASSWORD, password).apply();
}
public static String getDeviceID() {
return getPreferences().getString(PREF_DEVICEID, null);
}
public static void setDeviceID(String deviceID) {
getPreferences().edit().putString(PREF_DEVICEID, deviceID).apply();
}
public static String getHosturl() {
return getPreferences().getString(PREF_HOSTNAME, null);
}
public static void setHosturl(String value) {
getPreferences().edit().putString(PREF_HOSTNAME, value).apply();
}
public static synchronized void clear(Context context) {
setUsername(null);
setPassword(null);
setDeviceID(null);
SynchronizationQueueSink.clearQueue(context);
UserPreferences.setGpodnetNotificationsEnabled();
}
}

View File

@ -0,0 +1,47 @@
package de.danoeh.antennapod.core.sync;
import de.danoeh.antennapod.core.R;
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;
}
}

View File

@ -0,0 +1,83 @@
package de.danoeh.antennapod.core.sync;
import android.content.Context;
import android.content.SharedPreferences;
import de.danoeh.antennapod.core.ClientConfig;
public class SynchronizationSettings {
public static final String LAST_SYNC_ATTEMPT_TIMESTAMP = "last_sync_attempt_timestamp";
private static final String NAME = "synchronization";
private static final String SELECTED_SYNC_PROVIDER = "selected_sync_provider";
private static final String LAST_SYNC_ATTEMPT_SUCCESS = "last_sync_attempt_success";
private static final String LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP = "last_episode_actions_sync_timestamp";
private static final String LAST_SUBSCRIPTION_SYNC_TIMESTAMP = "last_sync_timestamp";
public static boolean isProviderConnected() {
return getSelectedSyncProviderKey() != null;
}
public static void resetTimestamps() {
getSharedPreferences().edit()
.putLong(LAST_SUBSCRIPTION_SYNC_TIMESTAMP, 0)
.putLong(LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP, 0)
.putLong(LAST_SYNC_ATTEMPT_TIMESTAMP, 0)
.apply();
}
public static boolean isLastSyncSuccessful() {
return getSharedPreferences().getBoolean(LAST_SYNC_ATTEMPT_SUCCESS, false);
}
public static long getLastSyncAttempt() {
return getSharedPreferences().getLong(LAST_SYNC_ATTEMPT_TIMESTAMP, 0);
}
public static void setSelectedSyncProvider(SynchronizationProviderViewData provider) {
getSharedPreferences()
.edit()
.putString(SELECTED_SYNC_PROVIDER, provider == null ? null : provider.getIdentifier())
.apply();
}
public static String getSelectedSyncProviderKey() {
return getSharedPreferences().getString(SELECTED_SYNC_PROVIDER, null);
}
public static void updateLastSynchronizationAttempt() {
getSharedPreferences().edit()
.putLong(LAST_SYNC_ATTEMPT_TIMESTAMP, System.currentTimeMillis())
.apply();
}
public static void setLastSynchronizationAttemptSuccess(boolean isSuccess) {
getSharedPreferences().edit()
.putBoolean(LAST_SYNC_ATTEMPT_SUCCESS, isSuccess)
.apply();
}
public static long getLastSubscriptionSynchronizationTimestamp() {
return getSharedPreferences().getLong(LAST_SUBSCRIPTION_SYNC_TIMESTAMP, 0);
}
public static void setLastSubscriptionSynchronizationAttemptTimestamp(long newTimeStamp) {
getSharedPreferences().edit()
.putLong(LAST_SUBSCRIPTION_SYNC_TIMESTAMP, newTimeStamp).apply();
}
public static long getLastEpisodeActionSynchronizationTimestamp() {
return getSharedPreferences()
.getLong(LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP, 0);
}
public static void setLastEpisodeActionSynchronizationAttemptTimestamp(long timestamp) {
getSharedPreferences().edit()
.putLong(LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP, timestamp).apply();
}
private static SharedPreferences getSharedPreferences() {
return ClientConfig.applicationCallbacks.getApplicationInstance()
.getSharedPreferences(NAME, Context.MODE_PRIVATE);
}
}

View File

@ -0,0 +1,67 @@
package de.danoeh.antennapod.core.sync.queue;
import android.content.Context;
import de.danoeh.antennapod.core.sync.LockingAsyncExecutor;
import de.danoeh.antennapod.core.sync.SyncService;
import de.danoeh.antennapod.core.sync.SynchronizationSettings;
import de.danoeh.antennapod.model.feed.FeedMedia;
import de.danoeh.antennapod.net.sync.model.EpisodeAction;
public class SynchronizationQueueSink {
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);
SyncService.sync(context);
});
}
public static void enqueueFeedRemovedIfSynchronizationIsActive(Context context, String downloadUrl) {
if (!SynchronizationSettings.isProviderConnected()) {
return;
}
LockingAsyncExecutor.executeLockedAsync(() -> {
new SynchronizationQueueStorage(context).enqueueFeedRemoved(downloadUrl);
SyncService.sync(context);
});
}
public static void enqueueEpisodeActionIfSynchronizationIsActive(Context context, EpisodeAction action) {
if (!SynchronizationSettings.isProviderConnected()) {
return;
}
LockingAsyncExecutor.executeLockedAsync(() -> {
new SynchronizationQueueStorage(context).enqueueEpisodeAction(action);
SyncService.sync(context);
});
}
public static void enqueueEpisodePlayedIfSynchronizationIsActive(Context context, FeedMedia media,
boolean completed) {
if (!SynchronizationSettings.isProviderConnected()) {
return;
}
if (media.getItem() == null) {
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);
}
}

View File

@ -0,0 +1,140 @@
package de.danoeh.antennapod.core.sync.queue;
import android.content.Context;
import android.content.SharedPreferences;
import org.json.JSONArray;
import org.json.JSONException;
import java.util.ArrayList;
import de.danoeh.antennapod.core.sync.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();
String json = sharedPreferences
.getString(QUEUED_FEEDS_ADDED, "[]");
try {
JSONArray queue = new JSONArray(json);
queue.put(downloadUrl);
sharedPreferences
.edit().putString(QUEUED_FEEDS_ADDED, queue.toString()).apply();
} catch (JSONException jsonException) {
jsonException.printStackTrace();
}
}
protected void enqueueFeedRemoved(String downloadUrl) {
SharedPreferences sharedPreferences = getSharedPreferences();
String json = sharedPreferences.getString(QUEUED_FEEDS_REMOVED, "[]");
try {
JSONArray queue = new JSONArray(json);
queue.put(downloadUrl);
sharedPreferences.edit().putString(QUEUED_FEEDS_REMOVED, queue.toString())
.apply();
} catch (JSONException jsonException) {
jsonException.printStackTrace();
}
}
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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -356,7 +356,7 @@
<string name="storage_sum">Episode auto delete, Import, Export</string>
<string name="project_pref">Project</string>
<string name="synchronization_pref">Synchronization</string>
<string name="synchronization_sum">Synchronize with other devices using gpodder.net</string>
<string name="synchronization_sum">Synchronize with other devices</string>
<string name="automation">Automation</string>
<string name="download_pref_details">Details</string>
<string name="import_export_pref">Import/Export</string>
@ -447,17 +447,20 @@
<string name="pref_theme_title_dark">Dark</string>
<string name="pref_theme_title_trueblack">Black (AMOLED ready)</string>
<string name="pref_episode_cache_unlimited">Unlimited</string>
<string name="pref_gpodnet_authenticate_title">Login</string>
<string name="pref_gpodnet_authenticate_sum">Login with your gpodder.net account in order to sync your subscriptions.</string>
<string name="pref_gpodnet_logout_title">Logout</string>
<string name="pref_gpodnet_logout_toast">Logout was successful</string>
<string name="synchronization_logout">Logout</string>
<string name="pref_synchronization_logout_toast">Logout was successful</string>
<string name="pref_gpodnet_setlogin_information_title">Change login information</string>
<string name="pref_gpodnet_setlogin_information_sum">Change the login information for your gpodder.net account.</string>
<string name="pref_gpodnet_sync_changes_title">Synchronize now</string>
<string name="pref_gpodnet_sync_changes_sum">Sync subscription and episode state changes with gpodder.net.</string>
<string name="pref_gpodnet_full_sync_title">Force full synchronization</string>
<string name="pref_gpodnet_full_sync_sum">Sync all subscriptions and episode states with gpodder.net.</string>
<string name="pref_gpodnet_login_status"><![CDATA[Logged in as <i>%1$s</i> with device <i>%2$s</i>]]></string>
<string name="synchronization_sync_changes_title">Synchronize now</string>
<string name="synchronization_full_sync_title">Force full synchronization</string>
<string name="synchronization_login_status"><![CDATA[Logged in as <i>%1$s</i> on <i>%2$s</i>. <br/><br/>You can choose your synchronization provider again once you have logged out]]></string>
<string name="synchronization_summary_unchoosen">You can choose from multiple providers to synchronize your subscriptions and episode play state with</string>
<string name="synchronization_summary_nextcloud">Gpoddersync is an open-source Nextcloud app that you can easily install on your own server. The app is independent of the AntennaPod project.</string>
<string name="synchronization_nextcloud_authenticate_browser">Grant access using the opened web browser and come back to AntennaPod.</string>
<string name="synchronization_choose_title">Choose synchronization provider</string>
<string name="synchronization_force_sync_summary">Re-synchronize all subscriptions and episode states</string>
<string name="synchronization_sync_summary">Synchronize subscription and episode state changes</string>
<string name="dialog_choose_sync_service_title">Choose synchronization provider</string>
<string name="pref_playback_speed_sum">Customize the speeds available for variable speed playback</string>
<string name="pref_feed_playback_speed_sum">The speed to use when starting audio playback for episodes in this podcast</string>
<string name="pref_feed_skip">Auto Skip</string>

View File

@ -14,5 +14,6 @@ public class GuidValidatorTest extends TestCase {
assertFalse(GuidValidator.isValidGuid("\n"));
assertFalse(GuidValidator.isValidGuid(" \n"));
assertFalse(GuidValidator.isValidGuid(null));
assertFalse(GuidValidator.isValidGuid("null"));
}
}

View File

@ -9,4 +9,7 @@ dependencies {
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
implementation "org.apache.commons:commons-lang3:$commonslangVersion"
implementation 'commons-io:commons-io:2.5'
implementation "io.reactivex.rxjava2:rxandroid:$rxAndroidVersion"
implementation "io.reactivex.rxjava2:rxjava:$rxJavaVersion"
}

View File

@ -0,0 +1,41 @@
package de.danoeh.antennapod.net.sync;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class HostnameParser {
public String scheme;
public int port;
public String host;
// split into schema, host and port - missing parts are null
private static final Pattern URLSPLIT_REGEX = Pattern.compile("(?:(https?)://)?([^:]+)(?::(\\d+))?");
public HostnameParser(String hosturl) {
Matcher m = URLSPLIT_REGEX.matcher(hosturl);
if (m.matches()) {
scheme = m.group(1);
host = m.group(2);
if (m.group(3) == null) {
port = -1;
} else {
port = Integer.parseInt(m.group(3)); // regex -> can only be digits
}
} else {
// URL does not match regex: use it anyway -> this will cause an exception on connect
scheme = "https";
host = hosturl;
port = 443;
}
if (scheme == null) { // assume https
scheme = "https";
}
if (scheme.equals("https") && port == -1) {
port = 443;
} else if (scheme.equals("http") && port == -1) {
port = 80;
}
}
}

View File

@ -1,25 +1,10 @@
package de.danoeh.antennapod.net.sync.gpoddernet;
import android.util.Log;
import androidx.annotation.NonNull;
import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetDevice;
import de.danoeh.antennapod.net.sync.model.EpisodeAction;
import de.danoeh.antennapod.net.sync.model.EpisodeActionChanges;
import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetEpisodeActionPostResponse;
import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetPodcast;
import de.danoeh.antennapod.net.sync.model.ISyncService;
import de.danoeh.antennapod.net.sync.model.SubscriptionChanges;
import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetTag;
import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetUploadChangesResponse;
import de.danoeh.antennapod.net.sync.model.SyncServiceException;
import de.danoeh.antennapod.net.sync.model.UploadChangesResponse;
import okhttp3.Credentials;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
import de.danoeh.antennapod.net.sync.HostnameParser;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
@ -35,12 +20,28 @@ import java.net.URL;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import de.danoeh.antennapod.net.sync.gpoddernet.mapper.ResponseMapper;
import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetDevice;
import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetEpisodeActionPostResponse;
import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetPodcast;
import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetTag;
import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetUploadChangesResponse;
import de.danoeh.antennapod.net.sync.model.EpisodeAction;
import de.danoeh.antennapod.net.sync.model.EpisodeActionChanges;
import de.danoeh.antennapod.net.sync.model.ISyncService;
import de.danoeh.antennapod.net.sync.model.SubscriptionChanges;
import de.danoeh.antennapod.net.sync.model.SyncServiceException;
import de.danoeh.antennapod.net.sync.model.UploadChangesResponse;
import okhttp3.Credentials;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
/**
* Communicates with the gpodder.net service.
@ -61,43 +62,16 @@ public class GpodnetService implements ISyncService {
private final OkHttpClient httpClient;
// split into schema, host and port - missing parts are null
private static final Pattern URLSPLIT_REGEX = Pattern.compile("(?:(https?)://)?([^:]+)(?::(\\d+))?");
public GpodnetService(OkHttpClient httpClient, String baseHosturl,
String deviceId, String username, String password) {
this.httpClient = httpClient;
this.deviceId = deviceId;
this.username = username;
this.password = password;
Matcher m = URLSPLIT_REGEX.matcher(baseHosturl);
if (m.matches()) {
this.baseScheme = m.group(1);
this.baseHost = m.group(2);
if (m.group(3) == null) {
this.basePort = -1;
} else {
this.basePort = Integer.parseInt(m.group(3)); // regex -> can only be digits
}
} else {
// URL does not match regex: use it anyway -> this will cause an exception on connect
this.baseScheme = "https";
this.baseHost = baseHosturl;
this.basePort = 443;
}
if (this.baseScheme == null) { // assume https
this.baseScheme = "https";
}
if (this.baseScheme.equals("https") && this.basePort == -1) {
this.basePort = 443;
}
if (this.baseScheme.equals("http") && this.basePort == -1) {
this.basePort = 80;
}
HostnameParser hostname = new HostnameParser(baseHosturl == null ? DEFAULT_BASE_HOST : baseHosturl);
this.baseHost = hostname.host;
this.basePort = hostname.port;
this.baseScheme = hostname.scheme;
}
private void requireLoggedIn() {
@ -434,7 +408,7 @@ public class GpodnetService implements ISyncService {
String response = executeRequest(request);
JSONObject changes = new JSONObject(response);
return readSubscriptionChangesFromJsonObject(changes);
return ResponseMapper.readSubscriptionChangesFromJsonObject(changes);
} catch (URISyntaxException e) {
e.printStackTrace();
throw new IllegalStateException(e);
@ -515,7 +489,7 @@ public class GpodnetService implements ISyncService {
String response = executeRequest(request);
JSONObject json = new JSONObject(response);
return readEpisodeActionsFromJsonObject(json);
return ResponseMapper.readEpisodeActionsFromJsonObject(json);
} catch (URISyntaxException e) {
e.printStackTrace();
throw new IllegalStateException(e);
@ -526,7 +500,6 @@ public class GpodnetService implements ISyncService {
}
/**
* Logs in a specific user. This method must be called if any of the methods
* that require authentication is used.
@ -689,48 +662,6 @@ public class GpodnetService implements ISyncService {
return new GpodnetDevice(id, caption, type, subscriptions);
}
private SubscriptionChanges readSubscriptionChangesFromJsonObject(@NonNull JSONObject object)
throws JSONException {
List<String> added = new LinkedList<>();
JSONArray jsonAdded = object.getJSONArray("add");
for (int i = 0; i < jsonAdded.length(); i++) {
String addedUrl = jsonAdded.getString(i);
// gpodder escapes colons unnecessarily
addedUrl = addedUrl.replace("%3A", ":");
added.add(addedUrl);
}
List<String> removed = new LinkedList<>();
JSONArray jsonRemoved = object.getJSONArray("remove");
for (int i = 0; i < jsonRemoved.length(); i++) {
String removedUrl = jsonRemoved.getString(i);
// gpodder escapes colons unnecessarily
removedUrl = removedUrl.replace("%3A", ":");
removed.add(removedUrl);
}
long timestamp = object.getLong("timestamp");
return new SubscriptionChanges(added, removed, timestamp);
}
private EpisodeActionChanges readEpisodeActionsFromJsonObject(@NonNull JSONObject object)
throws JSONException {
List<EpisodeAction> episodeActions = new ArrayList<>();
long timestamp = object.getLong("timestamp");
JSONArray jsonActions = object.getJSONArray("actions");
for (int i = 0; i < jsonActions.length(); i++) {
JSONObject jsonAction = jsonActions.getJSONObject(i);
EpisodeAction episodeAction = EpisodeAction.readFromJsonObject(jsonAction);
if (episodeAction != null) {
episodeActions.add(episodeAction);
}
}
return new EpisodeActionChanges(episodeActions, timestamp);
}
@Override
public void logout() {

View File

@ -0,0 +1,60 @@
package de.danoeh.antennapod.net.sync.gpoddernet.mapper;
import androidx.annotation.NonNull;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import de.danoeh.antennapod.net.sync.model.EpisodeAction;
import de.danoeh.antennapod.net.sync.model.EpisodeActionChanges;
import de.danoeh.antennapod.net.sync.model.SubscriptionChanges;
public class ResponseMapper {
public static SubscriptionChanges readSubscriptionChangesFromJsonObject(@NonNull JSONObject object)
throws JSONException {
List<String> added = new LinkedList<>();
JSONArray jsonAdded = object.getJSONArray("add");
for (int i = 0; i < jsonAdded.length(); i++) {
String addedUrl = jsonAdded.getString(i);
// gpodder escapes colons unnecessarily
addedUrl = addedUrl.replace("%3A", ":");
added.add(addedUrl);
}
List<String> removed = new LinkedList<>();
JSONArray jsonRemoved = object.getJSONArray("remove");
for (int i = 0; i < jsonRemoved.length(); i++) {
String removedUrl = jsonRemoved.getString(i);
// gpodder escapes colons unnecessarily
removedUrl = removedUrl.replace("%3A", ":");
removed.add(removedUrl);
}
long timestamp = object.getLong("timestamp");
return new SubscriptionChanges(added, removed, timestamp);
}
public static EpisodeActionChanges readEpisodeActionsFromJsonObject(@NonNull JSONObject object)
throws JSONException {
List<EpisodeAction> episodeActions = new ArrayList<>();
long timestamp = object.getLong("timestamp");
JSONArray jsonActions = object.getJSONArray("actions");
for (int i = 0; i < jsonActions.length(); i++) {
JSONObject jsonAction = jsonActions.getJSONObject(i);
EpisodeAction episodeAction = EpisodeAction.readFromJsonObject(jsonAction);
if (episodeAction != null) {
episodeActions.add(episodeAction);
}
}
return new EpisodeActionChanges(episodeActions, timestamp);
}
}

View File

@ -0,0 +1,107 @@
package de.danoeh.antennapod.net.sync.nextcloud;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import de.danoeh.antennapod.net.sync.HostnameParser;
import io.reactivex.Observable;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.Disposable;
import io.reactivex.schedulers.Schedulers;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
import org.json.JSONException;
import org.json.JSONObject;
import android.util.Log;
import java.io.IOException;
import java.net.URI;
import java.net.URL;
import java.util.concurrent.TimeUnit;
public class NextcloudLoginFlow {
private static final String TAG = "NextcloudLoginFlow";
private final OkHttpClient httpClient;
private final HostnameParser hostname;
private final Context context;
private final AuthenticationCallback callback;
private String token;
private String endpoint;
private Disposable startDisposable;
private Disposable pollDisposable;
public NextcloudLoginFlow(OkHttpClient httpClient, String hostUrl, Context context,
AuthenticationCallback callback) {
this.httpClient = httpClient;
this.hostname = new HostnameParser(hostUrl);
this.context = context;
this.callback = callback;
}
public void start() {
startDisposable = Observable.fromCallable(() -> {
URL url = new URI(hostname.scheme, null, hostname.host, hostname.port,
"/index.php/login/v2", null, null).toURL();
JSONObject result = doRequest(url, "");
String loginUrl = result.getString("login");
this.token = result.getJSONObject("poll").getString("token");
this.endpoint = result.getJSONObject("poll").getString("endpoint");
return loginUrl;
})
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
result -> {
Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(result));
context.startActivity(browserIntent);
poll();
}, error -> {
Log.e(TAG, Log.getStackTraceString(error));
callback.onNextcloudAuthError(error.getLocalizedMessage());
});
}
private void poll() {
pollDisposable = Observable.fromCallable(() -> doRequest(URI.create(endpoint).toURL(), "token=" + token))
.delay(1, TimeUnit.SECONDS)
.retry(60 * 10) // 10 minutes
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(result -> {
callback.onNextcloudAuthenticated(result.getString("server"),
result.getString("loginName"), result.getString("appPassword"));
}, Throwable::printStackTrace);
}
public void cancel() {
if (startDisposable != null) {
startDisposable.dispose();
}
if (pollDisposable != null) {
pollDisposable.dispose();
}
}
private JSONObject doRequest(URL url, String bodyContent) throws IOException, JSONException {
RequestBody requestBody = RequestBody.create(
MediaType.get("application/x-www-form-urlencoded"), bodyContent);
Request request = new Request.Builder().url(url).method("POST", requestBody).build();
Response response = httpClient.newCall(request).execute();
if (response.code() != 200) {
throw new IOException("Return code " + response.code());
}
ResponseBody body = response.body();
return new JSONObject(body.string());
}
public interface AuthenticationCallback {
void onNextcloudAuthenticated(String server, String username, String password);
void onNextcloudAuthError(String errorMessage);
}
}

View File

@ -0,0 +1,169 @@
package de.danoeh.antennapod.net.sync.nextcloud;
import de.danoeh.antennapod.net.sync.HostnameParser;
import de.danoeh.antennapod.net.sync.gpoddernet.mapper.ResponseMapper;
import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetUploadChangesResponse;
import de.danoeh.antennapod.net.sync.model.EpisodeAction;
import de.danoeh.antennapod.net.sync.model.EpisodeActionChanges;
import de.danoeh.antennapod.net.sync.model.ISyncService;
import de.danoeh.antennapod.net.sync.model.SubscriptionChanges;
import de.danoeh.antennapod.net.sync.model.SyncServiceException;
import de.danoeh.antennapod.net.sync.model.UploadChangesResponse;
import okhttp3.Credentials;
import okhttp3.HttpUrl;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.net.MalformedURLException;
import java.util.HashMap;
import java.util.List;
public class NextcloudSyncService implements ISyncService {
private static final int UPLOAD_BULK_SIZE = 30;
private final OkHttpClient httpClient;
private final String baseScheme;
private final int basePort;
private final String baseHost;
private final String username;
private final String password;
public NextcloudSyncService(OkHttpClient httpClient, String baseHosturl,
String username, String password) {
this.httpClient = httpClient;
this.username = username;
this.password = password;
HostnameParser hostname = new HostnameParser(baseHosturl);
this.baseHost = hostname.host;
this.basePort = hostname.port;
this.baseScheme = hostname.scheme;
}
@Override
public void login() {
}
@Override
public SubscriptionChanges getSubscriptionChanges(long lastSync) throws SyncServiceException {
try {
HttpUrl.Builder url = makeUrl("/index.php/apps/gpoddersync/subscriptions");
url.addQueryParameter("since", "" + lastSync);
String responseString = performRequest(url, "GET", null);
JSONObject json = new JSONObject(responseString);
return ResponseMapper.readSubscriptionChangesFromJsonObject(json);
} catch (JSONException | MalformedURLException e) {
e.printStackTrace();
throw new SyncServiceException(e);
} catch (Exception e) {
e.printStackTrace();
throw new SyncServiceException(e);
}
}
@Override
public UploadChangesResponse uploadSubscriptionChanges(List<String> addedFeeds,
List<String> removedFeeds)
throws NextcloudSynchronizationServiceException {
try {
HttpUrl.Builder url = makeUrl("/index.php/apps/gpoddersync/subscription_change/create");
final JSONObject requestObject = new JSONObject();
requestObject.put("add", new JSONArray(addedFeeds));
requestObject.put("remove", new JSONArray(removedFeeds));
RequestBody requestBody = RequestBody.create(
MediaType.get("application/json"), requestObject.toString());
performRequest(url, "POST", requestBody);
} catch (Exception e) {
e.printStackTrace();
throw new NextcloudSynchronizationServiceException(e);
}
return new GpodnetUploadChangesResponse(System.currentTimeMillis() / 1000, new HashMap<>());
}
@Override
public EpisodeActionChanges getEpisodeActionChanges(long timestamp) throws SyncServiceException {
try {
HttpUrl.Builder uri = makeUrl("/index.php/apps/gpoddersync/episode_action");
uri.addQueryParameter("since", "" + timestamp);
String responseString = performRequest(uri, "GET", null);
JSONObject json = new JSONObject(responseString);
return ResponseMapper.readEpisodeActionsFromJsonObject(json);
} catch (JSONException | MalformedURLException e) {
e.printStackTrace();
throw new SyncServiceException(e);
} catch (Exception e) {
e.printStackTrace();
throw new SyncServiceException(e);
}
}
@Override
public UploadChangesResponse uploadEpisodeActions(List<EpisodeAction> queuedEpisodeActions)
throws NextcloudSynchronizationServiceException {
for (int i = 0; i < queuedEpisodeActions.size(); i += UPLOAD_BULK_SIZE) {
uploadEpisodeActionsPartial(queuedEpisodeActions,
i, Math.min(queuedEpisodeActions.size(), i + UPLOAD_BULK_SIZE));
}
return new NextcloudGpodderEpisodeActionPostResponse(System.currentTimeMillis() / 1000);
}
private void uploadEpisodeActionsPartial(List<EpisodeAction> queuedEpisodeActions, int from, int to)
throws NextcloudSynchronizationServiceException {
try {
final JSONArray list = new JSONArray();
for (int i = from; i < to; i++) {
EpisodeAction episodeAction = queuedEpisodeActions.get(i);
JSONObject obj = episodeAction.writeToJsonObject();
if (obj != null) {
list.put(obj);
}
}
HttpUrl.Builder url = makeUrl("/index.php/apps/gpoddersync/episode_action/create");
RequestBody requestBody = RequestBody.create(
MediaType.get("application/json"), list.toString());
performRequest(url, "POST", requestBody);
} catch (Exception e) {
e.printStackTrace();
throw new NextcloudSynchronizationServiceException(e);
}
}
private String performRequest(HttpUrl.Builder url, String method, RequestBody body) throws IOException {
Request request = new Request.Builder()
.url(url.build())
.header("Authorization", Credentials.basic(username, password))
.header("Accept", "application/json")
.method(method, body)
.build();
Response response = httpClient.newCall(request).execute();
if (response.code() != 200) {
throw new IOException("Response code: " + response.code());
}
return response.body().string();
}
private HttpUrl.Builder makeUrl(String path) {
return new HttpUrl.Builder()
.scheme(baseScheme)
.host(baseHost)
.port(basePort)
.addPathSegments(path);
}
@Override
public void logout() {
}
private static class NextcloudGpodderEpisodeActionPostResponse extends UploadChangesResponse {
public NextcloudGpodderEpisodeActionPostResponse(long epochSecond) {
super(epochSecond);
}
}
}

View File

@ -0,0 +1,9 @@
package de.danoeh.antennapod.net.sync.nextcloud;
import de.danoeh.antennapod.net.sync.model.SyncServiceException;
public class NextcloudSynchronizationServiceException extends SyncServiceException {
public NextcloudSynchronizationServiceException(Throwable e) {
super(e);
}
}