package de.danoeh.antennapod.activity;
import android.app.Dialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.LightingColorFilter;
import android.os.Bundle;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.TextUtils;
import android.text.style.ForegroundColorSpan;
import android.util.Log;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.NavUtils;
import com.bumptech.glide.Glide;
import com.bumptech.glide.request.RequestOptions;
import com.google.android.material.snackbar.Snackbar;
import de.danoeh.antennapod.R;
import de.danoeh.antennapod.adapter.FeedItemlistDescriptionAdapter;
import de.danoeh.antennapod.core.preferences.ThemeSwitcher;
import de.danoeh.antennapod.core.service.download.DownloadRequestCreator;
import de.danoeh.antennapod.core.feed.FeedUrlNotFoundException;
import de.danoeh.antennapod.core.storage.DBTasks;
import de.danoeh.antennapod.core.service.playback.PlaybackServiceInterface;
import de.danoeh.antennapod.core.util.DownloadErrorLabel;
import de.danoeh.antennapod.event.EpisodeDownloadEvent;
import de.danoeh.antennapod.event.FeedListUpdateEvent;
import de.danoeh.antennapod.event.PlayerStatusEvent;
import de.danoeh.antennapod.core.preferences.PlaybackPreferences;
import de.danoeh.antennapod.net.download.serviceinterface.DownloadServiceInterface;
import de.danoeh.antennapod.storage.preferences.UserPreferences;
import de.danoeh.antennapod.net.download.serviceinterface.DownloadRequest;
import de.danoeh.antennapod.model.download.DownloadResult;
import de.danoeh.antennapod.core.service.download.Downloader;
import de.danoeh.antennapod.core.service.download.HttpDownloader;
import de.danoeh.antennapod.core.storage.DBReader;
import de.danoeh.antennapod.core.storage.DBWriter;
import de.danoeh.antennapod.net.discovery.CombinedSearcher;
import de.danoeh.antennapod.net.discovery.PodcastSearchResult;
import de.danoeh.antennapod.net.discovery.PodcastSearcherRegistry;
import de.danoeh.antennapod.parser.feed.FeedHandler;
import de.danoeh.antennapod.parser.feed.FeedHandlerResult;
import de.danoeh.antennapod.model.download.DownloadError;
import de.danoeh.antennapod.core.util.IntentUtils;
import de.danoeh.antennapod.net.common.UrlChecker;
import de.danoeh.antennapod.core.util.syndication.FeedDiscoverer;
import de.danoeh.antennapod.core.util.syndication.HtmlToPlainText;
import de.danoeh.antennapod.databinding.OnlinefeedviewActivityBinding;
import de.danoeh.antennapod.dialog.AuthenticationDialog;
import de.danoeh.antennapod.model.feed.Feed;
import de.danoeh.antennapod.model.feed.FeedPreferences;
import de.danoeh.antennapod.model.playback.RemoteMedia;
import de.danoeh.antennapod.parser.feed.UnsupportedFeedtypeException;
import de.danoeh.antennapod.ui.common.ThemeUtils;
import de.danoeh.antennapod.ui.glide.FastBlurTransformation;
import io.reactivex.Maybe;
import io.reactivex.Observable;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.Disposable;
import io.reactivex.observers.DisposableMaybeObserver;
import io.reactivex.schedulers.Schedulers;
import org.apache.commons.lang3.StringUtils;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Downloads a feed from a feed URL and parses it. Subclasses can display the
* feed object that was parsed. This activity MUST be started with a given URL
* or an Exception will be thrown.
*
* If the feed cannot be downloaded or parsed, an error dialog will be displayed
* and the activity will finish as soon as the error dialog is closed.
*/
public class OnlineFeedViewActivity extends AppCompatActivity {
public static final String ARG_FEEDURL = "arg.feedurl";
// Optional argument: specify a title for the actionbar.
private static final int RESULT_ERROR = 2;
private static final String TAG = "OnlineFeedViewActivity";
private static final String PREFS = "OnlineFeedViewActivityPreferences";
private static final String PREF_LAST_AUTO_DOWNLOAD = "lastAutoDownload";
private volatile List feeds;
private String selectedDownloadUrl;
private Downloader downloader;
private String username = null;
private String password = null;
private boolean isPaused;
private boolean didPressSubscribe = false;
private boolean isFeedFoundBySearch = false;
private Dialog dialog;
private Disposable download;
private Disposable parser;
private Disposable updater;
private OnlinefeedviewActivityBinding viewBinding;
@Override
protected void onCreate(Bundle savedInstanceState) {
setTheme(ThemeSwitcher.getTranslucentTheme(this));
super.onCreate(savedInstanceState);
viewBinding = OnlinefeedviewActivityBinding.inflate(getLayoutInflater());
setContentView(viewBinding.getRoot());
viewBinding.transparentBackground.setOnClickListener(v -> finish());
viewBinding.card.setOnClickListener(null);
viewBinding.card.setCardBackgroundColor(ThemeUtils.getColorFromAttr(this, R.attr.colorSurface));
String feedUrl = null;
if (getIntent().hasExtra(ARG_FEEDURL)) {
feedUrl = getIntent().getStringExtra(ARG_FEEDURL);
} else if (TextUtils.equals(getIntent().getAction(), Intent.ACTION_SEND)) {
feedUrl = getIntent().getStringExtra(Intent.EXTRA_TEXT);
} else if (TextUtils.equals(getIntent().getAction(), Intent.ACTION_VIEW)) {
feedUrl = getIntent().getDataString();
}
if (feedUrl == null) {
Log.e(TAG, "feedUrl is null.");
showNoPodcastFoundError();
} else {
Log.d(TAG, "Activity was started with url " + feedUrl);
setLoadingLayout();
// Remove subscribeonandroid.com from feed URL in order to subscribe to the actual feed URL
if (feedUrl.contains("subscribeonandroid.com")) {
feedUrl = feedUrl.replaceFirst("((www.)?(subscribeonandroid.com/))", "");
}
if (savedInstanceState != null) {
username = savedInstanceState.getString("username");
password = savedInstanceState.getString("password");
}
lookupUrlAndDownload(feedUrl);
}
}
private void showNoPodcastFoundError() {
runOnUiThread(() -> new MaterialAlertDialogBuilder(OnlineFeedViewActivity.this)
.setNeutralButton(android.R.string.ok, (dialog, which) -> finish())
.setTitle(R.string.error_label)
.setMessage(R.string.null_value_podcast_error)
.setOnDismissListener(dialog1 -> {
setResult(RESULT_ERROR);
finish();
})
.show());
}
/**
* Displays a progress indicator.
*/
private void setLoadingLayout() {
viewBinding.progressBar.setVisibility(View.VISIBLE);
viewBinding.feedDisplayContainer.setVisibility(View.GONE);
}
@Override
protected void onStart() {
super.onStart();
isPaused = false;
EventBus.getDefault().register(this);
}
@Override
protected void onStop() {
super.onStop();
isPaused = true;
EventBus.getDefault().unregister(this);
if (downloader != null && !downloader.isFinished()) {
downloader.cancel();
}
if(dialog != null && dialog.isShowing()) {
dialog.dismiss();
}
}
@Override
public void onDestroy() {
super.onDestroy();
if(updater != null) {
updater.dispose();
}
if(download != null) {
download.dispose();
}
if(parser != null) {
parser.dispose();
}
}
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putString("username", username);
outState.putString("password", password);
}
private void resetIntent(String url) {
Intent intent = new Intent();
intent.putExtra(ARG_FEEDURL, url);
setIntent(intent);
}
@Override
public void finish() {
super.finish();
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == android.R.id.home) {
Intent destIntent = new Intent(this, MainActivity.class);
if (NavUtils.shouldUpRecreateTask(this, destIntent)) {
startActivity(destIntent);
} else {
NavUtils.navigateUpFromSameTask(this);
}
return true;
}
return super.onOptionsItemSelected(item);
}
private void lookupUrlAndDownload(String url) {
download = PodcastSearcherRegistry.lookupUrl(url)
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
.subscribe(this::startFeedDownload,
error -> {
if (error instanceof FeedUrlNotFoundException) {
tryToRetrieveFeedUrlBySearch((FeedUrlNotFoundException) error);
} else {
showNoPodcastFoundError();
Log.e(TAG, Log.getStackTraceString(error));
}
});
}
private void tryToRetrieveFeedUrlBySearch(FeedUrlNotFoundException error) {
Log.d(TAG, "Unable to retrieve feed url, trying to retrieve feed url from search");
String url = searchFeedUrlByTrackName(error.getTrackName(), error.getArtistName());
if (url != null) {
Log.d(TAG, "Successfully retrieve feed url");
isFeedFoundBySearch = true;
startFeedDownload(url);
} else {
showNoPodcastFoundError();
Log.d(TAG, "Failed to retrieve feed url");
}
}
private String searchFeedUrlByTrackName(String trackName, String artistName) {
CombinedSearcher searcher = new CombinedSearcher();
String query = trackName + " " + artistName;
List results = searcher.search(query).blockingGet();
for (PodcastSearchResult result : results) {
if (result.feedUrl != null && result.author != null
&& result.author.equalsIgnoreCase(artistName) && result.title.equalsIgnoreCase(trackName)) {
return result.feedUrl;
}
}
return null;
}
private void startFeedDownload(String url) {
Log.d(TAG, "Starting feed download");
selectedDownloadUrl = UrlChecker.prepareUrl(url);
DownloadRequest request = DownloadRequestCreator.create(new Feed(selectedDownloadUrl, null))
.withAuthentication(username, password)
.withInitiatedByUser(true)
.build();
download = Observable.fromCallable(() -> {
feeds = DBReader.getFeedList();
downloader = new HttpDownloader(request);
downloader.call();
return downloader.getResult();
})
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(status -> checkDownloadResult(status, request.getDestination()),
error -> Log.e(TAG, Log.getStackTraceString(error)));
}
private void checkDownloadResult(@NonNull DownloadResult status, String destination) {
if (status.isSuccessful()) {
parseFeed(destination);
} else if (status.getReason() == DownloadError.ERROR_UNAUTHORIZED) {
if (!isFinishing() && !isPaused) {
if (username != null && password != null) {
Toast.makeText(this, R.string.download_error_unauthorized, Toast.LENGTH_LONG).show();
}
dialog = new FeedViewAuthenticationDialog(OnlineFeedViewActivity.this,
R.string.authentication_notification_title,
downloader.getDownloadRequest().getSource()).create();
dialog.show();
}
} else {
showErrorDialog(getString(DownloadErrorLabel.from(status.getReason())), status.getReasonDetailed());
}
}
@Subscribe
public void onFeedListChanged(FeedListUpdateEvent event) {
updater = Observable.fromCallable(DBReader::getFeedList)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
feeds -> {
OnlineFeedViewActivity.this.feeds = feeds;
handleUpdatedFeedStatus();
}, error -> Log.e(TAG, Log.getStackTraceString(error))
);
}
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
public void onEventMainThread(EpisodeDownloadEvent event) {
handleUpdatedFeedStatus();
}
private void parseFeed(String destination) {
Log.d(TAG, "Parsing feed");
parser = Maybe.fromCallable(() -> doParseFeed(destination))
.subscribeOn(Schedulers.computation())
.observeOn(AndroidSchedulers.mainThread())
.subscribeWith(new DisposableMaybeObserver() {
@Override
public void onSuccess(@NonNull FeedHandlerResult result) {
showFeedInformation(result.feed, result.alternateFeedUrls);
}
@Override
public void onComplete() {
// Ignore null result: We showed the discovery dialog.
}
@Override
public void onError(@NonNull Throwable error) {
showErrorDialog(error.getMessage(), "");
Log.d(TAG, "Feed parser exception: " + Log.getStackTraceString(error));
}
});
}
/**
* Try to parse the feed.
* @return The FeedHandlerResult if successful.
* Null if unsuccessful but we started another attempt.
* @throws Exception If unsuccessful but we do not know a resolution.
*/
@Nullable
private FeedHandlerResult doParseFeed(String destination) throws Exception {
FeedHandler handler = new FeedHandler();
Feed feed = new Feed(selectedDownloadUrl, null);
feed.setFile_url(destination);
File destinationFile = new File(destination);
try {
return handler.parseFeed(feed);
} catch (UnsupportedFeedtypeException e) {
Log.d(TAG, "Unsupported feed type detected");
if ("html".equalsIgnoreCase(e.getRootElement())) {
boolean dialogShown = showFeedDiscoveryDialog(destinationFile, selectedDownloadUrl);
if (dialogShown) {
return null; // Should not display an error message
} else {
throw new UnsupportedFeedtypeException(getString(R.string.download_error_unsupported_type_html));
}
} else {
throw e;
}
} catch (Exception e) {
Log.e(TAG, Log.getStackTraceString(e));
throw e;
} finally {
boolean rc = destinationFile.delete();
Log.d(TAG, "Deleted feed source file. Result: " + rc);
}
}
/**
* Called when feed parsed successfully.
* This method is executed on the GUI thread.
*/
private void showFeedInformation(final Feed feed, Map alternateFeedUrls) {
viewBinding.progressBar.setVisibility(View.GONE);
viewBinding.feedDisplayContainer.setVisibility(View.VISIBLE);
if (isFeedFoundBySearch) {
int resId = R.string.no_feed_url_podcast_found_by_search;
Snackbar.make(findViewById(android.R.id.content), resId, Snackbar.LENGTH_LONG).show();
}
viewBinding.backgroundImage.setColorFilter(new LightingColorFilter(0xff828282, 0x000000));
View header = View.inflate(this, R.layout.onlinefeedview_header, null);
viewBinding.listView.addHeaderView(header);
viewBinding.listView.setSelector(android.R.color.transparent);
viewBinding.listView.setAdapter(new FeedItemlistDescriptionAdapter(this, 0, feed.getItems()));
TextView description = header.findViewById(R.id.txtvDescription);
if (StringUtils.isNotBlank(feed.getImageUrl())) {
Glide.with(this)
.load(feed.getImageUrl())
.apply(new RequestOptions()
.placeholder(R.color.light_gray)
.error(R.color.light_gray)
.fitCenter()
.dontAnimate())
.into(viewBinding.coverImage);
Glide.with(this)
.load(feed.getImageUrl())
.apply(new RequestOptions()
.placeholder(R.color.image_readability_tint)
.error(R.color.image_readability_tint)
.transform(new FastBlurTransformation())
.dontAnimate())
.into(viewBinding.backgroundImage);
}
viewBinding.titleLabel.setText(feed.getTitle());
viewBinding.authorLabel.setText(feed.getAuthor());
description.setText(HtmlToPlainText.getPlainText(feed.getDescription()));
viewBinding.subscribeButton.setOnClickListener(v -> {
if (feedInFeedlist()) {
openFeed();
} else {
DBTasks.updateFeed(this, feed, false);
didPressSubscribe = true;
handleUpdatedFeedStatus();
}
});
viewBinding.stopPreviewButton.setOnClickListener(v -> {
PlaybackPreferences.writeNoMediaPlaying();
IntentUtils.sendLocalBroadcast(this, PlaybackServiceInterface.ACTION_SHUTDOWN_PLAYBACK_SERVICE);
});
if (UserPreferences.isEnableAutodownload()) {
SharedPreferences preferences = getSharedPreferences(PREFS, MODE_PRIVATE);
viewBinding.autoDownloadCheckBox.setChecked(preferences.getBoolean(PREF_LAST_AUTO_DOWNLOAD, true));
}
final int MAX_LINES_COLLAPSED = 10;
description.setMaxLines(MAX_LINES_COLLAPSED);
description.setOnClickListener(v -> {
if (description.getMaxLines() > MAX_LINES_COLLAPSED) {
description.setMaxLines(MAX_LINES_COLLAPSED);
} else {
description.setMaxLines(2000);
}
});
if (alternateFeedUrls.isEmpty()) {
viewBinding.alternateUrlsSpinner.setVisibility(View.GONE);
} else {
viewBinding.alternateUrlsSpinner.setVisibility(View.VISIBLE);
final List alternateUrlsList = new ArrayList<>();
final List alternateUrlsTitleList = new ArrayList<>();
alternateUrlsList.add(feed.getDownload_url());
alternateUrlsTitleList.add(feed.getTitle());
alternateUrlsList.addAll(alternateFeedUrls.keySet());
for (String url : alternateFeedUrls.keySet()) {
alternateUrlsTitleList.add(alternateFeedUrls.get(url));
}
ArrayAdapter adapter = new ArrayAdapter(this,
R.layout.alternate_urls_item, alternateUrlsTitleList) {
@Override
public View getDropDownView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
// reusing the old view causes a visual bug on Android <= 10
return super.getDropDownView(position, null, parent);
}
};
adapter.setDropDownViewResource(R.layout.alternate_urls_dropdown_item);
viewBinding.alternateUrlsSpinner.setAdapter(adapter);
viewBinding.alternateUrlsSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView> parent, View view, int position, long id) {
selectedDownloadUrl = alternateUrlsList.get(position);
}
@Override
public void onNothingSelected(AdapterView> parent) {
}
});
}
handleUpdatedFeedStatus();
}
private void openFeed() {
// feed.getId() is always 0, we have to retrieve the id from the feed list from
// the database
Intent intent = MainActivity.getIntentToOpenFeed(this, getFeedId());
intent.putExtra(MainActivity.EXTRA_STARTED_FROM_SEARCH,
getIntent().getBooleanExtra(MainActivity.EXTRA_STARTED_FROM_SEARCH, false));
finish();
startActivity(intent);
}
private void handleUpdatedFeedStatus() {
if (DownloadServiceInterface.get().isDownloadingEpisode(selectedDownloadUrl)) {
viewBinding.subscribeButton.setEnabled(false);
viewBinding.subscribeButton.setText(R.string.subscribing_label);
} else if (feedInFeedlist()) {
viewBinding.subscribeButton.setEnabled(true);
viewBinding.subscribeButton.setText(R.string.open_podcast);
if (didPressSubscribe) {
didPressSubscribe = false;
if (UserPreferences.isEnableAutodownload()) {
boolean autoDownload = viewBinding.autoDownloadCheckBox.isChecked();
Feed feed1 = DBReader.getFeed(getFeedId());
FeedPreferences feedPreferences = feed1.getPreferences();
feedPreferences.setAutoDownload(autoDownload);
DBWriter.setFeedPreferences(feedPreferences);
SharedPreferences preferences = getSharedPreferences(PREFS, MODE_PRIVATE);
SharedPreferences.Editor editor = preferences.edit();
editor.putBoolean(PREF_LAST_AUTO_DOWNLOAD, autoDownload);
editor.apply();
}
openFeed();
}
} else {
viewBinding.subscribeButton.setEnabled(true);
viewBinding.subscribeButton.setText(R.string.subscribe_label);
if (UserPreferences.isEnableAutodownload()) {
viewBinding.autoDownloadCheckBox.setVisibility(View.VISIBLE);
}
}
}
private boolean feedInFeedlist() {
return getFeedId() != 0;
}
private long getFeedId() {
if (feeds == null) {
return 0;
}
for (Feed f : feeds) {
if (f.getDownload_url().equals(selectedDownloadUrl)) {
return f.getId();
}
}
return 0;
}
@UiThread
private void showErrorDialog(String errorMsg, String details) {
if (!isFinishing() && !isPaused) {
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
builder.setTitle(R.string.error_label);
if (errorMsg != null) {
String total = errorMsg + "\n\n" + details;
SpannableString errorMessage = new SpannableString(total);
errorMessage.setSpan(new ForegroundColorSpan(0x88888888),
errorMsg.length(), total.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.setMessage(errorMessage);
} else {
builder.setMessage(R.string.download_error_error_unknown);
}
builder.setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.cancel());
builder.setOnDismissListener(dialog -> {
setResult(RESULT_ERROR);
finish();
});
if (dialog != null && dialog.isShowing()) {
dialog.dismiss();
}
dialog = builder.show();
}
}
@Subscribe(threadMode = ThreadMode.MAIN)
public void playbackStateChanged(PlayerStatusEvent event) {
boolean isPlayingPreview =
PlaybackPreferences.getCurrentlyPlayingMediaType() == RemoteMedia.PLAYABLE_TYPE_REMOTE_MEDIA;
viewBinding.stopPreviewButton.setVisibility(isPlayingPreview ? View.VISIBLE : View.GONE);
}
/**
*
* @return true if a FeedDiscoveryDialog is shown, false otherwise (e.g., due to no feed found).
*/
private boolean showFeedDiscoveryDialog(File feedFile, String baseUrl) {
FeedDiscoverer fd = new FeedDiscoverer();
final Map urlsMap;
try {
urlsMap = fd.findLinks(feedFile, baseUrl);
if (urlsMap == null || urlsMap.isEmpty()) {
return false;
}
} catch (IOException e) {
e.printStackTrace();
return false;
}
if (isPaused || isFinishing()) {
return false;
}
final List titles = new ArrayList<>();
final List urls = new ArrayList<>(urlsMap.keySet());
for (String url : urls) {
titles.add(urlsMap.get(url));
}
if (urls.size() == 1) {
// Skip dialog and display the item directly
resetIntent(urls.get(0));
startFeedDownload(urls.get(0));
return true;
}
final ArrayAdapter adapter = new ArrayAdapter<>(OnlineFeedViewActivity.this,
R.layout.ellipsize_start_listitem, R.id.txtvTitle, titles);
DialogInterface.OnClickListener onClickListener = (dialog, which) -> {
String selectedUrl = urls.get(which);
dialog.dismiss();
resetIntent(selectedUrl);
startFeedDownload(selectedUrl);
};
MaterialAlertDialogBuilder ab = new MaterialAlertDialogBuilder(OnlineFeedViewActivity.this)
.setTitle(R.string.feeds_label)
.setCancelable(true)
.setOnCancelListener(dialog -> finish())
.setAdapter(adapter, onClickListener);
runOnUiThread(() -> {
if(dialog != null && dialog.isShowing()) {
dialog.dismiss();
}
dialog = ab.show();
});
return true;
}
private class FeedViewAuthenticationDialog extends AuthenticationDialog {
private final String feedUrl;
FeedViewAuthenticationDialog(Context context, int titleRes, String feedUrl) {
super(context, titleRes, true, username, password);
this.feedUrl = feedUrl;
}
@Override
protected void onCancelled() {
super.onCancelled();
finish();
}
@Override
protected void onConfirmed(String username, String password) {
OnlineFeedViewActivity.this.username = username;
OnlineFeedViewActivity.this.password = password;
startFeedDownload(feedUrl);
}
}
}