23.
Podcast Episodes
Written by Fuad Kamal
Until this point, you’ve only dealt with the top-level podcast details. Now it’s time to dive deeper into the podcast episode details, and that involves loading and parsing the RSS feeds.
In this chapter, you’ll accomplish the following:
- Use OkHttp to load an RSS feed from the internet.
- Parse the details in an RSS file.
- Display the podcast episodes.
If you’re following along with your own project, open it and keep using it with this chapter. If not, don’t worry. Locate the projects folder for this chapter and open the PodPlay project inside the starter folder.
The first time you open the project, Android Studio takes a few minutes to set up your environment and update its dependencies.
Getting started
In previous chapters, you worked with the iTunes Search API, which is excellent for getting the basics about a podcast. But what if you need more information? What if you’re looking for information about the individual episodes? That’s where RSS feeds come into play!
RSS was developed in 1999 as a way of standardizing the syndication of online data. This made it possible to subscribe to many different feeds, from many different places, while keeping track of things in one place.
RSS feeds are formatted using XML 1.0, and they initially stored only textual data. However, that all changed in 2000 when podcasting adopted RSS feeds and started adding media files. With the release of RSS 0.92, a new element was added: the enclosure element.
Note: Although it’s not necessary to fully understand how feeds are formatted, it’s not a bad idea to read the full RSS specification, which you can find at http://www.rssboard.org/rss-specification.
Let’s take a look at a sample RSS file for a fictitious podcast:
<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
version="2.0">
<channel>
<title>Android Apprentice Podcast</title>
<link>http://rw.aa.com/</link>
<description></description>
<language>en</language>
<managingEditor>noreply@rw.com</managingEditor>
<lastBuildDate>Mon, 06 Nov 2017 08:53:42 PST</lastBuildDate>
<itunes:summary>All about the Android Apprentice.</itunes:summary>
<item>
<title>Episode 999: Kotlin Basics</title>
<link>http://rw.aa.com/episode-999.html</link>
<author>developers@rw.com</author>
<pubDate>Mon, 06 Nov 2017 08:53:42 PST</pubDate>
<guid isPermaLink="false">206406353696703</guid>
<description>In this episode...</description>
<enclosure url="https://rw.aa.com/Kotlin.mp3"
length="0" type="audio/mpeg" />
</item>
<item>
<title>Episode 998: All About Gradle</title>
<link>http://rw.aa.com/episode-998.html</link>
<author>developers@rw.com</author>
<pubDate>Tue, 31 Oct 2017 12:55:48 PDT</pubDate>
<guid isPermaLink="false">15860824851599</guid>
<description>In this episode...</description>
<enclosure url="https://rw.aa.com/Gradle.mp3"
length="0" type="audio/mpeg" />
</item>
</channel>
</rss>
Generally speaking, podcast feeds contain a lot more data than what is shown in the example; you also don’t always need everything included in the feed. Regardless of the extras, they all share some common elements. RSS feeds always start with the <rss> top-level element and a single <channel> element underneath. The <channel> element holds the main podcast details. For each episode, there’s an <item> element.
Notice the <enclosure> element under each <item>. This is the element that holds the playback media.
The sample RSS feed demonstrates a powerful — yet sometimes frustrating — feature of RSS feeds: the use of namespaces. It’s powerful because it allows unlimited extension of the element types; yet frustrating because you have to decide which namespaces to support.
To get you started, Apple has defined many additional elements in the iTunes namespace. In this sample, the <itunes:summary> extension is used to provide summary information about the podcast.
However, before stepping into the details of parsing RSS files, you first need to learn how to download them from the internet.
In Android, there are many choices for handling network requests. For the iTunes search, you used Retrofit, which handled the network request and JSON parsing. However, parsing XML podcast feeds is slightly more challenging.
Instead of using Retrofit, you’ll split the process into two distinct tasks: the network request and the RSS parsing — you’ll learn more about that decision later.
Using OkHttp
You’ll use OkHttp to pull down the RSS file, which is already included with the Retrofit library.
Start by creating a response model to hold the parsed RSS feed response.
In the service package, create a new file and name it RssFeedResponse.kt. Then, add the following:
data class RssFeedResponse(
var title: String = "",
var description: String = "",
var summary: String = "",
var lastUpdated: Date = Date(),
var episodes: MutableList<EpisodeResponse>? = null
) {
data class EpisodeResponse(
var title: String? = null,
var link: String? = null,
var description: String? = null,
var guid: String? = null,
var pubDate: String? = null,
var duration: String? = null,
var url: String? = null,
var type: String? = null
)
}
This represents all of the data you’ll retrieve from an RSS feed.
RssFeedResponse
-
title: The podcast title. -
description: The podcast description. -
summary: The podcast summary. -
lastUpdated: The last update date for the podcast. -
episodes: The list of episodes for the podcast.
EpisodeResponse
-
title: The episode title. -
link: URL link to the episode media file. -
description: The episode description. -
guid: Unique ID for the episode. -
pubDate: Publication date of the episode. -
duration: Episode duration. -
url: URL to the episode landing page. -
type: Type of media for the episode (‘audio’ or ‘video’).
Next, create a new service to process the RSS feed.
In the service package, create a new file and name it RssFeedService.kt. Then, add the following:
class RssFeedService private constructor() {
suspend fun getFeed(xmlFileURL: String): RssFeedResponse? {
}
companion object {
val instance: RssFeedService by lazy {
RssFeedService()
}
}
}
interface FeedService {
@Headers(
"Content-Type: application/xml; charset=utf-8",
"Accept: application/xml"
)
@GET
suspend fun getFeed(@Url xmlFileURL: String): Response<ResponseBody>
}
This is the basic outline of the RSS feed service. It provides a generic interface named FeedService, with a single method named getFeed(). It provides a FeedService implementation named RssFeedService that will eventually implement getFeed().
Looking a bit deeper at the code, getFeed() in the FeedService interface takes a URL pointing to an RSS file and returns the HTTP response via Retrofit 2. You’re doing this by wrapping the OKHTTP3 ResponseBody type with the Retrofit Response<T>. Then, in the RSSFeedService class, there is a getFeed() function which returns a RssFeedResponse if the function successfully retrieved the feed or null if it did not.
You’ll use coroutines, which are built into Retrofit to fetch the RSS file asynchronously. This ensures that the main thread is not blocked during the fetch.
Next, you’ll start implementing getFeed().
The first task is to download the RSS file, but there’s one small issue to address first.
Starting with Android 9 (API Level 28), by default, apps may not use cleartext network traffic. Cleartext traffic results from connections where the URL starts with HTTP, not HTTPS. Since you cannot control the URL of the podcast feed, you’ll set a flag that allows the app to use cleartext traffic.
Open AndroidManifest.xml and add the following as part of the application element header:
android:usesCleartextTraffic="true"
Now, you’re ready to write some code to fetch the podcast feed.
Add the following to getFeed() in RssFeedService:
// 1
val service: FeedService
// 2
val interceptor = HttpLoggingInterceptor()
interceptor.level = HttpLoggingInterceptor.Level.BODY
// 3
val client = OkHttpClient().newBuilder()
.connectTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
if (BuildConfig.DEBUG) {
client.addInterceptor(interceptor)
}
client.build()
// 4
val retrofit = Retrofit.Builder()
.baseUrl("${xmlFileURL.split("?")[0]}/")
.build()
service = retrofit.create(FeedService::class.java)
// 5
try {
val result = service.getFeed(xmlFileURL)
if (result.code() >= 400) {
println("server error, ${result.code()}, ${result.errorBody()}")
return null
} else {
var rssFeedResponse : RssFeedResponse? = null
// return success result
println(result.body()?.string())
// TODO : parse response
return rssFeedResponse
}
} catch (t: Throwable) {
println("error, ${t.localizedMessage}")
}
return null
Time to break the code apart:
- You create a new instance of the
FeedService. - You use
HttpLoggingInterceptorin order to log events around the request. You wouldn’t want these logs to be produced in production, so this interceptor is only added for debug builds. - To make a call with
OkHttpClient, an HTTPRequestobject is required. In this case, you build the object using the URL of the RSS file. If you need to have fine-grained control of the HTTP Request, you can specify headers, caching control, and the request method type. - One problem we have at this point is that the feed URL doesn’t end in a trailing slash “/” but that’s required for Retrofit in the
.baseURL()call. So you add it here. If you just put.baseUrl("$xmlFileURL/")here, it would work, for most cases. But some podcast feed URLs are a bit “special” and would still fail, because of the formatting of the URL, which Retrofit might not like. For example, for ATP (Accidental Tech Podcast) the URL formatting looks like this:https://atp.fm/episodes?format=rss. In this case, we want to get rid of the parameters (everything after the?). So we split the url until the first param and that will be the base url. In the case of ATP, then we gethttps://atp.fm/episodes/. - You attempt to fetch the feed. If the response code is 400 or greater, that indicates a server error and you would need to handle that case. If the call is successful you convert the response body to a string and print it out. This is just a placeholder to check that everything is returned correctly. You’ll implement the actual XML parsing method later.
Note: The
responseBodyobject is represented as a single stream and can be consumed only once. Anything that reads the full stream, such as callingstring()orbytes(), will empty and close the stream. Try callingprintlntwice with theresponseBody.string(), and you’ll see how easy it is to crash the app with ajava.lang.IllegalStateException: closedexception!
To test getFeed(), open PodcastRepo.kt and add the following to the top of getPodcast():
val rssFeedService = RssFeedService.instance
rssFeedService.getFeed(feedUrl) {
}
Build and run the app. Now find a podcast, and tap on a single episode to display the details. Look at the Logcat window, and view the output of the RSS XML file.
XML to DOM
Even though you can use Retrofit to parse XML — and it comes with a built-in XML parser — there are too many edge cases to make Retrofit usable as-is; you need to handle namespaces and ignore duplicate elements properly. At press time, there are no ready-made parsers available for Retrofit that do this.
Fortunately, the DOM parser provided in the standard Android libraries can read the XML data. DOM stands for Document Object Model and represents HTML and XML data as a node-based tree structure. The object returned from the DOM parser is a single top-level Document object with child Nodes underneath. Each node contains a node type, a list of child nodes, a name, text content, and optional attributes.
Here’s a simple XML file:
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Android Apprentice Podcast</title>
<link>http://rw.aa.com/</link>
<item>
<title>Episode 999: Kotlin Basics</title>
<link>http://rw.aa.com/episode-999.html</link>
<enclosure url="https://rw.aa.com/Kotlin.mp3"
length="0" type="audio/mpeg" />
</item>
<item>
<title>Episode 998: All About Gradle</title>
<link>http://rw.aa.com/episode-998.html</link>
<enclosure url="https://rw.aa.com/Gradle.mp3"
length="0" type="audio/mpeg" />
</item>
</channel>
</rss>
Parsing this file results in the following tree structure:
rss
+--channel
|--title
|--link
|--item
| |--title
| |--link
| +--enclosure
+--item
|--title
|--link
+--enclosure
The names shown in the tree are taken from the node name property. If an XML element contains attributes, such as a URL in <enclosure>, the node will store those in an attributes array. All of the data within a node is stored in the textContent property. The key to parsing nodes into your data model structure is recognizing the correct node types and then identifying the node’s location within the tree.
Before writing the parser, you first need to read the RSS file into a Document object. The Document object represents the top-level node in the XML tree and derives from the Node class.
In getFeed(), replace the call to println for the result body, and the TODO comment underneath it, with the following:
val dbFactory = DocumentBuilderFactory.newInstance()
val dBuilder = dbFactory.newDocumentBuilder()
withContext(Dispatchers.IO) {
val doc = dBuilder.parse(result.body()?.byteStream())
}
DocumentBuilderFactory provides a factory that can be used to obtain a parser for XML documents. DocumentBuilderFactory.newInstance() creates a new document builder named dBuilder. dBuilder.parse() is called with the RSS file content stream and the resulting top-level XML Document is assigned to doc. The parse() function is thread blocking, so it needs to be dispatched properly in a thread-safe manner using coroutines. Note we use IO dispatcher here rather than the default dispatcher. IO dispatcher allocates additional threads on top of the ones allocated to the default dispatcher, so we can do blocking IO and fully utilize the machine’s CPU resources at the same time. For a more detailed explanation of this, see the “Where to go from here” section at the end of this chapter.
That’s all there is to parsing the XML file into a DOM.
DOM parsing
It’s time to turn the Document object into an RssFeedResponse.
First, add a helper method to convert from an XML date string to a Date object.
Open DateUtils.kt and add the following method:
fun xmlDateToDate(dateString: String?): Date {
val date = dateString ?: return Date()
val inFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.getDefault())
return inFormat.parse(date) ?: Date()
}
This converts a date string found in the RSS XML feed to a Date object.
Open RssFeedService.kt and add the following method to RssFeedService:
private fun domToRssFeedResponse(node: Node, rssFeedResponse: RssFeedResponse) {
// 1
if (node.nodeType == Node.ELEMENT_NODE) {
// 2
val nodeName = node.nodeName
val parentName = node.parentNode.nodeName
// 3
if (parentName == "channel") {
// 4
when (nodeName) {
"title" -> rssFeedResponse.title = node.textContent
"description" -> rssFeedResponse.description = node.textContent
"itunes:summary" -> rssFeedResponse.summary = node.textContent
"item" -> rssFeedResponse.episodes?.
add(RssFeedResponse.EpisodeResponse())
"pubDate" -> rssFeedResponse.lastUpdated =
DateUtils.xmlDateToDate(node.textContent)
}
}
}
// 5
val nodeList = node.childNodes
for (i in 0 until nodeList.length) {
val childNode = nodeList.item(i)
// 6
domToRssFeedResponse(childNode, rssFeedResponse)
}
}
This is a simplified version of the final parser. It only parses the top-level RSS feed info. You’ll add item parsing next.
This method is designed to be recursive. It operates on a single node at a time and then calls itself to process each child node of the current node.
Don’t worry if this block seems a little confusing at this point. It’ll become more clear when you add episode item parsing next.
Here’s what’s going on with this code:
-
First, you check the
nodeTypeto make sure it’s an XML element. -
You store the node’s name and parent name. Each node, except the top-level one, contains a parent node. You use the name of the parent node to determine where the current node resides in the tree.
-
If the current node is a child of the
channelnode, extract the top-level RSS feed information from this node. -
You use the
whenexpression to switch on thenodeName. Depending on the name, you fill in top-levelrssFeedResponsedata with thetextContentof the node. If the node is an episode item, you add a new emptyEpisodeResponseobject to the episodes list. -
You assign
nodeListto the list of child nodes for the current node. -
For each child node, you call
domToRssFeedResponse(), passing in the existingrssFeedResponseobject. This allowsdomToRssFeedResponse()to keep building out therssFeedResponseobject in a recursive fashion.
Now, you just need to call domToRssFeedResponse(), and pass in the Document XML object and a new RssFeedResponse object.
Add the following after the assignment of the doc variable in getFeed():
val rss = RssFeedResponse(episodes = mutableListOf())
domToRssFeedResponse(doc, rss)
println(rss)
rssFeedResponse = rss
This creates a new empty RssFeedResponse and then calls domToRssFeedResponse() to parse the RSS document into the rssFeedResponse object. It then updates the value of rssFeedResponse and prints out the results.
Build and run the app. Once again, locate and display a podcast episode.
Look at the Logcat window. Notice that the RssFeedResponse top-level information was populated, along with a series of blank episode items.
You’re now ready to finish out the domToRssFeedResponse() by adding the episode item parsing.
In domToRssFeedResponse(), add the following below the assignment of parentName:
// 1
val grandParentName = node.parentNode.parentNode?.nodeName ?: ""
// 2
if (parentName == "item" && grandParentName == "channel") {
// 3
val currentItem = rssFeedResponse.episodes?.last()
if (currentItem != null) {
// 4
when (nodeName) {
"title" -> currentItem.title = node.textContent
"description" -> currentItem.description = node.textContent
"itunes:duration" -> currentItem.duration = node.textContent
"guid" -> currentItem.guid = node.textContent
"pubDate" -> currentItem.pubDate = node.textContent
"link" -> currentItem.link = node.textContent
"enclosure" -> {
currentItem.url = node.attributes.getNamedItem("url")
.textContent
currentItem.type = node.attributes.getNamedItem("type")
.textContent
}
}
}
}
Here’s what’s going on with this code:
- In addition to the name of the parent node, you also need to know the name of the parent of the parent; in other words, the grandparent node.
- If this node is a child of an
itemnode, and theitemnode is a child of achannelnode, then you know it is an episode element. - Because the parsing is recursive, you know that the parent
itemwas parsed already and an empty episode object was added toepisodeslist in therssFeedResponseobject. You assigncurrentItemto the last episode in theepisodeslist. - The
whenexpression is used to switch on the current node’s name. Based on the node name, the current episode item’s details are populated from the node’stextContentproperty. If the node is an enclosure, you extract theurlandtypefrom the node’s attributes and set them on thecurrentItem.
Build and run the app. Just as before, locate and display a podcast episode.
Look at the Logcat window. Notice that the RssFeedResponse is now fully populated with podcasts and episode details.
Congratulations, you created an RSS feed service that returns an RSS response object for any feed you throw at it!
You can now use the new RssFeedService to revisit the PodcastRepo class and add in the missing podcast details from earlier.
Updating the podcast repo
Open PodcastRepo.kt and update the class declaration to the following:
class PodcastRepo(private var feedService: FeedService) {
This declares a new feedService property that you’ll pass into the constructor.
Now, you need a helper method to convert the RssResponse data into Episode and Podcast objects.
Add the following method:
private fun rssItemsToEpisodes(
episodeResponses: List<RssFeedResponse.EpisodeResponse>
): List<Episode> {
return episodeResponses.map {
Episode(
it.guid ?: "",
it.title ?: "",
it.description ?: "",
it.url ?: "",
it.type ?: "",
DateUtils.xmlDateToDate(it.pubDate),
it.duration ?: ""
)
}
}
This uses the map method to convert a list of EpisodeResponse objects into a list of Episode objects. The pubDate string is converted to a Date object using the new xmlDateToDate method.
With this method in place, you can convert the full RssFeedResponse to a Podcast object. Add the following new method:
private fun rssResponseToPodcast(
feedUrl: String, imageUrl: String, rssResponse: RssFeedResponse
): Podcast? {
// 1
val items = rssResponse.episodes ?: return null
// 2
val description = if (rssResponse.description == "")
rssResponse.summary else rssResponse.description
// 3
return Podcast(feedUrl, rssResponse.title, description, imageUrl,
rssResponse.lastUpdated, episodes = rssItemsToEpisodes(items))
}
Here’s what’s happening with the code:
- You assign the list of episodes to
itemsprovided it’s notnull; otherwise, the method returnsnull. - If the
descriptionis empty, thedescriptionproperty is set to the responsesummary; otherwise, it’s set to the responsedescription. - You create a new
Podcastobject using the response data and then return it to the caller.
Now you can update getPodcast() to use the new capabilities.
In PodcastRepo.kt, replace the contents of getPodcast() with the following:
var podcast: Podcast? = null
val feedResponse = feedService.getFeed(feedUrl)
if (feedResponse != null) {
podcast = rssResponseToPodcast(feedUrl, "", feedResponse)
}
return podcast
If the feedResponse is null, you will return null from the function. If feedResponse is valid, then you convert it to a Podcast object and return that.
Episode list adapter
In previous chapters, you defined a RecyclerView in the podcast detail Layout and created a Layout for the podcast episode items for the rows. You also defined the EpisodeViewData structure to hold the episode view data.
Now, you need to add a list Adapter to populate the RecyclerView using EpisodeViewData items.
In the adapter package, create a new Kotlin class and name it EpisodeListAdapter.kt. Then replace the contents with the following:
class EpisodeListAdapter(
private var episodeViewList: List<PodcastViewModel.EpisodeViewData>?
) : RecyclerView.Adapter<EpisodeListAdapter.ViewHolder>() {
inner class ViewHolder(
databinding: EpisodeItemBinding
) : RecyclerView.ViewHolder(databinding.root) {
var episodeViewData: PodcastViewModel.EpisodeViewData? = null
val titleTextView: TextView = databinding.titleView
val descTextView: TextView = databinding.descView
val durationTextView: TextView = databinding.durationView
val releaseDateTextView: TextView = databinding.releaseDateView
}
override fun onCreateViewHolder(
parent: ViewGroup, viewType: Int
): EpisodeListAdapter.ViewHolder {
return ViewHolder(EpisodeItemBinding.inflate(
LayoutInflater.from(parent.context), parent, false))
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val episodeViewList = episodeViewList ?: return
val episodeView = episodeViewList[position]
holder.episodeViewData = episodeView
holder.titleTextView.text = episodeView.title
holder.descTextView.text = episodeView.description
holder.durationTextView.text = episodeView.duration
holder.releaseDateTextView.text = episodeView.releaseDate.toString()
}
override fun getItemCount(): Int {
return episodeViewList?.size ?: 0
}
}
This is a standard list adapter that creates RecyclerView items from a list of EpisodeViewData objects. You’ve seen this pattern several times in previous chapters, so you’ll skip the detailed explanation and move on to hooking up the adapter in the podcast detail fragment.
Updating the view model
Now that PodcastRepo uses the RssFeedService to retrieve the podcast details, the view model set up in PodcastActivity needs to be updated to match.
Open PodcastActivity.kt and replace the assignment of podcastViewModel.podcastRepo in setupViewModels() with the following:
podcastViewModel.podcastRepo = PodcastRepo(FeedService.instance)
This creates a new instance of the FeedService and uses it to create a new PodcastRepo object. The PodcastRepo object is assigned to the podcastViewModel.podcastRepo property.
Next, replace the contents of onShowDetails() with the following:
override fun onShowDetails(podcastSummaryViewData: SearchViewModel.PodcastSummaryViewData) {
podcastSummaryViewData.feedUrl?.let {
showProgressBar() podcastViewModel.getPodcast(podcastSummaryViewData)
}
}
Then add the following function:
private fun createSubscription() {
podcastViewModel.podcastLiveData.observe(this, {
hideProgressBar()
if (it != null) {
showDetailsFragment()
} else {
showError("Error loading feed")
}
})
}
The idea is to move business logic into the ViewModel class, rather than having everything tied into the Activity, which really should just be concerted with displaying things. So, now you need to update the ViewModel to provide the data to be observed in this view. Open PodcastViewModel.kt and add the following variables:
private val _podcastLiveData = MutableLiveData<PodcastViewData?>()
val podcastLiveData: LiveData<PodcastViewData?> = _podcastLiveData
Then replace getPodcast() with the following:
fun getPodcast(podcastSummaryViewData: PodcastSummaryViewData) {
podcastSummaryViewData.feedUrl?.let { url ->
viewModelScope.launch {
podcastRepo?.getPodcast(url)?.let {
it.feedTitle = podcastSummaryViewData.name ?: ""
it.imageUrl = podcastSummaryViewData.imageUrl ?: ""
_podcastLiveData.value = podcastToPodcastView(it)
} ?: run {
_podcastLiveData.value = null
}
}
} ?: run {
_podcastLiveData.value = null
}
}
This way, you are now providing LiveData for the view to subscribe to and automatically get updates.
All that’s left to do now is to set up the RecyclerView with the EpisodeListAdapter.
RecyclerView set up
Open PodcastDetailsFragment.kt and add the following property to the class:
private lateinit var episodeListAdapter: EpisodeListAdapter
Then replace everything after the super call in the content of onViewCreated() as follows:
podcastViewModel.podcastLiveData.observe(viewLifecycleOwner, { viewData ->
if (viewData != null) {
databinding.feedTitleTextView.text = viewData.feedTitle
databinding.feedDescTextView.text = viewData.feedDesc
activity?.let { activity ->
Glide.with(activity).load(viewData.imageUrl).into(databinding.feedImageView)
}
// 1
databinding.feedDescTextView.movementMethod = ScrollingMovementMethod()
// 2
databinding.episodeRecyclerView.setHasFixedSize(true)
val layoutManager = LinearLayoutManager(activity)
databinding.episodeRecyclerView.layoutManager = layoutManager
val dividerItemDecoration = DividerItemDecoration(
databinding.episodeRecyclerView.context, layoutManager.orientation)
databinding.episodeRecyclerView.addItemDecoration(dividerItemDecoration)
// 3
episodeListAdapter = EpisodeListAdapter(viewData.episodes)
databinding.episodeRecyclerView.adapter = episodeListAdapter
}
})
Here’s what’s going on:
- This allows the feed title to scroll if it gets too long for its container.
- This section is the standard setup code for the episode list
RecyclerView. - You create the
EpisodelistAdapterwith the list of episodes inactivePodcastViewDataand assign it toepisodeRecyclerView.
Build and run the app. Once again, find a podcast and display the details for an episode.
Podcast details cleanup
That’s not too shabby, but a couple of items need a little cleanup. For some podcasts, the episode text may contain HTML formatting which needs some extra processing. You also need to format the dates on the episodes. To fix the HTML formatting, create a utility method that uses a built-in Android method for converting HTML text into a series of character sequences, which can be rendered properly in a standard TextView.
In the util package, create a new file and name it HtmlUtils.kt. Replace the contents with the following:
object HtmlUtils {
fun htmlToSpannable(htmlDesc: String): Spanned {
// 1
var newHtmlDesc = htmlDesc.replace("\n".toRegex(), "")
newHtmlDesc = newHtmlDesc.replace("(<(/)img>)|(<img.+?>)".
toRegex(), "")
// 2
val descSpan: Spanned
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
descSpan = Html.fromHtml(newHtmlDesc, Html.FROM_HTML_MODE_LEGACY)
} else {
@Suppress("DEPRECATION")
descSpan = Html.fromHtml(newHtmlDesc)
}
return descSpan
}
}
A single htmlToSpannable method is defined to convert an HTML string into a spanned character sequence. Here’s how it works:
- Before converting the text to a
Spannedobject, some initial cleanup is required. These two lines strip out all\ncharacters and<img>elements from the text. - Android’s
Html.fromHtmlmethod is used to convert the text to aSpannedobject. This breaks the text down into multiple sections that Android will render with different styles.
Note: The second parameter to
fromHtml()is a flag added in Android N. This version of the call is only made if the app is running on Android N or higher. The flag can be set to eitherHtml.FROM_HTML_MODE_LEGACYorHtml.FROM_HTML_MODE_COMPACT, and controls how much space is added between block-level elements. The earlier version offromHtml()has been deprecated, but it’s still required when running on Android M or lower.@Suppress("DEPRECATION")is used to allow the code to compile even though it is deprecated.
Next, you’ll update the list adapter to fix the text formatting as it populates the TextView widgets.
Open EpisodeListAdapter.kt. In onBindViewHolder(), replace the line that assigns holder.descTextView.text with the following:
holder.descTextView.text = HtmlUtils.htmlToSpannable(episodeView.description ?: "")
That takes care of the episode descriptions. You’re ready to clean up the episode date display. :]
First, you need to add a new helper method to convert a Date object to a short date formatted string. Open DateUtils.kt and add the following method:
fun dateToShortDate(date: Date): String {
val outputFormat = DateFormat.getDateInstance(
DateFormat.SHORT, Locale.getDefault())
return outputFormat.format(date)
}
This is the same code you used in jsonDateToShortDate() to create a locale-aware short date string.
Go back to EpisodeListAdapter.kt. In onBindViewHolder(), replace the line that assigns holder.releaseDateTextView.text with the following:
holder.releaseDateTextView.text = episodeView.releaseDate?.let {
DateUtils.dateToShortDate(it)
}
If the releaseDate is not null, then it’s converted to a short date string and assigned to the episode date text view. Build and run the app, and display the details for a podcast. The episode text and date formatting look much better now!
Key Points
- OkHttp is a library included in Retrofit that you can use for doing HTTP requests.
- Data returned by remote APIs might need some processing on the app side before it can be used.
- You can process XML data using the DOM parser included in the standard Android libraries.
Where to go from here?
In the next chapter, you’ll finally hook up the SUBSCRIBE button and build out the persistence layer, which will let users store podcast data offline.
For more details on blocking threads and suspending coroutines, see this article by Roman Elizarov, project lead for the Kotlin programming language: https://elizarov.medium.com/blocking-threads-suspending-coroutines-d33e11bf4761