24.
Podcast Subscriptions, Part One
Written by Tom Blankenship
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:
- Storing the podcast details and episode lists locally for quick access. (this chapter)
- Displaying the list of subscribed podcasts by default. (this chapter)
- Notifying the user when new episodes are available. (next chapter)
You’ll cover several new topics throughout these two chapters including:
- Using Room to store multiple related database tables.
- Using
JobSchedulerservices to check for new episodes periodically. - 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.
Your first goal is to hook up the subscribe menu item so that it saves the current podcast.
Setting up the database code follows the same general approach used in MapBook:
- Annotate the podcast and episode models with Room attributes.
- Create a database access object (DAO) used by the repositories in the app.
- Create a
RoomDatabaseobject to manage the models and provide the DAO.
Things are slightly more difficult this time around because you have two models — podcast and episode — to manage instead of only one. You also have to manage the relationship between these two models. For example, if you delete a podcast from the database, all associated episodes should also be deleted from the database. However, don’t fret! This is only slightly more difficult because Room does all of the heavy lifting for you.
The database diagram will look like this:
If you recall, the Episode table is nearly a one-to-one match with the Episode model. The only difference here is in the table — you’re adding a foreign ID (podcastId) pointing the model back to a Podcast model. You’ll dive more deeply into this relationship later in the chapter.
Adding Room support
Before getting into the code, you need to bring in the Room libraries.
Open the project build.gradle file and add the following to the buildscript.ext section:
room_version = '2.2.4'
Open the application module build.gradle file and add the following to the dependencies:
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
These are the same libraries you used in PlaceBook. See Chapter 16, “Saving bookmarks with Room” for details about these dependencies.
Sync the gradle file.
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.
Open Podcast.kt and update the class declaration with the @Entity annotation, like so:
@Entity
data class Podcast(...)
The @Entity annotation is the basic requirement for a class managed by Room.
Next, you need to add a primary key to the Podcast table.
Add the following as the first property declaration to Podcast:
@PrimaryKey(autoGenerate = true) var id: Long? = null,
This defines an id that auto-generates as new items are added to the Podcast table.
By adding a new property to the class constructor, you broke the code that constructs a Podcast object. Fortunately, this only occurs in one place in the app.
Open PodcastRepo.kt and update the return call in rssResponseToPodcast() to match the following:
return Podcast(null, feedUrl, rssResponse.title, description,
imageUrl, rssResponse.lastUpdated,
episodes = rssItemsToEpisodes(items))
The only change is to pass in null for the id argument.
Now it’s time to bring Episode up-to-speed and make it an official database entity.
Open Episode.kt and update the class declaration with the @Entity annotation:
@Entity(
foreignKeys = [
ForeignKey(
entity = Podcast::class,
parentColumns = ["id"],
childColumns = ["podcastId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("podcastId")]
)
data class Episode (
Note: If given a choice for importing
ForeignKeyandIndex, select the following versions respectively:
import androidx.room.ForeignKey
import androidx.room.Index
Here, you’re adding some new attributes to define a foreign key and an index on the database.
When you have multiple entities or models that are related, it’s helpful to let Room know about these relationships. The foreignKeys attribute lets you define these relationships and add constraints on them. This helps maintain the database integrity without any extra work on your part.
In this case, you define a single ForeignKey that relates the podcastId property in the Episode entity to the property id in the Podcast entity. There are four fields defined on the ForeignKey attribute:
-
entity: Defines the parent entity. -
parentColumns: Defines the column names on the parent entity (thePodcastclass). -
childColumns: Defines the column names in the child entity (theEpisodeclass). -
onDelete: Defines the behavior when the parent entity is deleted.CASCADEindicates that any time you delete a podcast, all related child episodes are deleted automatically.
Room recommends creating an index on the child table. This prevents a full scan of the database when performing cascading operations. In this case, the indices attributes define podcastId as the index.
There’s no need to add a new property for the PrimaryKey attribute on the Episode entity. Instead, you’ll use the existing guid property. In database terminology, this is known as a natural key, where the id you added to Podcast acts as a surrogate key.
The purpose of a primary key is to provide a unique value for each row in the database, and the guid value naturally meets this criteria.
Update guid with the @PrimaryKey annotation as follows:
@PrimaryKey var guid: String = "",
You need to add the podcastId property that defines the foreign key to the Podcast entity, so inside Episode, add the following property below guid and above title:
var podcastId: Long? = null,
Now that you’ve added a new property to the constructor, you need to fix any places in the code that create a new Episode. Open PodcastRepo.kt and update the return call in rssItemsToEpisodes() with the following:
return episodeResponses.map {
Episode(
it.guid ?: "",
null,
it.title ?: "",
it.description ?: "",
it.url ?: "",
it.type ?: "",
DateUtils.xmlDateToDate(it.pubDate),
it.duration ?: ""
)
}
For the second argument, you pass in null for the podcastId. You’ll fill in this value after inserting the parent Podcast into the database.
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.
Inside com.raywenderlich.podplay, create a new package and name it db.
Next, create a new file inside of db and name it PodcastDao.kt. Then, replace the contents with the following:
// 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")
fun loadEpisodes(podcastId: Long): List<Episode>
// 4
@Insert(onConflict = REPLACE)
fun insertPodcast(podcast: Podcast): Long
// 5
@Insert(onConflict = REPLACE)
fun insertEpisode(episode: Episode): Long
}
Note: If given a choice for importing
QueryandREPLACE, select the following versions respectively:
import androidx.room.OnConflictStrategy.REPLACE
import androidx.room.Query
Let’s break the code down a bit:
-
You define the
PodcastDaointerface with the@Daoannotation. This indicates to the Room library that this is a managed DAO class. -
loadPodcasts()loads all of the podcasts from the database and returns aLiveDataobject. The@Queryannotation is defined to select all podcasts and sort them by their title in ascending order. -
loadEpisodes()loads all of the episodes from the database. The@Queryannotation is defined to select all episodes that match a singlepodcastIdand sort them by the release date in descending order. -
insertPodcast()inserts a single podcast into the database. No SQL statement is required on the@Insertannotation.onConflictis set toREPLACEto tell Room to replace the old record if a record with the same primary key already exists in the database. -
insertEpisode()inserts a single episode into the database.
Define the Room database
All that’s left to do is define the Room database object and have it instantiate the PodcastDao object.
In db, create a new file and name it PodPlayDatabase.kt. Replace the contents with the following:
// 1
@Database(entities = arrayOf(Podcast::class, Episode::class),
version = 1)
abstract class PodPlayDatabase : RoomDatabase() {
// 2
abstract fun podcastDao(): PodcastDao
// 3
companion object {
// 4
private var instance: PodPlayDatabase? = null
// 5
fun getInstance(context: Context): PodPlayDatabase {
if (instance == null) {
// 6
instance = Room.databaseBuilder(context.applicationContext,
PodPlayDatabase::class.java, "PodPlayer").build()
}
// 7
return instance as PodPlayDatabase
}
}
}
Here’s a closer look at what’s happening:
- You define
PodPlayDatabaseas an abstract class that implements theRoomDatabaseinterface. The@Databaseannotation is used to define this as a Room database with two tables:PodcastandEpisode. - The abstract method
podcastDaois defined to return aPodcastDaoobject. Room handles creating the final implementation of thePodcastDaoclass. - A companion object is defined to hold the single instance of the
PodPlayDatabase. - The single instance of the
PodPlayDatabaseis defined and set tonull. -
getInstance()returns a single application-wide instance of thePodPlayDatabase. - If an instance of
PodPlayDatabasehasn’t been created before, it’s created now. You useRoom.databaseBuilder()to instantiate thePodPlayDatabaseobject. - You return the
PodPlayDatabaseobject to the caller.
Build the project using Command-F9 (Control-F9 on Windows). You’ll get the following errors from the compiler:
- 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.
Unfortunately, Android Studio may not point you to the location of the errors.
The error message is telling you that Room doesn’t know how to handle one or more of the fields in the models. Why is that? Because Room only knows how to deal with basic and boxed basic types, not complex types. A boxed basic type is one that is wrapped in an object so it can be made nullable. For example, Integer is the boxed type for the basic type int.
Looking at the Podcast and Episode models, there are three complex properties:
In Podcast:
var lastUpdated: Date = Date()
var episodes: List<Episode> = listOf()
In Episode:
var releaseDate: Date = Date()
To handle the Date and List<Episode> complex types, you’ll use something called TypeConverters.
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.
The List<Episode> property is another matter. In this case, you’re not trying to store episodes in the Podcast table; instead, you are defining a relationship to Episode objects stored in the Episode table. It’s time to take care of the Date properties first and then address the episodes reference.
All you need to do is let Room know how to convert a date to a basic type and then back again. Using type converters, you can easily convert the Date object to a Long, and a Long back to a Date.
Open PodPlayDatabase.kt and add the following class before the PodPlayDatabase class definition:
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)
}
}
Note: If given a choice of imports for
Date, usejava.util.Date
The Converters class is a holder for the two TypeConverter methods. fromTimestamp() converts a Long to a Date, and toTimestamp() converts a Date to a Long. The @TypeConverter annotation is required on all type converters. To let Room know to use these type converters, you need to add a new annotation to the PodPlayDatabase class.
In PodPlayDatabase, sandwich a @TypeConverters annotation between the @Database annotation and the class declaration, so it looks like this:
@Database(entities = arrayOf(Podcast::class, Episode::class),
version = 1)
@TypeConverters(Converters::class)
abstract class PodPlayDatabase : RoomDatabase() {...}
This tells Room to look in the Converters class to find all methods annotated by @TypeConverter. Room recognizes the two methods for handling Dates, and it calls them when reading and writing the releaseDate and lastUpdated fields to the database.
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.
Note: You may be wondering why Room doesn’t allow object references. That’s a valid question, and the Room designers have some good reasons why this isn’t allowed. If you’re curious about the reasons, the following page gives a good explanation: https://developer.android.com/training/data-storage/room/referencing-data.html#understand-no-object-references.
Open Podcast.kt and update the episodes property to match the following:
@Ignore
var episodes: List<Episode> = listOf()
With this field ignored, Room won’t attempt to populate it when loading a Podcast from the database.
Build the app again to verify the errors are gone.
That handles the database access layer; now you need to define some methods in the podcast repo to read and write podcasts and episodes.
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.
You’re ready to add the ability for the podcast repo to access the podcast DAO in addition to the feed service.
Open PodcastRepo.kt and update the constructor from this:
class PodcastRepo(private var feedService: FeedService) {
To this:
class PodcastRepo(private var feedService: FeedService,
private var podcastDao: PodcastDao) {
This adds a new property to hold the PodcastDao object.
Next, you need to update the podcast activity to correctly instantiate the PodcastRepo class with the new podcastDao property.
Open PodcastActivity.kt and replace the following line in setupViewModels():
podcastViewModel.podcastRepo = PodcastRepo(rssService)
with this:
val db = PodPlayDatabase.getInstance(this)
val podcastDao = db.podcastDao()
podcastViewModel.podcastRepo = PodcastRepo(rssService, podcastDao)
You create an instance of PodPlayDatabase and retrieve the PodcastDao object from it. The PodcastRepo is updated to pass in the podcast DAO object in addition to the RSS service.
Great! Now you can go back to the podcast repo and update it with the database access methods.
Open PodcastRepo.kt and add the following method:
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)
}
}
}
This method uses the podcastDao object to insert a Podcast and its associated Episodes into the database.
Here’s a closer look at how this works:
-
First, you insert the
Podcastinto the database.insertPodcast()returns the new primary key assigned to the podcast. -
Using the
forloop, you walk through each episode belonging to the podcast. -
You assign the episode’s
podcastIdto theidof the insertedPodcastto create a relationship between the two. -
Finally, you insert the
episodeinto the database.
Now that the episode is in the database, you need a method to load it from the database.
Add the following new method:
fun getAll(): LiveData<List<Podcast>>
{
return podcastDao.loadPodcasts()
}
This passes the LiveData object from the DAO through to the caller.
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.
First, you need a method to save a podcast. To make it easy to save the currently loaded podcast, add a new property to store the active podcast. This gets updated any time the view loads a new podcast.
Open PodcastViewModel.kt and add the following property to the top of the class:
private var activePodcast: Podcast? = null
In getPodcast(), after the line that reads activePodcastViewData = podcastToPodcastView(it), add the following:
activePodcast = it
This assigns the activePodcast to the podcast loaded by getPodcast(). This allows the podcast view model to keep track of the most recently loaded podcast.
You can now add a method to save the active podcast. Add the following method:
fun saveActivePodcast() {
val repo = podcastRepo ?: return
activePodcast?.let {
repo.save(it)
}
}
This method first checks to make sure the podcastRepo and the activePodcast are not null. If they’re both not null, then the activatePodcast is saved to the repo.
The final addition to the view model is a method to return a view of all the subscribed podcasts.
You’ll return a LiveData version of the podcasts formatted for the summary view.
When you built out the search feature, the SearchViewModel class used a summary view model to return data for the search results. You can reuse this model to format the list of subscribed podcasts.
First, add the following method that converts from a podcast model to a summary view model.
private fun podcastToSummaryView(podcast: Podcast):
PodcastSummaryViewData {
return PodcastSummaryViewData(
podcast.feedTitle,
DateUtils.dateToShortDate(podcast.lastUpdated),
podcast.imageUrl,
podcast.feedUrl)
}
Next, create a method that returns the LiveData list of podcast summary view objects. It’s designed to be invoked multiple times, yet only create the LiveData object once.
Add the following property to the top of the class:
var livePodcastData: LiveData<List<PodcastSummaryViewData>>? = null
This is used to hold the LiveData list of podcast view objects.
Add the following new method:
fun getPodcasts(): LiveData<List<PodcastSummaryViewData>>? {
val repo = podcastRepo ?: return null
// 1
if (livePodcastData == null) {
// 2
val liveData = repo.getAll()
// 3
livePodcastData = Transformations.map(liveData) { podcastList ->
podcastList.map { podcast ->
podcastToSummaryView(podcast)
}
}
}
// 4
return livePodcastData
}
Here’s a closer look:
- If
livePodcastDataisnull, create it. - You retrieve the
LiveDataobject from the podcast repo. This is the list ofPodcastdata objects that now needs to be converted to versions formatted for the view. - Convert the list of
LiveDatapodcast objects to a list ofLiveDataPodcastSummaryViewDataobjects. - Return
livePodcastDatato the caller.
Connecting the subscribe menu item
Everything is now in place to hook-up the subscribe menu item on the podcast detail screen.
The Activity is the best place to determine what action should take place and then update the view accordingly. Therefore, the detail Fragment will listen for the tap on the menu item, and the podcast activity will handle the action.
Open PodcastDetailsFragment.kt and add the following to the end of the class.
interface OnPodcastDetailsListener {
fun onSubscribe()
}
PodcastDetailsFragment requires its parent activity — in this case, the PodcastActivity — to implement the interface and will call the onSubscribe() method when the user activates the menu item.
You might be wondering why you should bother adding this level of abstraction? Why not just use PodcastActivity directly? Because doing it this way is considered good practice if you plan on using PodcastDetailsFragment in other Activities.
Add the following property and method to PodcastDetailsFragment:
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")
}
}
The property holds a reference to the listener. onAttach() is called by the Fragment Manager when the fragment is attached to its parent activity. The context argument is a reference to the parent Activity. If the Activity implements the OnPodcastDetailsListener interface, then you assign the listener property to it. If it doesn’t implement the interface, then an exception is thrown.
Now you need to listen for the user tapping on the subscribe menu item and call the onSubscribe method on the listener.
Add the following override method:
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_feed_action -> {
podcastViewModel.activePodcastViewData?.feedUrl?.let {
listener?.onSubscribe()
}
return true
}
else ->
return super.onOptionsItemSelected(item)
}
}
You call onOptionsItemSelected() when the user selects a menu item. If the menu itemId matches the menu_feed_action (subscribe) item, and the active podcast is not null, then you call onSubscribe() on the listener.
Perfect! Now you need to jump back to the Activity to handle the onSubscribe() call.
Open PodcastActivity.kt and update the class declaration as follows:
class PodcastActivity : AppCompatActivity(), PodcastListAdapterListener,
OnPodcastDetailsListener {
To implement the OnPodcastDetailsListener interface add the following method:
override fun onSubscribe() {
podcastViewModel.saveActivePodcast()
supportFragmentManager.popBackStack()
}
Here, you’re using the view model to save the active podcast, and then you remove the PodcastDetailsFragment by calling popBackStack() on the fragment manager.
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!
The main podcast Activity already contains a RecyclerView that displays a list of podcasts generated from search results. You can reuse the same RecyclerView to display a list of subscribed podcasts.
The idea is that the app will initially display the subscribed podcasts; when the user performs a search, those are replaced with the search results.
You’ll start by updating the podcast Activity to load all of the podcasts and display them in the RecyclerView when the View is first created.
Open PodcastActivity.kt and add the following method:
private fun showSubscribedPodcasts()
{
// 1
val podcasts = podcastViewModel.getPodcasts()?.value
// 2
if (podcasts != null) {
toolbar.title = getString(R.string.subscribed_podcasts)
podcastListAdapter.setSearchData(podcasts)
}
}
Here’s what’s going on with this code:
- You call
getPodcasts()on the view model to get the podcastsLiveDataobject. Thevalueis the most recently returned object of theLiveDatainstance. This value may benullif theLiveDataobject does not have any observers attached yet, but you’ll observe theLiveDataobject when the Activity is created. - If
podcastsis notnull, then you update the podcast list Adapter with thepodcastsobject.
Add the following line to strings.xml to satisfy the subscribed_podcasts resource reference.
<string name="subscribed_podcasts">Subscribed</string>
Add the following method in PodcastActivity.kt:
private fun setupPodcastListView() {
podcastViewModel.getPodcasts()?.observe(this, Observer {
if (it != null) {
showSubscribedPodcasts()
}
})
}
Note: If given import options on
Observer, chooseimport androidx.lifecycle.Observer.
You’ll call this method when the Activity is created. It calls getPodcasts() on the view model and observes the changes to the data. When the data changes, showSubscribedPodcasts() is called and the podcast list Adapter is updated with the latest list of podcasts.
Now you need to call setupPodcastListView() when the view is created.
In onCreate(), add the following line after the call to updateControls():
setupPodcastListView()
Build and run the app.
Search and display the details for a podcast. Tap the subscribe button, and the app returns to the search results.
Behind the scenes, the Observer you created in setupPodcastListView() is called when the database is updated with the subscribed podcast. This will, in turn, update the RecyclerView and display the podcast in the list.
This is working reasonably well, but there are a few things that you need to clean up:
- When you tap on a subscribed podcast, it loads the episodes from the feed URL instead of using what you already have stored in the database. This may not be obvious at first, but if you disable your internet connection, the issue will become clear!
- You can subscribe to a podcast more than once, and it will keep adding to the list.
- You can’t unsubscribe to a podcast.
- There is no way to get back to the subscribed podcast lists once you perform a search.
You can fix the first issue by updating the podcast repo to check the database before it fetches a feed from the internet. First, you need a new method in the DAO that loads a podcast from the database based on the feed URL.
Open PodcastDao.kt and add the following method:
@Query("SELECT * FROM Podcast WHERE feedUrl = :url")
fun loadPodcast(url: String): Podcast?
Next, you need to update the repo logic so that it attempts to load from the database first.
Open PodcastRepo.kt and add the following to the beginning of getPodcast():
GlobalScope.launch {
val podcast = podcastDao.loadPodcast(feedUrl)
if (podcast != null) {
podcast.id?.let {
podcast.episodes = podcastDao.loadEpisodes(it)
GlobalScope.launch(Dispatchers.Main) {
callback(podcast)
}
}
} else {
Also, add a closing brace to the end of getPodcast():
}
}
This attempts to load the podcast from the database. If the podcast is not null, then it loads in the matching episodes from the database and passes the podcast to the callback method.
If the podcast is null, then the existing code block executes and loads the podcast from the internet.
To fix the second and third problems, you need to make the detail Fragment a little smarter. That means it needs to recognize the subscription status of a podcast; if already subscribed, the menu item shows as “Unsubscribe”; if not, the menu item shows as “Subscribe”.
First, you need the View to determine if a podcast is subscribed to or not.
The PodcastViewData object already has a subscribed property, but it’s not being used yet. So it’s time to update the view model to set the subscribed property.
Open PodcastViewModel.kt and update the return call in podcastToPodcastView():
return PodcastViewData(
podcast.id != null,
podcast.feedTitle,
podcast.feedUrl,
podcast.feedDesc,
podcast.imageUrl,
episodesToEpisodesView(podcast.episodes)
)
The only change is to the first parameter passed into PodcastViewData, which is the subscribed flag. If a podcast contains a non-null id value, that means it was loaded from the database. You can use that to determine how to set the subscribed property on PodcastViewData. Set it to true if the podcast id is not equal to null, or false if it is.
Now you can update the detail Fragment so that it sets the state of the subscribe menu item based on the value stored in the subscribed property. You can also update the details listener interface to support an unsubscribe action.
Open PodcastDetailsFragment.kt and add the following line to the OnPodcastDetailsListener interface declaration:
fun onUnsubscribe()
To update the menu item text to display either “Subscribe” or “Unsubscribe” dynamically, you need to save the MenuItem in a local property.
Add the following property to the PodcastDetailsFragment class:
private var menuItem: MenuItem? = null
You need a new method to update the menu item title based on the subscribed state of the podcast.
Add the following method:
private fun updateMenuItem() {
// 1
val viewData = podcastViewModel.activePodcastViewData ?: return
// 2
menuItem?.title = if (viewData.subscribed)
getString(R.string.unsubscribe) else getString(R.string.subscribe)
}
The code you just added:
- Verifies that there is an active podcast on the view model.
- Sets the menu item title based on the
subscribedproperty. If the user already subscribed to the podcast, the title is set to “Unsubscribe”; if not, the title is set to “Subscribe”.
Add the following line to strings.xml to define the R.string.unsubscribe string resource.
<string name="unsubscribe">Unsubscribe</string>
Now you can assign the menuItem property to the menu action item and call updateMenuItem().
In PodcastDetailsFragment.kt, add the following to the end of onCreateOptionsMenu():
menuItem = menu.findItem(R.id.menu_feed_action)
updateMenuItem()
This assigns the menuItem property to the menu item widget and then calls updateMenuItem().
That’s enough to set the correct menu item title. Now you need to update the menu action handling code to subscribe or unsubscribe based on the current state.
Update the line in onOptionsItemSelected() from this:
listener?.onSubscribe()
To this:
if (podcastViewModel.activePodcastViewData?.subscribed) {
listener?.onUnsubscribe()
} else {
listener?.onSubscribe()
}
If the podcast is already subscribed to, then call onUnsubscribe() on the listener. If the podcast is not subscribed to, then call onSubscribe() on the listener.
To complete this feature, you need to define the onUnsubscribe() method in the podcast Activity. Unsubscribing requires removing the podcast from the database, so you’ll need some additional database code first.
Open PodcastDao.kt and add the following method:
@Delete
fun deletePodcast(podcast: Podcast)
Note: Deleting the podcast automatically deletes all related episodes thanks to the foreign key defined in the
@Entityannotation on theEpisodemodel.
Open PodcastRepo.kt and add the following method:
fun delete(podcast: Podcast) {
GlobalScope.launch {
podcastDao.deletePodcast(podcast)
}
}
This calls the deletePodcast method in the background.
Open PodcastViewModel.kt and add the following method:
fun deleteActivePodcast() {
val repo = podcastRepo ?: return
activePodcast?.let {
repo.delete(it)
}
}
This method first checks to make sure the podcastRepo and the activePodcast are not null. If both are not null, then the activatePodcast is deleted from the repo.
Open PodcastActivity.kt and add the following method:
override fun onUnsubscribe() {
podcastViewModel.deleteActivePodcast()
supportFragmentManager.popBackStack()
}
This uses the view model to delete the active podcast and then removes the podcast details Fragment.
Note: If you created duplicate podcast entries by subscribing to the same one multiple times, you’ll need to delete the app before rerunning it. If you don’t do this, the database won’t load the existing episodes correctly.
Build and run the app.
Tap a previously subscribed podcast to display the details screen. The menu action now shows “UNSUBSCRIBE”.
Tap “UNSUBSCRIBE”, and the app returns to the main Activity, and the podcast will be gone!
The final issue you’ll address is getting back to the subscribed podcast list after performing a search.
This is easy enough to correct by listening for the search menu item to close, and then reloading the subscribed podcast list.
Menu items in Android allow you to assign a listener object that responds to the menu expanding and collapsing. You’ll assign the listener, and listen for the collapse action to indicate when the subscribed podcast should be shown again.
Open PodcastActivity.kt. In onCreateOptionsMenu(), after the assignment of the searchMenuItem, add the following:
searchMenuItem.setOnActionExpandListener(object: MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(p0: MenuItem?): Boolean {
return true
}
override fun onMenuItemActionCollapse(p0: MenuItem?): Boolean {
showSubscribedPodcasts()
return true
}
})
You define an OnActionExpandListener object with two required overrides and assign it using setOnActionExpandListener(). You’re not interested in the menu item expanding, so the onMenuItemActionExpand() method is empty.
onMenuItemActionCollapse() is called when the user closes the search widget. In response, you call showSubscribedPodcasts() to display the subscribed podcast items in place of the search results.
Build and run the app.
Search for a podcast, and then press the back arrow to close out the search widget. The display returns to the list of subscribed podcasts.
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!