Home Android & Kotlin Books Android Apprentice

23
Podcast Episodes Written by Fuad Kamal

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

You can unlock the rest of this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

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:

  1. Use OkHttp to load an RSS feed from the internet.
  2. Parse the details in an RSS file.
  3. 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.

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
  )
}
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>
}
android:usesCleartextTraffic="true"
// 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
val rssFeedService = RssFeedService.instance
rssFeedService.getFeed(feedUrl) {
}

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.

<?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>
rss
+--channel
   |--title
   |--link
   |--item
   |  |--title
   |  |--link
   |  +--enclosure
   +--item
      |--title
      |--link
      +--enclosure
val dbFactory = DocumentBuilderFactory.newInstance()
val dBuilder = dbFactory.newDocumentBuilder()
withContext(Dispatchers.IO) {
    val doc = dBuilder.parse(result.body()?.byteStream())
}

DOM parsing

It’s time to turn the Document object into an RssFeedResponse.

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()
}
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)
  }
}
val rss = RssFeedResponse(episodes = mutableListOf())
domToRssFeedResponse(doc, rss)
println(rss)
rssFeedResponse = rss

// 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
      }
    }
  }
}

Updating the podcast repo

Open PodcastRepo.kt and update the class declaration to the following:

class PodcastRepo(private var feedService: FeedService) {
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 ?: ""
    )
  }
}
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))
}
var podcast: Podcast? = null
val feedResponse = feedService.getFeed(feedUrl)
if (feedResponse != null) {
  podcast = rssResponseToPodcast(feedUrl, "", feedResponse)
}
return podcast

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.

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
  }
}

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.

podcastViewModel.podcastRepo = PodcastRepo(FeedService.instance)
override fun onShowDetails(podcastSummaryViewData: SearchViewModel.PodcastSummaryViewData) {
  podcastSummaryViewData.feedUrl?.let {
    showProgressBar()   podcastViewModel.getPodcast(podcastSummaryViewData)
  }
}
private fun createSubscription() {
  podcastViewModel.podcastLiveData.observe(this, {
        hideProgressBar()
        if (it != null) {
            showDetailsFragment()
        } else {
          showError("Error loading feed")
        }
  })
}
private val _podcastLiveData = MutableLiveData<PodcastViewData?>()
  val podcastLiveData: LiveData<PodcastViewData?> = _podcastLiveData
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
    }
}

RecyclerView set up

Open PodcastDetailsFragment.kt and add the following property to the class:

private lateinit var episodeListAdapter: EpisodeListAdapter
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
      }
    })

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.

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
  }
}
holder.descTextView.text =  HtmlUtils.htmlToSpannable(episodeView.description ?: "")
fun dateToShortDate(date: Date): String {
  val outputFormat = DateFormat.getDateInstance(
      DateFormat.SHORT, Locale.getDefault())
  return outputFormat.format(date)
}
holder.releaseDateTextView.text = episodeView.releaseDate?.let {
      DateUtils.dateToShortDate(it)
}

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.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2021 Razeware LLC

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.