diff options
41 files changed, 1526 insertions, 764 deletions
diff --git a/app/src/main/java/de/danoeh/antennapod/activity/PreferenceActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/PreferenceActivity.java index 600204554..1fc16ab32 100644 --- a/app/src/main/java/de/danoeh/antennapod/activity/PreferenceActivity.java +++ b/app/src/main/java/de/danoeh/antennapod/activity/PreferenceActivity.java @@ -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) { diff --git a/app/src/main/java/de/danoeh/antennapod/discovery/GpodnetPodcastSearcher.java b/app/src/main/java/de/danoeh/antennapod/discovery/GpodnetPodcastSearcher.java index f97c1c7ab..340783208 100644 --- a/app/src/main/java/de/danoeh/antennapod/discovery/GpodnetPodcastSearcher.java +++ b/app/src/main/java/de/danoeh/antennapod/discovery/GpodnetPodcastSearcher.java @@ -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) { diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/PodcastListFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/PodcastListFragment.java index c813cbf7a..af502ce13 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/PodcastListFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/PodcastListFragment.java @@ -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()) diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/TagListFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/TagListFragment.java index f961e30bb..abdfab941 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/TagListFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/TagListFragment.java @@ -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()) diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/GpodderPreferencesFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/GpodderPreferencesFragment.java deleted file mode 100644 index 4fb734e17..000000000 --- a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/GpodderPreferencesFragment.java +++ /dev/null @@ -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); - } -} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/MainPreferencesFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/MainPreferencesFragment.java index cc09acbca..83ad3110a 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/MainPreferencesFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/MainPreferencesFragment.java @@ -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) diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/NotificationPreferencesFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/NotificationPreferencesFragment.java index 94e151f7a..ba17cedb2 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/NotificationPreferencesFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/NotificationPreferencesFragment.java @@ -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()); } } diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/GpodderAuthenticationFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/synchronization/GpodderAuthenticationFragment.java index c0bf3e0ea..9dfe6840c 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/GpodderAuthenticationFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/synchronization/GpodderAuthenticationFragment.java @@ -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 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.preferences.GpodnetPreferences; +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.net.sync.gpoddernet.GpodnetService; -import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetDevice; +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; -import java.util.List; -import java.util.Locale; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - /** * 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); } } diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/synchronization/NextcloudAuthenticationFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/synchronization/NextcloudAuthenticationFragment.java new file mode 100644 index 000000000..2e9260c1d --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/synchronization/NextcloudAuthenticationFragment.java @@ -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); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/synchronization/SynchronizationPreferencesFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/synchronization/SynchronizationPreferencesFragment.java new file mode 100644 index 000000000..9b63b38ec --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/synchronization/SynchronizationPreferencesFragment.java @@ -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); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/menuhandler/FeedItemMenuHandler.java b/app/src/main/java/de/danoeh/antennapod/menuhandler/FeedItemMenuHandler.java index c272af7d5..23fdb86de 100644 --- a/app/src/main/java/de/danoeh/antennapod/menuhandler/FeedItemMenuHandler.java +++ b/app/src/main/java/de/danoeh/antennapod/menuhandler/FeedItemMenuHandler.java @@ -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); diff --git a/app/src/main/res/layout/alertdialog_sync_provider_chooser.xml b/app/src/main/res/layout/alertdialog_sync_provider_chooser.xml new file mode 100644 index 000000000..9b4d62804 --- /dev/null +++ b/app/src/main/res/layout/alertdialog_sync_provider_chooser.xml @@ -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> diff --git a/app/src/main/res/layout/nextcloud_auth_dialog.xml b/app/src/main/res/layout/nextcloud_auth_dialog.xml new file mode 100644 index 000000000..345eec88b --- /dev/null +++ b/app/src/main/res/layout/nextcloud_auth_dialog.xml @@ -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> diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index d528945c7..7c5012899 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -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" /> diff --git a/app/src/main/res/xml/preferences_gpodder.xml b/app/src/main/res/xml/preferences_gpodder.xml deleted file mode 100644 index a210b8e11..000000000 --- a/app/src/main/res/xml/preferences_gpodder.xml +++ /dev/null @@ -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> diff --git a/app/src/main/res/xml/preferences_synchronization.xml b/app/src/main/res/xml/preferences_synchronization.xml new file mode 100644 index 000000000..fbd4ccc79 --- /dev/null +++ b/app/src/main/res/xml/preferences_synchronization.xml @@ -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> diff --git a/core/src/main/java/de/danoeh/antennapod/core/preferences/GpodnetPreferences.java b/core/src/main/java/de/danoeh/antennapod/core/preferences/GpodnetPreferences.java deleted file mode 100644 index e338e0d01..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/preferences/GpodnetPreferences.java +++ /dev/null @@ -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(); - } - -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java index 8c9035621..6bbd704e2 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java @@ -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); } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java index f503c16f4..848ea7cfc 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java @@ -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()); } diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java index 185d85e7a..f776fe111 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java @@ -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 { diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java index 9dd979dc7..377202c4b 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java @@ -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); } } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java index 34ea5e207..479a7763c 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java @@ -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); diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java b/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java index 85ce2dc99..55cfafbbb 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java @@ -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); } diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/GuidValidator.java b/core/src/main/java/de/danoeh/antennapod/core/sync/GuidValidator.java index 6d80a6457..74e5d5cdf 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/sync/GuidValidator.java +++ b/core/src/main/java/de/danoeh/antennapod/core/sync/GuidValidator.java @@ -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"); } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/LockingAsyncExecutor.java b/core/src/main/java/de/danoeh/antennapod/core/sync/LockingAsyncExecutor.java new file mode 100644 index 000000000..e7dbbbd3c --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/sync/LockingAsyncExecutor.java @@ -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(); + } + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/SyncService.java b/core/src/main/java/de/danoeh/antennapod/core/sync/SyncService.java index 9803a29db..8edc37ac4 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/sync/SyncService.java +++ b/core/src/main/java/de/danoeh/antennapod/core/sync/SyncService.java @@ -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,156 +51,54 @@ 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 - updateErrorNotification(e); - } - 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(); + 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(); } - 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) { @@ -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; + } + } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationCredentials.java b/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationCredentials.java new file mode 100644 index 000000000..e08bc66ad --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationCredentials.java @@ -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(); + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationProviderViewData.java b/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationProviderViewData.java new file mode 100644 index 000000000..cba713f60 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationProviderViewData.java @@ -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; + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationSettings.java b/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationSettings.java new file mode 100644 index 000000000..1a53ac0fb --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationSettings.java @@ -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); + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/queue/SynchronizationQueueSink.java b/core/src/main/java/de/danoeh/antennapod/core/sync/queue/SynchronizationQueueSink.java new file mode 100644 index 000000000..445faf60f --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/sync/queue/SynchronizationQueueSink.java @@ -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); + } + +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/queue/SynchronizationQueueStorage.java b/core/src/main/java/de/danoeh/antennapod/core/sync/queue/SynchronizationQueueStorage.java new file mode 100644 index 000000000..5c6d58fe3 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/sync/queue/SynchronizationQueueStorage.java @@ -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; + } +} diff --git a/core/src/main/res/drawable-nodpi/nextcloud_logo.png b/core/src/main/res/drawable-nodpi/nextcloud_logo.png Binary files differnew file mode 100644 index 000000000..2164e37fb --- /dev/null +++ b/core/src/main/res/drawable-nodpi/nextcloud_logo.png diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 5f509a9b6..7aa32abe3 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -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> diff --git a/core/src/test/java/de/danoeh/antennapod/core/sync/GuidValidatorTest.java b/core/src/test/java/de/danoeh/antennapod/core/sync/GuidValidatorTest.java index 356a7f77e..552f7d70a 100644 --- a/core/src/test/java/de/danoeh/antennapod/core/sync/GuidValidatorTest.java +++ b/core/src/test/java/de/danoeh/antennapod/core/sync/GuidValidatorTest.java @@ -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")); } }
\ No newline at end of file diff --git a/net/sync/gpoddernet/build.gradle b/net/sync/gpoddernet/build.gradle index eb5af1b60..77e9ce0f3 100644 --- a/net/sync/gpoddernet/build.gradle +++ b/net/sync/gpoddernet/build.gradle @@ -9,4 +9,7 @@ dependencies { implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" implementation "org.apache.commons:commons-lang3:$commonslangVersion" + implementation 'commons-io:commons-io:2.5' + implementation "io.reactivex.rxjava2:rxandroid:$rxAndroidVersion" + implementation "io.reactivex.rxjava2:rxjava:$rxJavaVersion" } diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/HostnameParser.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/HostnameParser.java new file mode 100644 index 000000000..ebb415248 --- /dev/null +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/HostnameParser.java @@ -0,0 +1,41 @@ +package de.danoeh.antennapod.net.sync; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class HostnameParser { + public String scheme; + public int port; + public String host; + + // split into schema, host and port - missing parts are null + private static final Pattern URLSPLIT_REGEX = Pattern.compile("(?:(https?)://)?([^:]+)(?::(\\d+))?"); + + public HostnameParser(String hosturl) { + Matcher m = URLSPLIT_REGEX.matcher(hosturl); + if (m.matches()) { + scheme = m.group(1); + host = m.group(2); + if (m.group(3) == null) { + port = -1; + } else { + port = Integer.parseInt(m.group(3)); // regex -> can only be digits + } + } else { + // URL does not match regex: use it anyway -> this will cause an exception on connect + scheme = "https"; + host = hosturl; + port = 443; + } + + if (scheme == null) { // assume https + scheme = "https"; + } + + if (scheme.equals("https") && port == -1) { + port = 443; + } else if (scheme.equals("http") && port == -1) { + port = 80; + } + } +} diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetService.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetService.java index eb18da80b..439a528b7 100644 --- a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetService.java +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetService.java @@ -1,25 +1,10 @@ package de.danoeh.antennapod.net.sync.gpoddernet; import android.util.Log; + import androidx.annotation.NonNull; -import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetDevice; -import de.danoeh.antennapod.net.sync.model.EpisodeAction; -import de.danoeh.antennapod.net.sync.model.EpisodeActionChanges; -import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetEpisodeActionPostResponse; -import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetPodcast; -import de.danoeh.antennapod.net.sync.model.ISyncService; -import de.danoeh.antennapod.net.sync.model.SubscriptionChanges; -import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetTag; -import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetUploadChangesResponse; -import de.danoeh.antennapod.net.sync.model.SyncServiceException; -import de.danoeh.antennapod.net.sync.model.UploadChangesResponse; -import okhttp3.Credentials; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; -import okhttp3.ResponseBody; + +import de.danoeh.antennapod.net.sync.HostnameParser; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -35,12 +20,28 @@ import java.net.URL; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.Collections; -import java.util.LinkedList; import java.util.List; import java.util.Locale; -import java.util.regex.Matcher; -import java.util.regex.Pattern; +import de.danoeh.antennapod.net.sync.gpoddernet.mapper.ResponseMapper; +import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetDevice; +import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetEpisodeActionPostResponse; +import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetPodcast; +import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetTag; +import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetUploadChangesResponse; +import de.danoeh.antennapod.net.sync.model.EpisodeAction; +import de.danoeh.antennapod.net.sync.model.EpisodeActionChanges; +import de.danoeh.antennapod.net.sync.model.ISyncService; +import de.danoeh.antennapod.net.sync.model.SubscriptionChanges; +import de.danoeh.antennapod.net.sync.model.SyncServiceException; +import de.danoeh.antennapod.net.sync.model.UploadChangesResponse; +import okhttp3.Credentials; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; /** * Communicates with the gpodder.net service. @@ -61,43 +62,16 @@ public class GpodnetService implements ISyncService { private final OkHttpClient httpClient; - // split into schema, host and port - missing parts are null - private static final Pattern URLSPLIT_REGEX = Pattern.compile("(?:(https?)://)?([^:]+)(?::(\\d+))?"); - public GpodnetService(OkHttpClient httpClient, String baseHosturl, String deviceId, String username, String password) { this.httpClient = httpClient; this.deviceId = deviceId; this.username = username; this.password = password; - - Matcher m = URLSPLIT_REGEX.matcher(baseHosturl); - if (m.matches()) { - this.baseScheme = m.group(1); - this.baseHost = m.group(2); - if (m.group(3) == null) { - this.basePort = -1; - } else { - this.basePort = Integer.parseInt(m.group(3)); // regex -> can only be digits - } - } else { - // URL does not match regex: use it anyway -> this will cause an exception on connect - this.baseScheme = "https"; - this.baseHost = baseHosturl; - this.basePort = 443; - } - - if (this.baseScheme == null) { // assume https - this.baseScheme = "https"; - } - - if (this.baseScheme.equals("https") && this.basePort == -1) { - this.basePort = 443; - } - - if (this.baseScheme.equals("http") && this.basePort == -1) { - this.basePort = 80; - } + HostnameParser hostname = new HostnameParser(baseHosturl == null ? DEFAULT_BASE_HOST : baseHosturl); + this.baseHost = hostname.host; + this.basePort = hostname.port; + this.baseScheme = hostname.scheme; } private void requireLoggedIn() { @@ -434,7 +408,7 @@ public class GpodnetService implements ISyncService { String response = executeRequest(request); JSONObject changes = new JSONObject(response); - return readSubscriptionChangesFromJsonObject(changes); + return ResponseMapper.readSubscriptionChangesFromJsonObject(changes); } catch (URISyntaxException e) { e.printStackTrace(); throw new IllegalStateException(e); @@ -515,7 +489,7 @@ public class GpodnetService implements ISyncService { String response = executeRequest(request); JSONObject json = new JSONObject(response); - return readEpisodeActionsFromJsonObject(json); + return ResponseMapper.readEpisodeActionsFromJsonObject(json); } catch (URISyntaxException e) { e.printStackTrace(); throw new IllegalStateException(e); @@ -526,7 +500,6 @@ public class GpodnetService implements ISyncService { } - /** * Logs in a specific user. This method must be called if any of the methods * that require authentication is used. @@ -689,48 +662,6 @@ public class GpodnetService implements ISyncService { return new GpodnetDevice(id, caption, type, subscriptions); } - private SubscriptionChanges readSubscriptionChangesFromJsonObject(@NonNull JSONObject object) - throws JSONException { - - List<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() { diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/mapper/ResponseMapper.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/mapper/ResponseMapper.java new file mode 100644 index 000000000..c8e607d74 --- /dev/null +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/mapper/ResponseMapper.java @@ -0,0 +1,60 @@ +package de.danoeh.antennapod.net.sync.gpoddernet.mapper; + +import androidx.annotation.NonNull; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; + +import de.danoeh.antennapod.net.sync.model.EpisodeAction; +import de.danoeh.antennapod.net.sync.model.EpisodeActionChanges; +import de.danoeh.antennapod.net.sync.model.SubscriptionChanges; + +public class ResponseMapper { + + public static SubscriptionChanges readSubscriptionChangesFromJsonObject(@NonNull JSONObject object) + throws JSONException { + + List<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); + } +} diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudLoginFlow.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudLoginFlow.java new file mode 100644 index 000000000..b66c44402 --- /dev/null +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudLoginFlow.java @@ -0,0 +1,107 @@ +package de.danoeh.antennapod.net.sync.nextcloud; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import de.danoeh.antennapod.net.sync.HostnameParser; +import io.reactivex.Observable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.json.JSONException; +import org.json.JSONObject; +import android.util.Log; + +import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.util.concurrent.TimeUnit; + +public class NextcloudLoginFlow { + private static final String TAG = "NextcloudLoginFlow"; + + private final OkHttpClient httpClient; + private final HostnameParser hostname; + private final Context context; + private final AuthenticationCallback callback; + private String token; + private String endpoint; + private Disposable startDisposable; + private Disposable pollDisposable; + + public NextcloudLoginFlow(OkHttpClient httpClient, String hostUrl, Context context, + AuthenticationCallback callback) { + this.httpClient = httpClient; + this.hostname = new HostnameParser(hostUrl); + this.context = context; + this.callback = callback; + } + + public void start() { + startDisposable = Observable.fromCallable(() -> { + URL url = new URI(hostname.scheme, null, hostname.host, hostname.port, + "/index.php/login/v2", null, null).toURL(); + JSONObject result = doRequest(url, ""); + String loginUrl = result.getString("login"); + this.token = result.getJSONObject("poll").getString("token"); + this.endpoint = result.getJSONObject("poll").getString("endpoint"); + return loginUrl; + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + result -> { + Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(result)); + context.startActivity(browserIntent); + poll(); + }, error -> { + Log.e(TAG, Log.getStackTraceString(error)); + callback.onNextcloudAuthError(error.getLocalizedMessage()); + }); + } + + private void poll() { + pollDisposable = Observable.fromCallable(() -> doRequest(URI.create(endpoint).toURL(), "token=" + token)) + .delay(1, TimeUnit.SECONDS) + .retry(60 * 10) // 10 minutes + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(result -> { + callback.onNextcloudAuthenticated(result.getString("server"), + result.getString("loginName"), result.getString("appPassword")); + }, Throwable::printStackTrace); + } + + public void cancel() { + if (startDisposable != null) { + startDisposable.dispose(); + } + if (pollDisposable != null) { + pollDisposable.dispose(); + } + } + + private JSONObject doRequest(URL url, String bodyContent) throws IOException, JSONException { + RequestBody requestBody = RequestBody.create( + MediaType.get("application/x-www-form-urlencoded"), bodyContent); + Request request = new Request.Builder().url(url).method("POST", requestBody).build(); + Response response = httpClient.newCall(request).execute(); + if (response.code() != 200) { + throw new IOException("Return code " + response.code()); + } + ResponseBody body = response.body(); + return new JSONObject(body.string()); + } + + public interface AuthenticationCallback { + void onNextcloudAuthenticated(String server, String username, String password); + + void onNextcloudAuthError(String errorMessage); + } +} diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSyncService.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSyncService.java new file mode 100644 index 000000000..647a9073c --- /dev/null +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSyncService.java @@ -0,0 +1,169 @@ +package de.danoeh.antennapod.net.sync.nextcloud; + +import de.danoeh.antennapod.net.sync.HostnameParser; +import de.danoeh.antennapod.net.sync.gpoddernet.mapper.ResponseMapper; +import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetUploadChangesResponse; +import de.danoeh.antennapod.net.sync.model.EpisodeAction; +import de.danoeh.antennapod.net.sync.model.EpisodeActionChanges; +import de.danoeh.antennapod.net.sync.model.ISyncService; +import de.danoeh.antennapod.net.sync.model.SubscriptionChanges; +import de.danoeh.antennapod.net.sync.model.SyncServiceException; +import de.danoeh.antennapod.net.sync.model.UploadChangesResponse; +import okhttp3.Credentials; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.util.HashMap; +import java.util.List; + +public class NextcloudSyncService implements ISyncService { + private static final int UPLOAD_BULK_SIZE = 30; + private final OkHttpClient httpClient; + private final String baseScheme; + private final int basePort; + private final String baseHost; + private final String username; + private final String password; + + public NextcloudSyncService(OkHttpClient httpClient, String baseHosturl, + String username, String password) { + this.httpClient = httpClient; + this.username = username; + this.password = password; + HostnameParser hostname = new HostnameParser(baseHosturl); + this.baseHost = hostname.host; + this.basePort = hostname.port; + this.baseScheme = hostname.scheme; + } + + @Override + public void login() { + } + + @Override + public SubscriptionChanges getSubscriptionChanges(long lastSync) throws SyncServiceException { + try { + HttpUrl.Builder url = makeUrl("/index.php/apps/gpoddersync/subscriptions"); + url.addQueryParameter("since", "" + lastSync); + String responseString = performRequest(url, "GET", null); + JSONObject json = new JSONObject(responseString); + return ResponseMapper.readSubscriptionChangesFromJsonObject(json); + } catch (JSONException | MalformedURLException e) { + e.printStackTrace(); + throw new SyncServiceException(e); + } catch (Exception e) { + e.printStackTrace(); + throw new SyncServiceException(e); + } + } + + @Override + public UploadChangesResponse uploadSubscriptionChanges(List<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); + } + } +} + diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSynchronizationServiceException.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSynchronizationServiceException.java new file mode 100644 index 000000000..d907c229e --- /dev/null +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSynchronizationServiceException.java @@ -0,0 +1,9 @@ +package de.danoeh.antennapod.net.sync.nextcloud; + +import de.danoeh.antennapod.net.sync.model.SyncServiceException; + +public class NextcloudSynchronizationServiceException extends SyncServiceException { + public NextcloudSynchronizationServiceException(Throwable e) { + super(e); + } +} |