diff --git a/app/build.gradle b/app/build.gradle index c1b1b30c..06770956 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -149,8 +149,8 @@ android { // Version code schema (not used): // "1.2.3-beta4" -> 1020304 // "1.2.3" -> 1020395 - versionCode 3020118 - versionName "4.4.2" + versionCode 3020119 + versionName "4.4.3" def commit = "" try { diff --git a/app/src/main/java/ac/mdiq/podcini/ui/activity/MainActivity.kt b/app/src/main/java/ac/mdiq/podcini/ui/activity/MainActivity.kt index e539afa7..d47116a7 100644 --- a/app/src/main/java/ac/mdiq/podcini/ui/activity/MainActivity.kt +++ b/app/src/main/java/ac/mdiq/podcini/ui/activity/MainActivity.kt @@ -16,7 +16,6 @@ import ac.mdiq.podcini.preferences.UserPreferences.hiddenDrawerItems import ac.mdiq.podcini.receiver.MediaButtonReceiver.Companion.createIntent import ac.mdiq.podcini.storage.DBReader import ac.mdiq.podcini.storage.model.download.DownloadStatus -import ac.mdiq.podcini.storage.model.feed.FeedItem import ac.mdiq.podcini.ui.appstartintent.MainActivityStarter import ac.mdiq.podcini.ui.common.ThemeUtils.getDrawableFromAttr import ac.mdiq.podcini.ui.dialog.RatingDialog @@ -592,10 +591,9 @@ class MainActivity : CastEnabledActivity() { } bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED) } - intent.hasExtra(EXTRA_EPISODES) -> { - val episodes = (if (Build.VERSION.SDK_INT >= 33) intent.getSerializableExtra(EXTRA_EPISODES) - else intent.getSerializableExtra(EXTRA_EPISODES)) as ArrayList - loadChildFragment(EpisodesListFragment.newInstance(episodes)) + intent.hasExtra(EXTRA_FEED_URL) -> { + val feedurl = intent.getStringExtra(EXTRA_FEED_URL) + if (feedurl != null) loadChildFragment(OnlineFeedViewFragment.newInstance(feedurl)) } intent.hasExtra(MainActivityStarter.EXTRA_FRAGMENT_TAG) -> { val tag = intent.getStringExtra(MainActivityStarter.EXTRA_FRAGMENT_TAG) @@ -736,11 +734,11 @@ class MainActivity : CastEnabledActivity() { const val PREF_IS_FIRST_LAUNCH: String = "prefMainActivityIsFirstLaunch" const val EXTRA_FEED_ID: String = "fragment_feed_id" + const val EXTRA_FEED_URL: String = "fragment_feed_url" const val EXTRA_REFRESH_ON_START: String = "refresh_on_start" const val EXTRA_STARTED_FROM_SEARCH: String = "started_from_search" const val EXTRA_ADD_TO_BACK_STACK: String = "add_to_back_stack" const val KEY_GENERATED_VIEW_ID: String = "generated_view_id" - const val EXTRA_EPISODES: String = "episodes_list" @JvmStatic fun getIntentToOpenFeed(context: Context, feedId: Long): Intent { @@ -750,12 +748,12 @@ class MainActivity : CastEnabledActivity() { return intent } - fun openEpisodesList(context: Context, episodes: ArrayList): Intent { + @JvmStatic + fun showOnlineFeed(context: Context, feedUrl: String): Intent { val intent = Intent(context.applicationContext, MainActivity::class.java) - intent.putExtra(EXTRA_EPISODES, episodes) + intent.putExtra(EXTRA_FEED_URL, feedUrl) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) return intent } - } } diff --git a/app/src/main/java/ac/mdiq/podcini/ui/activity/OnlineFeedViewActivity.kt b/app/src/main/java/ac/mdiq/podcini/ui/activity/OnlineFeedViewActivity.kt index 26851009..ff854b67 100644 --- a/app/src/main/java/ac/mdiq/podcini/ui/activity/OnlineFeedViewActivity.kt +++ b/app/src/main/java/ac/mdiq/podcini/ui/activity/OnlineFeedViewActivity.kt @@ -1,117 +1,24 @@ package ac.mdiq.podcini.ui.activity import ac.mdiq.podcini.R -import ac.mdiq.podcini.databinding.EditTextDialogBinding -import ac.mdiq.podcini.databinding.OnlinefeedviewActivityBinding -import ac.mdiq.podcini.feed.FeedUrlNotFoundException -import ac.mdiq.podcini.feed.parser.FeedHandler -import ac.mdiq.podcini.feed.parser.FeedHandlerResult -import ac.mdiq.podcini.feed.parser.UnsupportedFeedtypeException -import ac.mdiq.podcini.net.common.UrlChecker.prepareUrl -import ac.mdiq.podcini.net.discovery.CombinedSearcher -import ac.mdiq.podcini.net.discovery.PodcastSearcherRegistry -import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface -import ac.mdiq.podcini.preferences.ThemeSwitcher.getTranslucentTheme -import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownload -import ac.mdiq.podcini.service.download.DownloadRequestCreator.create -import ac.mdiq.podcini.service.download.Downloader -import ac.mdiq.podcini.service.download.HttpDownloader -import ac.mdiq.podcini.storage.DBReader -import ac.mdiq.podcini.storage.DBTasks -import ac.mdiq.podcini.storage.DBWriter -import ac.mdiq.podcini.storage.model.download.DownloadError -import ac.mdiq.podcini.storage.model.download.DownloadResult -import ac.mdiq.podcini.storage.model.feed.Feed -import ac.mdiq.podcini.storage.model.feed.FeedItem -import ac.mdiq.podcini.ui.common.ThemeUtils.getColorFromAttr -import ac.mdiq.podcini.ui.dialog.AuthenticationDialog -import ac.mdiq.podcini.ui.glide.FastBlurTransformation -import ac.mdiq.podcini.util.DownloadErrorLabel.from -import ac.mdiq.podcini.util.event.EpisodeDownloadEvent -import ac.mdiq.podcini.util.event.FeedListUpdateEvent -import ac.mdiq.podcini.util.syndication.FeedDiscoverer -import ac.mdiq.podcini.util.syndication.HtmlToPlainText -import android.app.Dialog -import android.content.Context import android.content.DialogInterface import android.content.Intent -import android.graphics.LightingColorFilter +import android.net.Uri 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.Toast -import androidx.annotation.UiThread +import androidx.annotation.OptIn import androidx.appcompat.app.AppCompatActivity -import androidx.core.app.NavUtils import androidx.media3.common.util.UnstableApi -import com.bumptech.glide.Glide -import com.bumptech.glide.request.RequestOptions import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -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.greenrobot.eventbus.EventBus -import org.greenrobot.eventbus.Subscribe -import org.greenrobot.eventbus.ThreadMode -import java.io.File -import java.io.IOException -import kotlin.concurrent.Volatile -import kotlin.math.min +import java.net.URLDecoder -/** - * 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. - */ +// this now is only used for receiving shared feed url class OnlineFeedViewActivity : AppCompatActivity() { - private var _binding: OnlinefeedviewActivityBinding? = null - private val binding get() = _binding!! - @Volatile - private var feeds: List? = null - private var selectedDownloadUrl: String? = null - private var downloader: Downloader? = null - private var username: String? = null - private var password: String? = null - - private var isPaused = false - private var didPressSubscribe = false - private var isFeedFoundBySearch = false - - private var dialog: Dialog? = null - - private var download: Disposable? = null - private var parser: Disposable? = null - private var updater: Disposable? = null - - override fun onCreate(savedInstanceState: Bundle?) { - setTheme(getTranslucentTheme(this)) + @OptIn(UnstableApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - _binding = OnlinefeedviewActivityBinding.inflate(layoutInflater) - setContentView(binding.root) - - binding.transparentBackground.setOnClickListener { finish() } - binding.closeButton.setOnClickListener { finish() } - binding.card.setOnClickListener(null) - binding.card.setCardBackgroundColor(getColorFromAttr(this, R.attr.colorSurface)) - var feedUrl: String? = null when { intent.hasExtra(ARG_FEEDURL) -> { @@ -125,21 +32,22 @@ class OnlineFeedViewActivity : AppCompatActivity() { } } + if (!feedUrl.isNullOrBlank() && !feedUrl.startsWith("http")) { + val uri = Uri.parse(feedUrl) + feedUrl = URLDecoder.decode(uri.getQueryParameter("url"), "UTF-8") + } + 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/))".toRegex(), "") - } - if (savedInstanceState != null) { - username = savedInstanceState.getString("username") - password = savedInstanceState.getString("password") - } - lookupUrlAndDownload(feedUrl) + + val intent = MainActivity.showOnlineFeed(this, feedUrl) + intent.putExtra(MainActivity.EXTRA_STARTED_FROM_SEARCH, + getIntent().getBooleanExtra(MainActivity.EXTRA_STARTED_FROM_SEARCH, false)) + startActivity(intent) + finish() } } @@ -157,530 +65,15 @@ class OnlineFeedViewActivity : AppCompatActivity() { } } - /** - * Displays a progress indicator. - */ - private fun setLoadingLayout() { - binding.progressBar.visibility = View.VISIBLE - binding.feedDisplayContainer.visibility = View.GONE - } - - override fun onStart() { - super.onStart() - isPaused = false - EventBus.getDefault().register(this) - } - - override fun onStop() { - super.onStop() - isPaused = true - EventBus.getDefault().unregister(this) - if (downloader != null && !downloader!!.isFinished) downloader!!.cancel() - - if (dialog != null && dialog!!.isShowing) dialog!!.dismiss() - } - - public override fun onDestroy() { - super.onDestroy() - _binding = null - updater?.dispose() - download?.dispose() - parser?.dispose() - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putString("username", username) - outState.putString("password", password) - } - - private fun resetIntent(url: String) { - val intent = Intent() - intent.putExtra(ARG_FEEDURL, url) - setIntent(intent) - } - override fun finish() { super.finish() overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) } - @UnstableApi override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == android.R.id.home) { - val destIntent = Intent(this, MainActivity::class.java) - if (NavUtils.shouldUpRecreateTask(this, destIntent)) { - startActivity(destIntent) - } else { - NavUtils.navigateUpFromSameTask(this) - } - return true - } - return super.onOptionsItemSelected(item) - } - - private fun lookupUrlAndDownload(url: String) { - download = PodcastSearcherRegistry.lookupUrl(url) - .subscribeOn(Schedulers.io()) - .observeOn(Schedulers.io()) - .subscribe({ url1: String -> this.startFeedDownload(url1) }, - { error: Throwable? -> - if (error is FeedUrlNotFoundException) { - tryToRetrieveFeedUrlBySearch(error) - } else { - showNoPodcastFoundError() - Log.e(TAG, Log.getStackTraceString(error)) - } - }) - } - - private fun tryToRetrieveFeedUrlBySearch(error: FeedUrlNotFoundException) { - Log.d(TAG, "Unable to retrieve feed url, trying to retrieve feed url from search") - val url = searchFeedUrlByTrackName(error.trackName, error.artistName) - 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 fun searchFeedUrlByTrackName(trackName: String, artistName: String): String? { - val searcher = CombinedSearcher() - val query = "$trackName $artistName" - val results = searcher.search(query).blockingGet() - if (results.isNullOrEmpty()) return null - for (result in results) { - if (result?.feedUrl != null && result.author != null && - result.author.equals(artistName, ignoreCase = true) && - result.title.equals(trackName, ignoreCase = true)) { - return result.feedUrl - } - } - return null - } - - private fun startFeedDownload(url: String) { - Log.d(TAG, "Starting feed download") - selectedDownloadUrl = prepareUrl(url) - val request = create(Feed(selectedDownloadUrl, null)) - .withAuthentication(username, password) - .withInitiatedByUser(true) - .build() - - download = Observable.fromCallable { - feeds = DBReader.getFeedList() - downloader = HttpDownloader(request) - downloader?.call() - downloader?.result - } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ status: DownloadResult? -> if (request.destination != null) checkDownloadResult(status, request.destination) }, - { error: Throwable? -> Log.e(TAG, Log.getStackTraceString(error)) }) - } - - private fun checkDownloadResult(status: DownloadResult?, destination: String) { - if (status == null) return - when { - status.isSuccessful -> { - parseFeed(destination) - } - status.reason == DownloadError.ERROR_UNAUTHORIZED -> { - if (!isFinishing && !isPaused) { - if (username != null && password != null) { - Toast.makeText(this, R.string.download_error_unauthorized, Toast.LENGTH_LONG).show() - } - if (downloader?.downloadRequest?.source != null) { - dialog = FeedViewAuthenticationDialog(this@OnlineFeedViewActivity, - R.string.authentication_notification_title, downloader!!.downloadRequest.source!!).create() - dialog?.show() - } - } - } - else -> { - showErrorDialog(getString(from(status.reason)), status.reasonDetailed) - } - } - } - - @UnstableApi @Subscribe - fun onFeedListChanged(event: FeedListUpdateEvent?) { - updater = Observable.fromCallable { DBReader.getFeedList() } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { feeds: List? -> - this@OnlineFeedViewActivity.feeds = feeds - handleUpdatedFeedStatus() - }, { error: Throwable? -> Log.e(TAG, Log.getStackTraceString(error)) } - ) - } - - @UnstableApi @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) - fun onEventMainThread(event: EpisodeDownloadEvent?) { - handleUpdatedFeedStatus() - } - - private fun parseFeed(destination: String) { - Log.d(TAG, "Parsing feed") - parser = Maybe.fromCallable { doParseFeed(destination) } - .subscribeOn(Schedulers.computation()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeWith(object : DisposableMaybeObserver() { - @UnstableApi override fun onSuccess(result: FeedHandlerResult) { - showFeedInformation(result.feed, result.alternateFeedUrls) - } - - override fun onComplete() { - // Ignore null result: We showed the discovery dialog. - } - - override fun onError(error: Throwable) { - showErrorDialog(error.message, "") - 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. - */ - @Throws(Exception::class) - private fun doParseFeed(destination: String): FeedHandlerResult? { - val handler = FeedHandler() - val feed = Feed(selectedDownloadUrl, null) - feed.file_url = destination - val destinationFile = File(destination) - return try { - handler.parseFeed(feed) - } catch (e: UnsupportedFeedtypeException) { - Log.d(TAG, "Unsupported feed type detected") - if ("html".equals(e.rootElement, ignoreCase = true)) { - if (selectedDownloadUrl != null) { - val dialogShown = showFeedDiscoveryDialog(destinationFile, selectedDownloadUrl!!) - if (dialogShown) { - null // Should not display an error message - } else { - throw UnsupportedFeedtypeException(getString(R.string.download_error_unsupported_type_html)) - } - } else null - } else { - throw e - } - } catch (e: Exception) { - Log.e(TAG, Log.getStackTraceString(e)) - throw e - } finally { - val 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. - */ - @UnstableApi private fun showFeedInformation(feed: Feed, alternateFeedUrls: Map) { - binding.progressBar.visibility = View.GONE - binding.feedDisplayContainer.visibility = View.VISIBLE - if (isFeedFoundBySearch) { - val resId = R.string.no_feed_url_podcast_found_by_search - Snackbar.make(binding.root, resId, Snackbar.LENGTH_LONG).show() - } - - binding.backgroundImage.colorFilter = LightingColorFilter(-0x7d7d7e, 0x000000) - - binding.episodeLabel.setOnClickListener { showEpisodes(feed.items)} - - if (!feed.imageUrl.isNullOrBlank()) { - Glide.with(this) - .load(feed.imageUrl) - .apply(RequestOptions() - .placeholder(R.color.light_gray) - .error(R.color.light_gray) - .fitCenter() - .dontAnimate()) - .into(binding.coverImage) - Glide.with(this) - .load(feed.imageUrl) - .apply(RequestOptions() - .placeholder(R.color.image_readability_tint) - .error(R.color.image_readability_tint) - .transform(FastBlurTransformation()) - .dontAnimate()) - .into(binding.backgroundImage) - } - - binding.titleLabel.text = feed.title - binding.authorLabel.text = feed.author - - binding.txtvDescription.text = HtmlToPlainText.getPlainText(feed.description?:"") - - binding.subscribeButton.setOnClickListener { - if (feedInFeedlist()) { - openFeed() - } else { - DBTasks.updateFeed(this, feed, false) - didPressSubscribe = true - handleUpdatedFeedStatus() - } - } - - if (isEnableAutodownload) { - val preferences = getSharedPreferences(PREFS, MODE_PRIVATE) - binding.autoDownloadCheckBox.isChecked = preferences.getBoolean(PREF_LAST_AUTO_DOWNLOAD, true) - } - - if (alternateFeedUrls.isEmpty()) { - binding.alternateUrlsSpinner.visibility = View.GONE - } else { - binding.alternateUrlsSpinner.visibility = View.VISIBLE - - val alternateUrlsList: MutableList = ArrayList() - val alternateUrlsTitleList: MutableList = ArrayList() - - if (feed.download_url != null) alternateUrlsList.add(feed.download_url!!) - alternateUrlsTitleList.add(feed.title) - - alternateUrlsList.addAll(alternateFeedUrls.keys) - for (url in alternateFeedUrls.keys) { - alternateUrlsTitleList.add(alternateFeedUrls[url]) - } - - val adapter: ArrayAdapter = object : ArrayAdapter(this, - R.layout.alternate_urls_item, alternateUrlsTitleList) { - override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { - // 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) - binding.alternateUrlsSpinner.adapter = adapter - binding.alternateUrlsSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View, position: Int, id: Long) { - selectedDownloadUrl = alternateUrlsList[position] - } - - override fun onNothingSelected(parent: AdapterView<*>?) { - } - } - } - handleUpdatedFeedStatus() - } - - @UnstableApi private fun openFeed() { - // feed.getId() is always 0, we have to retrieve the id from the feed list from - // the database - val intent = MainActivity.getIntentToOpenFeed(this, feedId) - intent.putExtra(MainActivity.EXTRA_STARTED_FROM_SEARCH, - getIntent().getBooleanExtra(MainActivity.EXTRA_STARTED_FROM_SEARCH, false)) - finish() - startActivity(intent) - } - - @UnstableApi private fun showEpisodes(episodes: List) { - Log.d(TAG, "showEpisodes ${episodes.size}") - if (episodes.isNullOrEmpty()) return - val intent = MainActivity.openEpisodesList(this, ArrayList(episodes.subList(0, min(50, episodes.size-1)))) - intent.putExtra(MainActivity.EXTRA_STARTED_FROM_SEARCH, - getIntent().getBooleanExtra(MainActivity.EXTRA_STARTED_FROM_SEARCH, false)) - finish() - startActivity(intent) - } - - @UnstableApi private fun handleUpdatedFeedStatus() { - val dli = DownloadServiceInterface.get() - if (dli == null || selectedDownloadUrl == null) return - - when { - dli.isDownloadingEpisode(selectedDownloadUrl!!) -> { - binding.subscribeButton.isEnabled = false - binding.subscribeButton.setText(R.string.subscribing_label) - } - feedInFeedlist() -> { - binding.subscribeButton.isEnabled = true - binding.subscribeButton.setText(R.string.open) - if (didPressSubscribe) { - didPressSubscribe = false - - val feed1 = DBReader.getFeed(feedId)?: return - val feedPreferences = feed1.preferences - if (feedPreferences != null) { - if (isEnableAutodownload) { - val autoDownload = binding.autoDownloadCheckBox.isChecked - feedPreferences.autoDownload = autoDownload - - val preferences = getSharedPreferences(PREFS, MODE_PRIVATE) - val editor = preferences.edit() - editor.putBoolean(PREF_LAST_AUTO_DOWNLOAD, autoDownload) - editor.apply() - } - if (username != null) { - feedPreferences.username = username - feedPreferences.password = password - } - DBWriter.setFeedPreferences(feedPreferences) - } - openFeed() - } - } - else -> { - binding.subscribeButton.isEnabled = true - binding.subscribeButton.setText(R.string.subscribe_label) - if (isEnableAutodownload) { - binding.autoDownloadCheckBox.visibility = View.VISIBLE - } - } - } - } - - private fun feedInFeedlist(): Boolean { - return feedId != 0L - } - - private val feedId: Long - get() { - if (feeds == null) return 0 - - for (f in feeds!!) { - if (f.download_url == selectedDownloadUrl) { - return f.id - } - } - return 0 - } - - @UiThread - private fun showErrorDialog(errorMsg: String?, details: String) { - if (!isFinishing && !isPaused) { - val builder = MaterialAlertDialogBuilder(this) - builder.setTitle(R.string.error_label) - if (errorMsg != null) { - val total = """ - $errorMsg - - $details - """.trimIndent() - val errorMessage = SpannableString(total) - errorMessage.setSpan(ForegroundColorSpan(-0x77777778), - 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: DialogInterface, _: Int -> dialog.cancel() } - if (intent.getBooleanExtra(ARG_WAS_MANUAL_URL, false)) { - builder.setNeutralButton(R.string.edit_url_menu) { _: DialogInterface?, _: Int -> editUrl() } - } - builder.setOnCancelListener { - setResult(RESULT_ERROR) - finish() - } - if (dialog != null && dialog!!.isShowing) dialog!!.dismiss() - dialog = builder.show() - } - } - - private fun editUrl() { - val builder = MaterialAlertDialogBuilder(this) - builder.setTitle(R.string.edit_url_menu) - val dialogBinding = EditTextDialogBinding.inflate(layoutInflater) - if (downloader != null) { - dialogBinding.urlEditText.setText(downloader!!.downloadRequest.source) - } - builder.setView(dialogBinding.root) - builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int -> - setLoadingLayout() - lookupUrlAndDownload(dialogBinding.urlEditText.text.toString()) - } - builder.setNegativeButton(R.string.cancel_label) { dialog1: DialogInterface, _: Int -> dialog1.cancel() } - builder.setOnCancelListener { - setResult(RESULT_ERROR) - finish() - } - builder.show() - } - - /** - * - * @return true if a FeedDiscoveryDialog is shown, false otherwise (e.g., due to no feed found). - */ - private fun showFeedDiscoveryDialog(feedFile: File, baseUrl: String): Boolean { - val fd = FeedDiscoverer() - val urlsMap: Map - try { - urlsMap = fd.findLinks(feedFile, baseUrl) - if (urlsMap.isEmpty()) return false - } catch (e: IOException) { - e.printStackTrace() - return false - } - - if (isPaused || isFinishing) return false - - val titles: MutableList = ArrayList() - - val urls: List = ArrayList(urlsMap.keys) - for (url in urls) { - titles.add(urlsMap[url]) - } - - if (urls.size == 1) { - // Skip dialog and display the item directly - resetIntent(urls[0]) - startFeedDownload(urls[0]) - return true - } - - val adapter = ArrayAdapter(this@OnlineFeedViewActivity, - R.layout.ellipsize_start_listitem, R.id.txtvTitle, titles) - val onClickListener = DialogInterface.OnClickListener { dialog: DialogInterface, which: Int -> - val selectedUrl = urls[which] - dialog.dismiss() - resetIntent(selectedUrl) - startFeedDownload(selectedUrl) - } - - val ab = MaterialAlertDialogBuilder(this@OnlineFeedViewActivity) - .setTitle(R.string.feeds_label) - .setCancelable(true) - .setOnCancelListener { _: DialogInterface? -> finish() } - .setAdapter(adapter, onClickListener) - - runOnUiThread { - if (dialog != null && dialog!!.isShowing) dialog!!.dismiss() - dialog = ab.show() - } - return true - } - - private inner class FeedViewAuthenticationDialog(context: Context, titleRes: Int, private val feedUrl: String) : - AuthenticationDialog(context, titleRes, true, username, password) { - override fun onCancelled() { - super.onCancelled() - finish() - } - - override fun onConfirmed(username: String, password: String) { - this@OnlineFeedViewActivity.username = username - this@OnlineFeedViewActivity.password = password - startFeedDownload(feedUrl) - } - } companion object { const val ARG_FEEDURL: String = "arg.feedurl" - const val ARG_WAS_MANUAL_URL: String = "manual_url" private const val RESULT_ERROR = 2 private const val TAG = "OnlineFeedViewActivity" - private const val PREFS = "OnlineFeedViewActivityPreferences" - private const val PREF_LAST_AUTO_DOWNLOAD = "lastAutoDownload" - private const val DESCRIPTION_MAX_LINES_COLLAPSED = 20 } } diff --git a/app/src/main/java/ac/mdiq/podcini/ui/activity/PlaybackSpeedDialogActivity.kt b/app/src/main/java/ac/mdiq/podcini/ui/activity/PlaybackSpeedDialogActivity.kt index 031109ca..c782807b 100644 --- a/app/src/main/java/ac/mdiq/podcini/ui/activity/PlaybackSpeedDialogActivity.kt +++ b/app/src/main/java/ac/mdiq/podcini/ui/activity/PlaybackSpeedDialogActivity.kt @@ -6,6 +6,7 @@ import androidx.appcompat.app.AppCompatActivity import ac.mdiq.podcini.preferences.ThemeSwitcher.getTranslucentTheme import ac.mdiq.podcini.ui.dialog.VariableSpeedDialog +// This is for widget class PlaybackSpeedDialogActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { setTheme(getTranslucentTheme(this)) diff --git a/app/src/main/java/ac/mdiq/podcini/ui/activity/SelectSubscriptionActivity.kt b/app/src/main/java/ac/mdiq/podcini/ui/activity/SelectSubscriptionActivity.kt index 84db520a..d25ce3ad 100644 --- a/app/src/main/java/ac/mdiq/podcini/ui/activity/SelectSubscriptionActivity.kt +++ b/app/src/main/java/ac/mdiq/podcini/ui/activity/SelectSubscriptionActivity.kt @@ -33,15 +33,16 @@ import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers +// TODO: need to enable class SelectSubscriptionActivity : AppCompatActivity() { - private var disposable: Disposable? = null - - @Volatile - private var listItems: List? = null - private var _binding: SubscriptionSelectionActivityBinding? = null private val binding get() = _binding!! + @Volatile + private var listItems: List = listOf() + + private var disposable: Disposable? = null + override fun onCreate(savedInstanceState: Bundle?) { setTheme(ThemeSwitcher.getTranslucentTheme(this)) super.onCreate(savedInstanceState) @@ -64,7 +65,7 @@ class SelectSubscriptionActivity : AppCompatActivity() { } binding.shortcutBtn.setOnClickListener { if (checkedPosition[0] != null && Intent.ACTION_CREATE_SHORTCUT == intent.action) { - getBitmapFromUrl(listItems!![checkedPosition[0]!!]) + getBitmapFromUrl(listItems[checkedPosition[0]!!]) } } } @@ -72,10 +73,10 @@ class SelectSubscriptionActivity : AppCompatActivity() { fun getFeedItems(items: List, result: MutableList): List { for (item in items) { if (item == null) continue - val feed: Feed = item.feed - if (!result.contains(feed)) { - result.add(feed) - } + val feed: Feed = item.feed + if (!result.contains(feed)) { + result.add(feed) + } } return result } diff --git a/app/src/main/java/ac/mdiq/podcini/ui/adapter/actionbutton/ItemActionButton.kt b/app/src/main/java/ac/mdiq/podcini/ui/adapter/actionbutton/ItemActionButton.kt index a87cfcba..4e340bec 100644 --- a/app/src/main/java/ac/mdiq/podcini/ui/adapter/actionbutton/ItemActionButton.kt +++ b/app/src/main/java/ac/mdiq/podcini/ui/adapter/actionbutton/ItemActionButton.kt @@ -8,6 +8,7 @@ import ac.mdiq.podcini.util.PlaybackStatus.isCurrentlyPlaying import ac.mdiq.podcini.storage.model.feed.FeedItem import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface import ac.mdiq.podcini.preferences.UserPreferences.isStreamOverDownload +import ac.mdiq.podcini.storage.model.playback.RemoteMedia abstract class ItemActionButton internal constructor(@JvmField var item: FeedItem) { abstract fun getLabel(): Int @@ -38,9 +39,6 @@ abstract class ItemActionButton internal constructor(@JvmField var item: FeedIte isCurrentlyPlaying(media) -> { PauseActionButton(item) } - item.feed == null -> { - StreamActionButton(item) - } item.feed != null && item.feed!!.isLocalFeed -> { PlayLocalActionButton(item) } @@ -50,7 +48,7 @@ abstract class ItemActionButton internal constructor(@JvmField var item: FeedIte isDownloadingMedia -> { CancelDownloadActionButton(item) } - isStreamOverDownload || item.feed == null -> { + isStreamOverDownload || item.feed == null || item.feedId == 0L -> { StreamActionButton(item) } else -> { diff --git a/app/src/main/java/ac/mdiq/podcini/ui/fragment/AddFeedFragment.kt b/app/src/main/java/ac/mdiq/podcini/ui/fragment/AddFeedFragment.kt index 801ff54b..f314c5cf 100644 --- a/app/src/main/java/ac/mdiq/podcini/ui/fragment/AddFeedFragment.kt +++ b/app/src/main/java/ac/mdiq/podcini/ui/fragment/AddFeedFragment.kt @@ -1,6 +1,15 @@ package ac.mdiq.podcini.ui.fragment +import ac.mdiq.podcini.R +import ac.mdiq.podcini.databinding.AddfeedBinding +import ac.mdiq.podcini.databinding.EditTextDialogBinding +import ac.mdiq.podcini.net.discovery.* +import ac.mdiq.podcini.net.download.FeedUpdateManager +import ac.mdiq.podcini.storage.DBTasks +import ac.mdiq.podcini.storage.model.feed.Feed +import ac.mdiq.podcini.storage.model.feed.SortOrder import ac.mdiq.podcini.ui.activity.MainActivity +import ac.mdiq.podcini.ui.activity.OpmlImportActivity import android.content.* import android.net.Uri import android.os.Bundle @@ -17,16 +26,6 @@ import androidx.fragment.app.Fragment import androidx.media3.common.util.UnstableApi import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar -import ac.mdiq.podcini.R -import ac.mdiq.podcini.ui.activity.OnlineFeedViewActivity -import ac.mdiq.podcini.ui.activity.OpmlImportActivity -import ac.mdiq.podcini.storage.DBTasks -import ac.mdiq.podcini.net.download.FeedUpdateManager -import ac.mdiq.podcini.databinding.AddfeedBinding -import ac.mdiq.podcini.databinding.EditTextDialogBinding -import ac.mdiq.podcini.storage.model.feed.Feed -import ac.mdiq.podcini.storage.model.feed.SortOrder -import ac.mdiq.podcini.net.discovery.* import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.schedulers.Schedulers @@ -135,10 +134,8 @@ class AddFeedFragment : Fragment() { } private fun addUrl(url: String) { - val intent = Intent(getActivity(), OnlineFeedViewActivity::class.java) - intent.putExtra(OnlineFeedViewActivity.ARG_FEEDURL, url) - intent.putExtra(OnlineFeedViewActivity.ARG_WAS_MANUAL_URL, true) - startActivity(intent) + val fragment: Fragment = OnlineFeedViewFragment.newInstance(url) + (activity as MainActivity).loadChildFragment(fragment) } private fun performSearch() { diff --git a/app/src/main/java/ac/mdiq/podcini/ui/fragment/DiscoveryFragment.kt b/app/src/main/java/ac/mdiq/podcini/ui/fragment/DiscoveryFragment.kt index e1d3aa2f..038e75a4 100644 --- a/app/src/main/java/ac/mdiq/podcini/ui/fragment/DiscoveryFragment.kt +++ b/app/src/main/java/ac/mdiq/podcini/ui/fragment/DiscoveryFragment.kt @@ -7,12 +7,11 @@ import ac.mdiq.podcini.databinding.SelectCountryDialogBinding import ac.mdiq.podcini.net.discovery.ItunesTopListLoader import ac.mdiq.podcini.net.discovery.PodcastSearchResult import ac.mdiq.podcini.storage.DBReader -import ac.mdiq.podcini.ui.activity.OnlineFeedViewActivity +import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.adapter.OnlineFeedsAdapter import ac.mdiq.podcini.util.event.DiscoveryDefaultUpdateEvent import android.content.Context import android.content.DialogInterface -import android.content.Intent import android.content.SharedPreferences import android.os.Bundle import android.util.Log @@ -23,8 +22,10 @@ import android.view.View.OnFocusChangeListener import android.view.ViewGroup import android.widget.* import android.widget.AdapterView.OnItemClickListener +import androidx.annotation.OptIn import androidx.appcompat.widget.Toolbar import androidx.fragment.app.Fragment +import androidx.media3.common.util.UnstableApi import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.MaterialAutoCompleteTextView @@ -95,7 +96,7 @@ class DiscoveryFragment : Fragment(), Toolbar.OnMenuItemClickListener { needsConfirm = prefs.getBoolean(ItunesTopListLoader.PREF_KEY_NEEDS_CONFIRM, true) } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + @OptIn(UnstableApi::class) override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { // Inflate the layout for this fragment _binding = FragmentItunesSearchBinding.inflate(inflater) // val root = inflater.inflate(R.layout.fragment_itunes_search, container, false) @@ -119,9 +120,8 @@ class DiscoveryFragment : Fragment(), Toolbar.OnMenuItemClickListener { if (podcast.feedUrl == null) { return@OnItemClickListener } - val intent = Intent(activity, OnlineFeedViewActivity::class.java) - intent.putExtra(OnlineFeedViewActivity.ARG_FEEDURL, podcast.feedUrl) - startActivity(intent) + val fragment: Fragment = OnlineFeedViewFragment.newInstance(podcast.feedUrl) + (activity as MainActivity).loadChildFragment(fragment) } progressBar = binding.progressBar diff --git a/app/src/main/java/ac/mdiq/podcini/ui/fragment/ExternalPlayerFragment.kt b/app/src/main/java/ac/mdiq/podcini/ui/fragment/ExternalPlayerFragment.kt index 82ed194f..d40ddd93 100644 --- a/app/src/main/java/ac/mdiq/podcini/ui/fragment/ExternalPlayerFragment.kt +++ b/app/src/main/java/ac/mdiq/podcini/ui/fragment/ExternalPlayerFragment.kt @@ -234,7 +234,7 @@ class ExternalPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener { val duration: Int = converter.convert(event.duration) val remainingTime: Int = converter.convert(max((event.duration - event.position).toDouble(), 0.0).toInt()) if (currentPosition == Playable.INVALID_TIME || duration == Playable.INVALID_TIME) { - Log.w(AudioPlayerFragment.TAG, "Could not react to position observer update because of invalid time") + Log.w(TAG, "Could not react to position observer update because of invalid time") return } diff --git a/app/src/main/java/ac/mdiq/podcini/ui/fragment/OnlineFeedViewFragment.kt b/app/src/main/java/ac/mdiq/podcini/ui/fragment/OnlineFeedViewFragment.kt new file mode 100644 index 00000000..d5203bf9 --- /dev/null +++ b/app/src/main/java/ac/mdiq/podcini/ui/fragment/OnlineFeedViewFragment.kt @@ -0,0 +1,646 @@ +package ac.mdiq.podcini.ui.fragment + +import ac.mdiq.podcini.R +import ac.mdiq.podcini.databinding.EditTextDialogBinding +import ac.mdiq.podcini.databinding.OnlineFeedviewFragmentBinding +import ac.mdiq.podcini.feed.FeedUrlNotFoundException +import ac.mdiq.podcini.feed.parser.FeedHandler +import ac.mdiq.podcini.feed.parser.FeedHandlerResult +import ac.mdiq.podcini.feed.parser.UnsupportedFeedtypeException +import ac.mdiq.podcini.net.common.UrlChecker.prepareUrl +import ac.mdiq.podcini.net.discovery.CombinedSearcher +import ac.mdiq.podcini.net.discovery.PodcastSearcherRegistry +import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface +import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownload +import ac.mdiq.podcini.service.download.DownloadRequestCreator.create +import ac.mdiq.podcini.service.download.Downloader +import ac.mdiq.podcini.service.download.HttpDownloader +import ac.mdiq.podcini.storage.DBReader +import ac.mdiq.podcini.storage.DBTasks +import ac.mdiq.podcini.storage.DBWriter +import ac.mdiq.podcini.storage.model.download.DownloadError +import ac.mdiq.podcini.storage.model.download.DownloadResult +import ac.mdiq.podcini.storage.model.feed.Feed +import ac.mdiq.podcini.storage.model.feed.FeedItem +import ac.mdiq.podcini.ui.activity.MainActivity +import ac.mdiq.podcini.ui.common.ThemeUtils.getColorFromAttr +import ac.mdiq.podcini.ui.dialog.AuthenticationDialog +import ac.mdiq.podcini.ui.glide.FastBlurTransformation +import ac.mdiq.podcini.util.DownloadErrorLabel.from +import ac.mdiq.podcini.util.event.EpisodeDownloadEvent +import ac.mdiq.podcini.util.event.FeedListUpdateEvent +import ac.mdiq.podcini.util.syndication.FeedDiscoverer +import ac.mdiq.podcini.util.syndication.HtmlToPlainText +import android.app.Dialog +import android.content.Context +import android.content.Context.MODE_PRIVATE +import android.content.DialogInterface +import android.graphics.LightingColorFilter +import android.os.Bundle +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ForegroundColorSpan +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.Toast +import androidx.annotation.OptIn +import androidx.annotation.UiThread +import androidx.fragment.app.Fragment +import androidx.media3.common.util.UnstableApi +import com.bumptech.glide.Glide +import com.bumptech.glide.request.RequestOptions +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +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.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import java.io.File +import java.io.IOException +import kotlin.concurrent.Volatile + +/** + * 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. + */ +class OnlineFeedViewFragment : Fragment() { + private var _binding: OnlineFeedviewFragmentBinding? = null + private val binding get() = _binding!! + + private var displayUpArrow = false + + @Volatile + private var feeds: List? = null + private var selectedDownloadUrl: String? = null + private var downloader: Downloader? = null + private var username: String? = null + private var password: String? = null + + private var isPaused = false + private var didPressSubscribe = false + private var isFeedFoundBySearch = false + + private var dialog: Dialog? = null + + private var download: Disposable? = null + private var parser: Disposable? = null + private var updater: Disposable? = null + + @OptIn(UnstableApi::class) override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = OnlineFeedviewFragmentBinding.inflate(layoutInflater) + binding.closeButton.visibility = View.INVISIBLE + binding.card.setOnClickListener(null) + binding.card.setCardBackgroundColor(getColorFromAttr(requireContext(), R.attr.colorSurface)) + + Log.d(TAG, "fragment onCreateView") + + displayUpArrow = parentFragmentManager.backStackEntryCount != 0 + if (savedInstanceState != null) { + displayUpArrow = savedInstanceState.getBoolean(KEY_UP_ARROW) + } + (activity as MainActivity).setupToolbarToggle(binding.toolbar, displayUpArrow) + + var feedUrl = requireArguments().getString(ARG_FEEDURL) + + 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/))".toRegex(), "") + } + if (savedInstanceState != null) { + username = savedInstanceState.getString("username") + password = savedInstanceState.getString("password") + } + lookupUrlAndDownload(feedUrl) + } + + return binding.root + } + + private fun showNoPodcastFoundError() { + requireActivity().runOnUiThread { + MaterialAlertDialogBuilder(requireContext()) + .setNeutralButton(android.R.string.ok) { _: DialogInterface?, _: Int -> } + .setTitle(R.string.error_label) + .setMessage(R.string.null_value_podcast_error) + .setOnDismissListener {} + .show() + } + } + + /** + * Displays a progress indicator. + */ + private fun setLoadingLayout() { + binding.progressBar.visibility = View.VISIBLE + binding.feedDisplayContainer.visibility = View.GONE + } + + override fun onStart() { + super.onStart() + isPaused = false + EventBus.getDefault().register(this) + } + + override fun onStop() { + super.onStop() + isPaused = true + EventBus.getDefault().unregister(this) + if (downloader != null && !downloader!!.isFinished) downloader!!.cancel() + if (dialog != null && dialog!!.isShowing) dialog!!.dismiss() + } + + override fun onDestroy() { + super.onDestroy() + _binding = null + updater?.dispose() + download?.dispose() + parser?.dispose() + } + + @OptIn(UnstableApi::class) override fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean(KEY_UP_ARROW, displayUpArrow) + super.onSaveInstanceState(outState) + outState.putString("username", username) + outState.putString("password", password) + } + + private fun lookupUrlAndDownload(url: String) { + download = PodcastSearcherRegistry.lookupUrl(url) + .subscribeOn(Schedulers.io()) + .observeOn(Schedulers.io()) + .subscribe({ url1: String -> this.startFeedDownload(url1) }, + { error: Throwable? -> + if (error is FeedUrlNotFoundException) { + tryToRetrieveFeedUrlBySearch(error) + } else { + showNoPodcastFoundError() + Log.e(TAG, Log.getStackTraceString(error)) + } + }) + } + + private fun tryToRetrieveFeedUrlBySearch(error: FeedUrlNotFoundException) { + Log.d(TAG, "Unable to retrieve feed url, trying to retrieve feed url from search") + val url = searchFeedUrlByTrackName(error.trackName, error.artistName) + 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 fun searchFeedUrlByTrackName(trackName: String, artistName: String): String? { + val searcher = CombinedSearcher() + val query = "$trackName $artistName" + val results = searcher.search(query).blockingGet() + if (results.isNullOrEmpty()) return null + for (result in results) { + if (result?.feedUrl != null && result.author != null && + result.author.equals(artistName, ignoreCase = true) && + result.title.equals(trackName, ignoreCase = true)) { + return result.feedUrl + } + } + return null + } + + private fun startFeedDownload(url: String) { + Log.d(TAG, "Starting feed download") + selectedDownloadUrl = prepareUrl(url) + val request = create(Feed(selectedDownloadUrl, null)) + .withAuthentication(username, password) + .withInitiatedByUser(true) + .build() + + download = Observable.fromCallable { + feeds = DBReader.getFeedList() + downloader = HttpDownloader(request) + downloader?.call() + downloader?.result + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ status: DownloadResult? -> if (request.destination != null) checkDownloadResult(status, request.destination) }, + { error: Throwable? -> Log.e(TAG, Log.getStackTraceString(error)) }) + } + + private fun checkDownloadResult(status: DownloadResult?, destination: String) { + if (status == null) return + when { + status.isSuccessful -> { + parseFeed(destination) + } + status.reason == DownloadError.ERROR_UNAUTHORIZED -> { + if (!isRemoving && !isPaused) { + if (username != null && password != null) { + Toast.makeText(requireContext(), R.string.download_error_unauthorized, Toast.LENGTH_LONG).show() + } + if (downloader?.downloadRequest?.source != null) { + dialog = FeedViewAuthenticationDialog(requireContext(), + R.string.authentication_notification_title, downloader!!.downloadRequest.source!!).create() + dialog?.show() + } + } + } + else -> { + showErrorDialog(getString(from(status.reason)), status.reasonDetailed) + } + } + } + + @UnstableApi @Subscribe + fun onFeedListChanged(event: FeedListUpdateEvent?) { + updater = Observable.fromCallable { DBReader.getFeedList() } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { feeds: List? -> + this@OnlineFeedViewFragment.feeds = feeds + handleUpdatedFeedStatus() + }, { error: Throwable? -> Log.e(TAG, Log.getStackTraceString(error)) } + ) + } + + @UnstableApi @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) + fun onEventMainThread(event: EpisodeDownloadEvent?) { + handleUpdatedFeedStatus() + } + + private fun parseFeed(destination: String) { + Log.d(TAG, "Parsing feed") + parser = Maybe.fromCallable { doParseFeed(destination) } + .subscribeOn(Schedulers.computation()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeWith(object : DisposableMaybeObserver() { + @UnstableApi override fun onSuccess(result: FeedHandlerResult) { + showFeedInformation(result.feed, result.alternateFeedUrls) + } + + override fun onComplete() { + // Ignore null result: We showed the discovery dialog. + } + + override fun onError(error: Throwable) { + showErrorDialog(error.message, "") + 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. + */ + @Throws(Exception::class) + private fun doParseFeed(destination: String): FeedHandlerResult? { + val handler = FeedHandler() + val feed = Feed(selectedDownloadUrl, null) + feed.file_url = destination + val destinationFile = File(destination) + return try { + handler.parseFeed(feed) + } catch (e: UnsupportedFeedtypeException) { + Log.d(TAG, "Unsupported feed type detected") + if ("html".equals(e.rootElement, ignoreCase = true)) { + if (selectedDownloadUrl != null) { + val dialogShown = showFeedDiscoveryDialog(destinationFile, selectedDownloadUrl!!) + if (dialogShown) { + null // Should not display an error message + } else { + throw UnsupportedFeedtypeException(getString(R.string.download_error_unsupported_type_html)) + } + } else null + } else { + throw e + } + } catch (e: Exception) { + Log.e(TAG, Log.getStackTraceString(e)) + throw e + } finally { + val 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. + */ + @UnstableApi private fun showFeedInformation(feed: Feed, alternateFeedUrls: Map) { + binding.progressBar.visibility = View.GONE + binding.feedDisplayContainer.visibility = View.VISIBLE + if (isFeedFoundBySearch) { + val resId = R.string.no_feed_url_podcast_found_by_search + Snackbar.make(binding.root, resId, Snackbar.LENGTH_LONG).show() + } + + binding.backgroundImage.colorFilter = LightingColorFilter(-0x7d7d7e, 0x000000) + binding.episodeLabel.setOnClickListener { showEpisodes(feed.items)} + + if (!feed.imageUrl.isNullOrBlank()) { + Glide.with(this) + .load(feed.imageUrl) + .apply(RequestOptions() + .placeholder(R.color.light_gray) + .error(R.color.light_gray) + .fitCenter() + .dontAnimate()) + .into(binding.coverImage) + Glide.with(this) + .load(feed.imageUrl) + .apply(RequestOptions() + .placeholder(R.color.image_readability_tint) + .error(R.color.image_readability_tint) + .transform(FastBlurTransformation()) + .dontAnimate()) + .into(binding.backgroundImage) + } + + binding.titleLabel.text = feed.title + binding.authorLabel.text = feed.author + + binding.txtvDescription.text = HtmlToPlainText.getPlainText(feed.description?:"") + + binding.subscribeButton.setOnClickListener { + if (feedInFeedlist()) { + openFeed() + } else { + DBTasks.updateFeed(requireContext(), feed, false) + didPressSubscribe = true + handleUpdatedFeedStatus() + } + } + + if (isEnableAutodownload) { + val preferences = requireContext().getSharedPreferences(PREFS, MODE_PRIVATE) + binding.autoDownloadCheckBox.isChecked = preferences.getBoolean(PREF_LAST_AUTO_DOWNLOAD, true) + } + + if (alternateFeedUrls.isEmpty()) { + binding.alternateUrlsSpinner.visibility = View.GONE + } else { + binding.alternateUrlsSpinner.visibility = View.VISIBLE + + val alternateUrlsList: MutableList = ArrayList() + val alternateUrlsTitleList: MutableList = ArrayList() + + if (feed.download_url != null) alternateUrlsList.add(feed.download_url!!) + alternateUrlsTitleList.add(feed.title) + + alternateUrlsList.addAll(alternateFeedUrls.keys) + for (url in alternateFeedUrls.keys) { + alternateUrlsTitleList.add(alternateFeedUrls[url]) + } + + val adapter: ArrayAdapter = object : ArrayAdapter(requireContext(), + R.layout.alternate_urls_item, alternateUrlsTitleList) { + override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { + // 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) + binding.alternateUrlsSpinner.adapter = adapter + binding.alternateUrlsSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View, position: Int, id: Long) { + selectedDownloadUrl = alternateUrlsList[position] + } + + override fun onNothingSelected(parent: AdapterView<*>?) { + } + } + } + handleUpdatedFeedStatus() + } + + @UnstableApi private fun openFeed() { + // feed.getId() is always 0, we have to retrieve the id from the feed list from + // the database + (activity as MainActivity).loadFeedFragmentById(feedId, null) + } + + @UnstableApi private fun showEpisodes(episodes: List) { + Log.d(TAG, "showEpisodes ${episodes.size}") + if (episodes.isNullOrEmpty()) return + val fragment: Fragment = EpisodesListFragment.newInstance(ArrayList(episodes)) + (activity as MainActivity).loadChildFragment(fragment) + } + + @UnstableApi private fun handleUpdatedFeedStatus() { + val dli = DownloadServiceInterface.get() + if (dli == null || selectedDownloadUrl == null) return + + when { + dli.isDownloadingEpisode(selectedDownloadUrl!!) -> { + binding.subscribeButton.isEnabled = false + binding.subscribeButton.setText(R.string.subscribing_label) + } + feedInFeedlist() -> { + binding.subscribeButton.isEnabled = true + binding.subscribeButton.setText(R.string.open) + if (didPressSubscribe) { + didPressSubscribe = false + + val feed1 = DBReader.getFeed(feedId)?: return + val feedPreferences = feed1.preferences + if (feedPreferences != null) { + if (isEnableAutodownload) { + val autoDownload = binding.autoDownloadCheckBox.isChecked + feedPreferences.autoDownload = autoDownload + + val preferences = requireContext().getSharedPreferences(PREFS, MODE_PRIVATE) + val editor = preferences.edit() + editor.putBoolean(PREF_LAST_AUTO_DOWNLOAD, autoDownload) + editor.apply() + } + if (username != null) { + feedPreferences.username = username + feedPreferences.password = password + } + DBWriter.setFeedPreferences(feedPreferences) + } + openFeed() + } + } + else -> { + binding.subscribeButton.isEnabled = true + binding.subscribeButton.setText(R.string.subscribe_label) + if (isEnableAutodownload) { + binding.autoDownloadCheckBox.visibility = View.VISIBLE + } + } + } + } + + private fun feedInFeedlist(): Boolean { + return feedId != 0L + } + + private val feedId: Long + get() { + if (feeds == null) return 0 + + for (f in feeds!!) { + if (f.download_url == selectedDownloadUrl) { + return f.id + } + } + return 0 + } + + @UiThread + private fun showErrorDialog(errorMsg: String?, details: String) { + if (!isRemoving && !isPaused) { + val builder = MaterialAlertDialogBuilder(requireContext()) + builder.setTitle(R.string.error_label) + if (errorMsg != null) { + val total = """ + $errorMsg + + $details + """.trimIndent() + val errorMessage = SpannableString(total) + errorMessage.setSpan(ForegroundColorSpan(-0x77777778), + 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: DialogInterface, _: Int -> dialog.cancel() } +// if (intent.getBooleanExtra(ARG_WAS_MANUAL_URL, false)) { +// builder.setNeutralButton(R.string.edit_url_menu) { _: DialogInterface?, _: Int -> editUrl() } +// } + builder.setOnCancelListener { +// setResult(RESULT_ERROR) +// finish() + } + if (dialog != null && dialog!!.isShowing) dialog!!.dismiss() + dialog = builder.show() + } + } + + private fun editUrl() { + val builder = MaterialAlertDialogBuilder(requireContext()) + builder.setTitle(R.string.edit_url_menu) + val dialogBinding = EditTextDialogBinding.inflate(layoutInflater) + if (downloader != null) { + dialogBinding.urlEditText.setText(downloader!!.downloadRequest.source) + } + builder.setView(dialogBinding.root) + builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int -> + setLoadingLayout() + lookupUrlAndDownload(dialogBinding.urlEditText.text.toString()) + } + builder.setNegativeButton(R.string.cancel_label) { dialog1: DialogInterface, _: Int -> dialog1.cancel() } + builder.setOnCancelListener {} + builder.show() + } + + /** + * + * @return true if a FeedDiscoveryDialog is shown, false otherwise (e.g., due to no feed found). + */ + private fun showFeedDiscoveryDialog(feedFile: File, baseUrl: String): Boolean { + val fd = FeedDiscoverer() + val urlsMap: Map + try { + urlsMap = fd.findLinks(feedFile, baseUrl) + if (urlsMap.isEmpty()) return false + } catch (e: IOException) { + e.printStackTrace() + return false + } + + if (isRemoving || isPaused) return false + + val titles: MutableList = ArrayList() + + val urls: List = ArrayList(urlsMap.keys) + for (url in urls) { + titles.add(urlsMap[url]) + } + + if (urls.size == 1) { + // Skip dialog and display the item directly + startFeedDownload(urls[0]) + return true + } + + val adapter = ArrayAdapter(requireContext(), R.layout.ellipsize_start_listitem, R.id.txtvTitle, titles) + val onClickListener = DialogInterface.OnClickListener { dialog: DialogInterface, which: Int -> + val selectedUrl = urls[which] + dialog.dismiss() + startFeedDownload(selectedUrl) + } + + val ab = MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.feeds_label) + .setCancelable(true) + .setOnCancelListener { _: DialogInterface? -> +// finish() + } + .setAdapter(adapter, onClickListener) + + requireActivity().runOnUiThread { + if (dialog != null && dialog!!.isShowing) dialog!!.dismiss() + dialog = ab.show() + } + return true + } + + private inner class FeedViewAuthenticationDialog(context: Context, titleRes: Int, private val feedUrl: String) : + AuthenticationDialog(context, titleRes, true, username, password) { + override fun onConfirmed(username: String, password: String) { + this@OnlineFeedViewFragment.username = username + this@OnlineFeedViewFragment.password = password + startFeedDownload(feedUrl) + } + } + + companion object { + const val ARG_FEEDURL: String = "arg.feedurl" + const val ARG_WAS_MANUAL_URL: String = "manual_url" + private const val RESULT_ERROR = 2 + private const val TAG = "OnlineFeedViewFragment" + private const val PREFS = "OnlineFeedViewFragmentPreferences" + private const val PREF_LAST_AUTO_DOWNLOAD = "lastAutoDownload" + private const val KEY_UP_ARROW = "up_arrow" + + @JvmStatic + fun newInstance(feedUrl: String): OnlineFeedViewFragment { + val fragment = OnlineFeedViewFragment() + val b = Bundle() + b.putString(ARG_FEEDURL, feedUrl) + fragment.arguments = b + return fragment + } + } +} diff --git a/app/src/main/java/ac/mdiq/podcini/ui/fragment/OnlineSearchFragment.kt b/app/src/main/java/ac/mdiq/podcini/ui/fragment/OnlineSearchFragment.kt index 16244abd..5cb15403 100644 --- a/app/src/main/java/ac/mdiq/podcini/ui/fragment/OnlineSearchFragment.kt +++ b/app/src/main/java/ac/mdiq/podcini/ui/fragment/OnlineSearchFragment.kt @@ -1,8 +1,13 @@ package ac.mdiq.podcini.ui.fragment +import ac.mdiq.podcini.R +import ac.mdiq.podcini.databinding.FragmentItunesSearchBinding +import ac.mdiq.podcini.net.discovery.PodcastSearchResult +import ac.mdiq.podcini.net.discovery.PodcastSearcher +import ac.mdiq.podcini.net.discovery.PodcastSearcherRegistry import ac.mdiq.podcini.ui.activity.MainActivity +import ac.mdiq.podcini.ui.adapter.OnlineFeedsAdapter import android.content.Context -import android.content.Intent import android.os.Bundle import android.util.Log import android.view.LayoutInflater @@ -15,13 +20,6 @@ import androidx.appcompat.widget.SearchView import androidx.fragment.app.Fragment import androidx.media3.common.util.UnstableApi import com.google.android.material.appbar.MaterialToolbar -import ac.mdiq.podcini.R -import ac.mdiq.podcini.databinding.FragmentItunesSearchBinding -import ac.mdiq.podcini.ui.activity.OnlineFeedViewActivity -import ac.mdiq.podcini.ui.adapter.OnlineFeedsAdapter -import ac.mdiq.podcini.net.discovery.PodcastSearchResult -import ac.mdiq.podcini.net.discovery.PodcastSearcher -import ac.mdiq.podcini.net.discovery.PodcastSearcherRegistry import io.reactivex.disposables.Disposable class OnlineSearchFragment : Fragment() { @@ -70,11 +68,9 @@ class OnlineSearchFragment : Fragment() { //Show information about the podcast when the list item is clicked gridView.onItemClickListener = AdapterView.OnItemClickListener { _: AdapterView<*>?, _: View?, position: Int, _: Long -> val podcast = searchResults!![position] - if (podcast != null) { - val intent = Intent(activity, OnlineFeedViewActivity::class.java) - intent.putExtra(OnlineFeedViewActivity.ARG_FEEDURL, podcast.feedUrl) - intent.putExtra(MainActivity.EXTRA_STARTED_FROM_SEARCH, true) - startActivity(intent) + if (podcast?.feedUrl != null) { + val fragment: Fragment = OnlineFeedViewFragment.newInstance(podcast.feedUrl) + (activity as MainActivity).loadChildFragment(fragment) } } progressBar = binding.progressBar diff --git a/app/src/main/java/ac/mdiq/podcini/ui/fragment/QuickFeedDiscoveryFragment.kt b/app/src/main/java/ac/mdiq/podcini/ui/fragment/QuickFeedDiscoveryFragment.kt index 6a91dc6e..eb7147b9 100644 --- a/app/src/main/java/ac/mdiq/podcini/ui/fragment/QuickFeedDiscoveryFragment.kt +++ b/app/src/main/java/ac/mdiq/podcini/ui/fragment/QuickFeedDiscoveryFragment.kt @@ -3,15 +3,13 @@ package ac.mdiq.podcini.ui.fragment import ac.mdiq.podcini.BuildConfig import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.QuickFeedDiscoveryBinding -import ac.mdiq.podcini.ui.activity.MainActivity -import ac.mdiq.podcini.ui.activity.OnlineFeedViewActivity -import ac.mdiq.podcini.ui.adapter.FeedDiscoverAdapter -import ac.mdiq.podcini.storage.DBReader -import ac.mdiq.podcini.util.event.DiscoveryDefaultUpdateEvent import ac.mdiq.podcini.net.discovery.ItunesTopListLoader import ac.mdiq.podcini.net.discovery.PodcastSearchResult +import ac.mdiq.podcini.storage.DBReader +import ac.mdiq.podcini.ui.activity.MainActivity +import ac.mdiq.podcini.ui.adapter.FeedDiscoverAdapter +import ac.mdiq.podcini.util.event.DiscoveryDefaultUpdateEvent import android.content.Context -import android.content.Intent import android.content.SharedPreferences import android.os.Bundle import android.util.DisplayMetrics @@ -158,14 +156,12 @@ class QuickFeedDiscoveryFragment : Fragment(), AdapterView.OnItemClickListener { }) } - override fun onItemClick(parent: AdapterView<*>?, view: View, position: Int, id: Long) { + @OptIn(UnstableApi::class) override fun onItemClick(parent: AdapterView<*>?, view: View, position: Int, id: Long) { val podcast: PodcastSearchResult? = adapter.getItem(position) - if (podcast?.feedUrl.isNullOrEmpty()) { - return - } - val intent = Intent(activity, OnlineFeedViewActivity::class.java) - intent.putExtra(OnlineFeedViewActivity.ARG_FEEDURL, podcast!!.feedUrl) - startActivity(intent) + if (podcast?.feedUrl.isNullOrEmpty()) return + + val fragment: Fragment = OnlineFeedViewFragment.newInstance(podcast!!.feedUrl!!) + (activity as MainActivity).loadChildFragment(fragment) } companion object { diff --git a/app/src/main/java/ac/mdiq/podcini/ui/fragment/SearchFragment.kt b/app/src/main/java/ac/mdiq/podcini/ui/fragment/SearchFragment.kt index f11f8fae..a3f09d16 100644 --- a/app/src/main/java/ac/mdiq/podcini/ui/fragment/SearchFragment.kt +++ b/app/src/main/java/ac/mdiq/podcini/ui/fragment/SearchFragment.kt @@ -1,9 +1,29 @@ package ac.mdiq.podcini.ui.fragment +import ac.mdiq.podcini.R +import ac.mdiq.podcini.databinding.MultiSelectSpeedDialBinding +import ac.mdiq.podcini.databinding.SearchFragmentBinding +import ac.mdiq.podcini.net.discovery.CombinedSearcher +import ac.mdiq.podcini.playback.event.PlaybackPositionEvent +import ac.mdiq.podcini.storage.FeedSearcher +import ac.mdiq.podcini.storage.model.feed.Feed +import ac.mdiq.podcini.storage.model.feed.FeedItem import ac.mdiq.podcini.ui.activity.MainActivity +import ac.mdiq.podcini.ui.adapter.EpisodeItemListAdapter +import ac.mdiq.podcini.ui.adapter.HorizontalFeedListAdapter +import ac.mdiq.podcini.ui.adapter.SelectableAdapter +import ac.mdiq.podcini.ui.fragment.actions.EpisodeMultiSelectActionHandler +import ac.mdiq.podcini.ui.menuhandler.FeedItemMenuHandler +import ac.mdiq.podcini.ui.menuhandler.FeedMenuHandler +import ac.mdiq.podcini.ui.menuhandler.MenuItemUtils +import ac.mdiq.podcini.ui.view.EmptyViewHandler +import ac.mdiq.podcini.ui.view.EpisodeItemListRecyclerView +import ac.mdiq.podcini.ui.view.LiftOnScrollListener +import ac.mdiq.podcini.ui.view.viewholder.EpisodeItemViewHolder +import ac.mdiq.podcini.util.FeedItemUtil +import ac.mdiq.podcini.util.event.* import android.content.Context -import android.content.Intent import android.os.Bundle import android.os.Handler import android.os.Looper @@ -22,28 +42,6 @@ import com.google.android.material.chip.Chip import com.google.android.material.snackbar.Snackbar import com.leinardi.android.speeddial.SpeedDialActionItem import com.leinardi.android.speeddial.SpeedDialView -import ac.mdiq.podcini.R -import ac.mdiq.podcini.ui.activity.OnlineFeedViewActivity -import ac.mdiq.podcini.ui.adapter.EpisodeItemListAdapter -import ac.mdiq.podcini.ui.adapter.HorizontalFeedListAdapter -import ac.mdiq.podcini.ui.adapter.SelectableAdapter -import ac.mdiq.podcini.ui.menuhandler.MenuItemUtils -import ac.mdiq.podcini.storage.FeedSearcher -import ac.mdiq.podcini.util.FeedItemUtil -import ac.mdiq.podcini.databinding.MultiSelectSpeedDialBinding -import ac.mdiq.podcini.databinding.SearchFragmentBinding -import ac.mdiq.podcini.util.event.* -import ac.mdiq.podcini.playback.event.PlaybackPositionEvent -import ac.mdiq.podcini.ui.fragment.actions.EpisodeMultiSelectActionHandler -import ac.mdiq.podcini.ui.menuhandler.FeedItemMenuHandler -import ac.mdiq.podcini.ui.menuhandler.FeedMenuHandler -import ac.mdiq.podcini.storage.model.feed.Feed -import ac.mdiq.podcini.storage.model.feed.FeedItem -import ac.mdiq.podcini.net.discovery.CombinedSearcher -import ac.mdiq.podcini.ui.view.EmptyViewHandler -import ac.mdiq.podcini.ui.view.EpisodeItemListRecyclerView -import ac.mdiq.podcini.ui.view.LiftOnScrollListener -import ac.mdiq.podcini.ui.view.viewholder.EpisodeItemViewHolder import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable @@ -140,7 +138,7 @@ class SearchFragment : Fragment(), SelectableAdapter.OnSelectModeListener { requireArguments().putLong(ARG_FEED, 0) searchWithProgressBar() } - chip.visibility = if ((requireArguments().getLong(ARG_FEED, 0) == 0L)) View.GONE else View.VISIBLE + chip.visibility = if (requireArguments().getLong(ARG_FEED, 0) == 0L) View.GONE else View.VISIBLE chip.text = requireArguments().getString(ARG_FEED_NAME, "") if (requireArguments().getString(ARG_QUERY, null) != null) { search() @@ -295,7 +293,7 @@ class SearchFragment : Fragment(), SelectableAdapter.OnSelectModeListener { if (currentPlaying != null && currentPlaying!!.isCurrentlyPlayingItem) currentPlaying!!.notifyPlaybackPositionUpdated(event) else { - Log.d(FeedItemlistFragment.TAG, "onEventMainThread() search list") + Log.d(TAG, "onEventMainThread() search list") for (i in 0 until adapter.itemCount) { val holder: EpisodeItemViewHolder? = recyclerView.findViewHolderForAdapterPosition(i) as? EpisodeItemViewHolder @@ -368,9 +366,8 @@ class SearchFragment : Fragment(), SelectableAdapter.OnSelectModeListener { inVal.hideSoftInputFromWindow(searchView.windowToken, 0) val query = searchView.query.toString() if (query.matches("http[s]?://.*".toRegex())) { - val intent = Intent(activity, OnlineFeedViewActivity::class.java) - intent.putExtra(OnlineFeedViewActivity.ARG_FEEDURL, query) - startActivity(intent) + val fragment: Fragment = OnlineFeedViewFragment.newInstance(query) + (activity as MainActivity).loadChildFragment(fragment) return } (activity as MainActivity).loadChildFragment( diff --git a/app/src/main/res/layout/online_feedview_fragment.xml b/app/src/main/res/layout/online_feedview_fragment.xml new file mode 100644 index 00000000..eca4dd41 --- /dev/null +++ b/app/src/main/res/layout/online_feedview_fragment.xml @@ -0,0 +1,240 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +