Chapters

Hide chapters

Android Apprentice

Fourth Edition · Android 11 · Kotlin 1.4 · Android Studio 4.1

Section II: Building a List App

Section 2: 7 chapters
Show chapters Hide chapters

Section III: Creating Map-Based Apps

Section 3: 7 chapters
Show chapters Hide chapters

24. Podcast Subscriptions, Part One
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.

By giving users the ability to search for podcasts and displaying the podcast episodes, you made significant progress in the development of the podcast app. In this section, you’ll add the ability to subscribe to favorite podcasts.

Over the next two chapters, you’ll add the following features to the app:

  1. Storing the podcast details and episode lists locally for quick access (this chapter).
  2. Displaying the list of subscribed podcasts by default (this chapter).
  3. Notifying the user when new episodes are available (next chapter).

You’ll cover several new topics throughout these two chapters including:

  1. Using Room to store multiple related database tables.
  2. Using JobScheduler services to check for new episodes periodically.
  3. Using local notifications to alert users when new episodes are available.

Getting started

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.

Saving podcasts

The first new feature you’ll implement is the ability to track podcast subscriptions. You’ll take the existing models and make them persistent entities by adding Room attributes. The database will only contain podcasts to which the user subscribes.

Adding Room support

Before getting into the code, you need to bring in the Room libraries.

room_version = '2.2.6'
// Room
implementation ("androidx.room:room-common:$room_version")
kapt ("androidx.room:room-compiler:$room_version")
implementation ("androidx.room:room-runtime:$room_version")
// Kotlin Extensions and Coroutines support for Room
implementation ("androidx.room:room-ktx:$room_version")

Annotating the models

Your first task is to properly annotate the existing models so that Room knows how to store the data. Start by getting the Podcast class into shape.

@Entity
data class Podcast(...)
@PrimaryKey(autoGenerate = true) var id: Long? = null,
return Podcast(null, feedUrl, rssResponse.title, description, 
    imageUrl, rssResponse.lastUpdated, 
    episodes = rssItemsToEpisodes(items))
@Entity(
    foreignKeys = [
      ForeignKey(
          entity = Podcast::class,
          parentColumns = ["id"],
          childColumns = ["podcastId"],
          onDelete = ForeignKey.CASCADE
      )
    ],
    indices = [Index("podcastId")]
)
data class Episode (
@PrimaryKey var guid: String = "",
var podcastId: Long? = null,
return episodeResponses.map {
  Episode(
      it.guid ?: "",
      null,
      it.title ?: "",
      it.description ?: "",
      it.url ?: "",
      it.type ?: "",
      DateUtils.xmlDateToDate(it.pubDate),
      it.duration ?: ""
  )
}

Data access object

Before you can define the main Room database object, you need to create the DAO to read and write to the database. This is where you define all of the SQL statements for the basic database operations. You’ll add additional methods later, but for now, all you need is the ability to save and load podcasts and their corresponding episodes.

// 1
@Dao
interface PodcastDao {
  // 2
  @Query("SELECT * FROM Podcast ORDER BY FeedTitle")
  fun loadPodcasts(): LiveData<List<Podcast>>
  // 3
  @Query("SELECT * FROM Episode WHERE podcastId = :podcastId
      ORDER BY releaseDate DESC")
  suspend fun loadEpisodes(podcastId: Long): List<Episode>
  // 4
  @Insert(onConflict = REPLACE)
  suspend fun insertPodcast(podcast: Podcast): Long
  // 5
  @Insert(onConflict = REPLACE)
  suspend fun insertEpisode(episode: Episode): Long
}
- A failure occurred while executing org.jetbrains.kotlin.gradle.internal.KaptExecution
- java.lang.reflect.InvocationTargetException (no error message)

Define the Room database

All that’s left to do is define the Room database object and have it instantiate the PodcastDao object.

// 1
@Database(entities = [Podcast::class, Episode::class], version = 1)
abstract class PodPlayDatabase : RoomDatabase() {
  // 2
  abstract fun podcastDao(): PodcastDao
    
  // 3
  companion object {
    // 4
    @Volatile
    private var INSTANCE: PodPlayDatabase? = null

    // 5
    fun getInstance(context: Context, coroutineScope: CoroutineScope): PodPlayDatabase {
      val tempInstance = INSTANCE
      if (tempInstance != null) {
        return tempInstance
      }

      // 6
      synchronized(this) {
        val instance = Room.databaseBuilder(context.applicationContext,
            PodPlayDatabase::class.java,
            "PodPlayer")
            .build()
        INSTANCE = instance
        // 7
        return instance
      }
    }
  }
}
- Cannot figure out how to save this field into the database. You can consider adding a type converter for it.
- Cannot figure out how to read this field from a cursor.
var lastUpdated: Date = Date()
var episodes: List<Episode> = listOf()
var releaseDate: Date = Date()

Room type converters

Although Room can’t handle complex types directly, it provides a concept known as TypeConverters that let you define how to convert them to-and-from basic types. This is the perfect solution for the Date properties.

class Converters {
  @TypeConverter
  fun fromTimestamp(value: Long?): Date? {
    return if (value == null) null else Date(value)
  }

  @TypeConverter
  fun toTimestamp(date: Date?): Long? {
    return (date?.time)
  }
}
@Database(entities = arrayOf(Podcast::class, Episode::class), 
    version = 1)
@TypeConverters(Converters::class)
abstract class PodPlayDatabase : RoomDatabase() {...}

Room object references

Now back to the episodes list in the Podcast model. Since Room does not support defining object references in Entity classes, you need to tell it to ignore the episodes property.

@Ignore
var episodes: List<Episode> = listOf()

Update the podcast repo

The podcast repo currently uses only the RssFeedService to retrieve podcast data. One benefit of using the repository pattern is that a single repository can access data from multiple sources or services.

class PodcastRepo(private var feedService: FeedService) {
class PodcastRepo(private var feedService: FeedService,
                  private var podcastDao: PodcastDao) {
val podcastDao : PodcastDao = PodPlayDatabase
      .getInstance(application, viewModelScope)
      .podcastDao()
podcastViewModel.podcastRepo = PodcastRepo(rssService)
podcastViewModel.podcastRepo = PodcastRepo(rssService, podcastViewModel.podcastDao)
fun save(podcast: Podcast) {
  GlobalScope.launch {
    // 1
    val podcastId = podcastDao.insertPodcast(podcast)
    // 2
    for (episode in podcast.episodes) {
      // 3
      episode.podcastId = podcastId
      podcastDao.insertEpisode(episode)
    }
  }
}
fun getAll(): LiveData<List<Podcast>> {
  return podcastDao.loadPodcasts()
}

Updating the view model

One more step is needed before you can connect the subscribe menu item. Since the view only talks to the view model, you need to update the podcast view model to use the new repository methods.

private var activePodcast: Podcast? = null
activePodcast = it
fun saveActivePodcast() {
  val repo = podcastRepo ?: return
  activePodcast?.let {
    repo.save(it)
  }
}
private fun podcastToSummaryView(podcast: Podcast): PodcastSummaryViewData {
  return PodcastSummaryViewData(
      podcast.feedTitle,
      DateUtils.dateToShortDate(podcast.lastUpdated),
      podcast.imageUrl,
      podcast.feedUrl)
}
var livePodcastSummaryData: LiveData<List<PodcastSummaryViewData>>? = null
fun getPodcasts(): LiveData<List<PodcastSummaryViewData>>? {
  val repo = podcastRepo ?: return null
    // 1
  if (livePodcastSummaryData == null) {
    // 2
    val liveData = repo.getAll()
    // 3
    livePodcastSummaryData = Transformations.map(liveData) { podcastList ->
      podcastList.map { podcast ->
        podcastToSummaryView(podcast)
      }
    }
  }

  // 4
  return livePodcastSummaryData
}

Connecting the subscribe menu item

Everything is now in place to hook up the subscribe menu item on the podcast detail screen.

interface OnPodcastDetailsListener {
  fun onSubscribe()
}
private var listener: OnPodcastDetailsListener? = null
override fun onAttach(context: Context) {
  super.onAttach(context)
  if (context is OnPodcastDetailsListener) {
    listener = context
  } else {
    throw RuntimeException(context.toString() + 
        " must implement OnPodcastDetailsListener")
  }
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
  return when (item.itemId) {
      R.id.menu_feed_action -> {
        podcastViewModel.podcastLiveData.value?.feedUrl?.let {
      listener?.onSubscribe()
    }
    true
    }
      else ->
    super.onOptionsItemSelected(item)
  }
}
class PodcastActivity : AppCompatActivity(), PodcastListAdapterListener,
    OnPodcastDetailsListener {
override fun onSubscribe() {
  podcastViewModel.saveActivePodcast()
  supportFragmentManager.popBackStack()
}

Displaying subscribed podcasts

That completes the code to subscribe to a podcast. Of course, subscribing to a podcast isn’t useful if you don’t let the user see their subscriptions!

private fun showSubscribedPodcasts()
{
  // 1
  val podcasts = podcastViewModel.getPodcasts()?.value
  // 2
  if (podcasts != null) {
    toolbar.title = getString(R.string.subscribed_podcasts)
    podcastListAdapter.setSearchData(podcasts)
  }
}
<string name="subscribed_podcasts">Subscribed</string>
private fun setupPodcastListView() {
  podcastViewModel.getPodcasts()?.observe(this, {
    if (it != null) {
      showSubscribedPodcasts()
    }
  })
}
setupPodcastListView()

@Query("SELECT * FROM Podcast WHERE feedUrl = :url")
fun loadPodcast(url: String): Podcast?
val podcastLocal = podcastDao.loadPodcast(feedUrl)
if (podcastLocal != null) {
  podcastLocal.id?.let {
    podcastLocal.episodes = podcastDao.loadEpisodes(it)
  return podcastLocal
  }
}
return PodcastViewData(
    podcast.id != null,
    podcast.feedTitle,
    podcast.feedUrl,
    podcast.feedDesc,
    podcast.imageUrl,
    episodesToEpisodesView(podcast.episodes)
)
fun onUnsubscribe()
<string name="unsubscribe">Unsubscribe</string>
activity?.invalidateOptionsMenu()
override fun onPrepareOptionsMenu(menu: Menu) {
  podcastViewModel.podcastLiveData.observe(viewLifecycleOwner, { podcast ->
  if (podcast != null) {
    menu.findItem(R.id.menu_feed_action).title = if (podcast.subscribed)
          getString(R.string.unsubscribe) else getString(R.string.subscribe)
    }
  })

  super.onPrepareOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
  return when (item.itemId) {
    R.id.menu_feed_action -> {
      if (item.title == getString(R.string.unsubscribe)) {
        listener?.onUnsubscribe()
      } else {
        listener?.onSubscribe()
      }
        true
      }
      else ->
        super.onOptionsItemSelected(item)
  }
}
@Delete
fun deletePodcast(podcast: Podcast)
fun delete(podcast: Podcast) {
  GlobalScope.launch {
    podcastDao.deletePodcast(podcast)
  }
}
fun deleteActivePodcast() {
  val repo = podcastRepo ?: return
  activePodcast?.let {
    repo.delete(it)
  }
}
override fun onUnsubscribe() {
  podcastViewModel.deleteActivePodcast()
  supportFragmentManager.popBackStack()
}

searchMenuItem.setOnActionExpandListener(object: MenuItem.OnActionExpandListener {
  override fun onMenuItemActionExpand(p0: MenuItem?): Boolean {
    return true
  }
  override fun onMenuItemActionCollapse(p0: MenuItem?): Boolean {
    showSubscribedPodcasts()
    return true
  }
})
// 1
showProgressBar()
// 2
podcastViewModel.getPodcast(podcastSummaryViewData)
// 3
hideProgressBar()
showDetailsFragment()
podcastSummaryViewData.feedUrl ?: return
showProgressBar()
podcastViewModel.viewModelScope.launch (context = Dispatchers.Main) {
  podcastViewModel.getPodcast(podcastSummaryViewData)
  hideProgressBar()
  showDetailsFragment()
}

Key Points

In this chapter:

Where to go from here?

Good job! You made it through the first part of podcast subscriptions. Take a breather, and pick up with part two when you’re ready to finish!

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.
© 2024 Kodeco Inc.

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 Kodeco Personal Plan.

Unlock now