Chapters

Hide chapters

Android Apprentice

Third Edition · Android 10 · Kotlin 1.3 · Android Studio 3.6

Before You Begin

Section 0: 4 chapters
Show chapters Hide chapters

Section III: Creating Map-Based Apps

Section 3: 7 chapters
Show chapters Hide chapters

25. Podcast Subscriptions, Part Two
Written by Tom Blankenship

Now that the user can subscribe to podcasts, it’s helpful to notify them when new episodes are available. In this chapter, you’ll update the app to periodically check for new episodes in the background and post a notification if any are found.

Getting started

If you’re following along with your own project, the starter project for this chapter includes an additional icon that you’ll need to complete the section. Open your project then copy the following resources from the provided starter project into yours:

  • src/main/res/drawable-hdpi/ic_episode_icon.png
  • src/main/res/drawable-mdpi/ic_episode_icon.png
  • src/main/res/drawable-xhdpi/ic_episode_icon.png
  • src/main/res/drawable-xxhdpi/ic_episode_icon.png

When you’re done, the res\drawable folder in Android Studio will look like this:

If you don’t have your own project, 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.

Background methods

Checking for new episodes should happen automatically at regular intervals whether the app is running or not. There are several methods available for an application to perform tasks when it’s not running. It’s important to choose the correct one so that it doesn’t affect the performance of other running applications.

There are four primary methods to run tasks in the background:

Alarms

You can use AlarmManager to wake up the app at a specified time so it can perform operations. An Intent is sent to the application to wake it up, and then it can perform the work.

This is not intended for doing tasks at regular intervals, and is therefore not a good solution for this app.

Broadcasts

You can register to receive broadcasts from the system for certain events and then perform tasks. This option is highly restricted to a limited number of broadcasts in apps that target API level 26 or higher.

This is not an option for running a task at regular intervals.

Services

Android provides foreground and background services.

Foreground services are intended to perform work that is visible to the user. For example, in the next chapter, you’ll use a foreground service to play podcasts that will keep playing when the app does not have focus.

Background services are intended for operations that are not visible to the user. Due to concerns with the performance of multiple application running background services at the same time, Android does not allow them for apps targeting API level 26 or higher.

This option is also not a good fit for PodPlay.

Scheduled jobs

This is the approach Google recommends for most background operations. You can specify detailed criteria about when the job will run. Android intelligently determines the best time and takes advantage of system idle time.

One common option for scheduling background jobs is the JobScheduler class. The JobScheduler was introduced with API level 21, but Google has never released a backward compatible version.

With the release of the Android Jetpack, Google added the WorkManager library as part of the Android Architecture components. This expands on the capabilities of JobScheduler and at the same time provides backward compatibility all the way to API level 14.

WorkManager

WorkManager provides a way to schedule background tasks that are considered deferrable. This is in contrast to a background tasks that needs to run immediately and while the user is actively running the application. It also guarantees that the task will run even if the app is closed or the device is rebooted.

Before getting into the details of WorkManager, you’ll first build out the underlying logic to update podcast episodes. This logic will be executed by the WorkManager at periodic intervals.

Here is a sneak peek at how the WorkManager will fit into the application architecture. As you complete each section of this chapter, it may be helpful to refer back to this diagram to see how the pieces all fit together.

Episode update logic

To keep with the current architecture of using the repo for updating podcast data, you need to add a new method in the repo to handle the episode update logic.

The update logic will work as follows:

  1. Walk through all subscribed podcasts.
  2. Download the latest podcast feed.
  3. Determine which episodes are new.
  4. Add the new episodes to the database.
  5. Notify the user when new episodes are available.

Because LiveData doesn’t do much good in the background, you need a method in the DAO class to load the podcasts and episodes without using the LiveData wrapper.

Open db\PodcastDao.kt and add the following method:

@Query("SELECT * FROM Podcast ORDER BY FeedTitle")
fun loadPodcastsStatic(): List<Podcast>

You also need a method that takes a single podcast and returns a list of new episodes available.

Open repository\PodcastRepo.kt and add the following method:

  private fun getNewEpisodes(localPodcast: Podcast, callBack: (List<Episode>) -> Unit) {
// 1
    feedService.getFeed(localPodcast.feedUrl) { response ->
      if (response != null) {
// 2
        val remotePodcast = rssResponseToPodcast(localPodcast.feedUrl, localPodcast.imageUrl, response)
        remotePodcast?.let {
// 3
          val localEpisodes = podcastDao.loadEpisodes(localPodcast.id!!)
// 4
          val newEpisodes = remotePodcast.episodes.filter { episode ->
            localEpisodes.find {
              episode.guid == it.guid
            } == null
          }
// 5
          callBack(newEpisodes)
        }
      } else {
        callBack(listOf())
      }
    }
  }

This method takes a subscribed podcast and downloads its latest episodes. This uses the network to download the episodes in the background; therefore, it accepts a callBack method as the second argument. It executes the callBack method after the episodes are retrieved. Here’s a step-by-step look at how this works:

  1. Use the feedService to download the latest podcast episodes.
  2. Convert the feedService response to the remotePodcast object.
  3. Load the list of local episodes from the database.
  4. Filter the remotePodcast episodes to contain only the ones that are not found in the localEpisodes list and assign to newEpisodes.
  5. Pass the newEpisodes list to the callBack method.
  6. Return an empty list if the feedService does return a response.

You also need a new method that updates an existing podcast with a new episode.

Add the following method:

private fun saveNewEpisodes(podcastId: Long, episodes: List<Episode>) {
  GlobalScope.launch {
    for (episode in episodes) {
      episode.podcastId = podcastId
      podcastDao.insertEpisode(episode)
    }
  }
}

This method inserts the list of episodes into the database for the given podcastId.

Before you can create the main podcast update method, you need one small class. This class will hold the update details for a single podcast.

Add the following inner class to PodcastRepo:

class PodcastUpdateInfo (val feedUrl: String, val name: String, 
    val newCount: Int)

You’re ready to create the podcast update method.

Add the following method to PodcastRepo:

fun updatePodcastEpisodes(callback: (List<PodcastUpdateInfo>) -> Unit) {
// 1
  val updatedPodcasts: MutableList<PodcastUpdateInfo> = mutableListOf()
// 2
  val podcasts = podcastDao.loadPodcastsStatic()
// 3
  var processCount = podcasts.count()
// 4
  for (podcast in podcasts) {
// 5
    getNewEpisodes(podcast) { newEpisodes ->
// 6
        if (newEpisodes.count() > 0) {
            saveNewEpisodes(podcast.id!!, newEpisodes)
            updatedPodcasts.add(PodcastUpdateInfo(podcast.feedUrl, podcast.feedTitle, newEpisodes.count()))
        }
// 7
        processCount--
        if (processCount == 0) {
// 8
            callback(updatedPodcasts)
        }
    }
  }
}

This method walks through all of the subscribed podcasts and updates them with the latest episodes. It executes the passed in callback method with a summary of the podcasts that were updated. Here’s the step-by-step explanation:

  1. Initialize an empty list of PodcastUpdateInfo objects.
  2. Load the subscribed podcasts from the database without the LiveData wrapper.
  3. processCount is initialized to keep track of the background processing.
  4. The podcasts are processed one at a time.
  5. getNewEpisodes() is called to fetch any new episodes. Because getNewEpisodes() runs in the background, it won’t run until the loop iterates over all podcasts and returns to the caller. The processCount is used as a way to track when all background processing has completed. When processCount reaches 0, it’s time to pass the updatedPodcasts list to the callback method.
  6. If there were new episodes, they’re saved to the database, and the updatedPodcasts list is appended with a new PodcastUpdateInfo object. This object stores the feed URL, podcast name and the numbers of episodes added.
  7. The process count is decremented.
  8. If the process count reaches 0, indicating that all podcasts were processed, then the callback method gets called and passes the list of updated podcasts.

WorkManager

Now that all of the support code is in place to update podcast episodes, you can turn your attention back to job scheduling.

As mentioned earlier, WorkManager provides a way to run background tasks that can be deferred to a later time. It provides some nice features such as:

  1. Provides constraints on when the tasks will run to help save battery life. For example, you can specify that a task will only run when the device is plugged into a power source.
  2. Allows for periodic running of a background tasks, such as every hour.
  3. Provides a guarantee that tasks will run even if the app is closed or the device is rebooted.
  4. Allow for tasks to be chained together in complex ways.
  5. Provides a method to observe background task status.
  6. It is backwards compatible with older API levels.

Using the WorkManager class consists of the following steps:

  1. Define a custom Worker class that executes the job logic.
  2. Create a WorkManager request object.
  3. Define a WorkManager request with the required scheduling parameters and the custom Worker class.
  4. Schedule the request through the WorkManager object.

Worker

You must add the WorkManager library to the project first.

Open the module build.gradle file and add the following line to the dependencies section:

implementation "androidx.work:work-runtime-ktx:2.3.4"

Sync the project.

WorkManager uses Worker objects to perform the tasks that it has scheduled to run. Where do the Worker objects come from? You define them! To create your Worker class, you will extend one of the Worker classes provided by WorkManager.

While WorkManager provides several versions of the Worker classes to extend, you’ll use the CoroutineWorker class that is intended for Kotlin users. This class has support for using coroutines to perform the background operations.

Your first task is to define a custom worker class that extends CoroutineWorker. This class gets activated by the WorkManager when a scheduled task is ready to run.

Inside com.raywenderlich.podplay, create a new package and name it worker.

In the worker package, create a new Kotlin file and name it EpisodeUpdateWorker.kt. Replace the contents with the following:

class EpisodeUpdateWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {

  override suspend fun doWork(): Result = coroutineScope {
	Result.success()
  }
}

By extending CoroutineWorker, you are required to define one method in EpisodeUpdateWorker:

  • doWork(): This is where you perform the episode updating logic. WorkManager calls this method when it’s time for you to perform your work. Upon completion of your job logic, you must call Result.success(), Result.failure(), or Result.retry() to indicate that the job is finished. You should call success() if the task completed without issue, failure() if it could not be completed, and retry() if it should be retried.

    Note that this is a suspending function, meaning that it can be called from inside a coroutine to suspend execution, and it can also call other suspending functions. Behind the scenes, WorkManager will call doWork() from inside a coroutine.

    The code above is the minimum required to satisfy WorkManager. You’ll implement the actual update logic next and the doWork() function will be explained in more detail.

Now you can start adding some supporting methods to the worker class.

The purpose of using WorkManager is to allow the episodes to be checked in the background, even if the app is not running. But what happens then? The user won’t know there are new episodes unless they return to the application and check manually.

You need a way to notify the user from outside the app when new episodes are available. This is where Android Notifications come in to play.

Notifications

Notifications are Android’s way of letting you display information outside of your application. The notifications appear as icons in the notification display area at the top of the screen as shown here:

You use NotificationManager to trigger notifications based on a Notification object that’s created with NotificationCompat.Builder.

When you create a notification, it requires the following items at a minimum:

  1. Small icon: Set with setSmallIcon().
  2. Title: Set with setContentTitle().
  3. Detailed text: set with setContentText().

Starting with API level 26 (Oreo), you also need a notification channel. This gives the user more control over the types of notifications they get from the application.

When you create a notification channel, you define some initial settings such as vibration, but then the user can customize each channel and decide how it behaves. For PodPlay, you’ll use a single notification channel.

In addition to the required settings, there are many more ways to customize notifications. PodPlay will stick with the basics, but you’re encouraged to view the documentation at https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder to learn more about the other notification options.

Before creating the notification channel, you need a unique channel ID.

Open EpisodeUpdateWorker.kt and add the following companion object to EpisodeUpdateWorker:

companion object {
  const val EPISODE_CHANNEL_ID = "podplay_episodes_channel"
}

This defines a channel ID that identifies this channel to the notification system. This can be any string that is unique to your app.

Add the following method that creates the PodPlay notification channel:

// 1
@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel() {
  // 2
  val notificationManager = applicationContext.getSystemService(NOTIFICATION_SERVICE) as
  NotificationManager
  // 3
  if (notificationManager.getNotificationChannel(EPISODE_CHANNEL_ID)
      == null) {
    // 4
    val channel = NotificationChannel(EPISODE_CHANNEL_ID,
        "Episodes", NotificationManager.IMPORTANCE_DEFAULT)
    notificationManager.createNotificationChannel(channel)
  }
}

Note you may need to manually add this import:

import android.content.Context.NOTIFICATION_SERVICE

Here’s the breakdown:

  1. Since notification channels are only supported in API 26 or newer, the RequiresApi annotation is used to notify the compiler that this method should only be called when running on API 26 or newer (in this case API 26 is the letter ‘O’ for ‘Oreo’, and therefore we use Build.VERSION_CODES.O).
  2. The notification manager is retrieved using applicationContext.getSystemService() is provided by the CoroutineWorker class. You should never create the notification manager directly.
  3. The notification manager is used to check if the channel already exists.
  4. If the channel doesn’t exist, then a new NotificationChannel object is created with the name “Episodes”. The notification manager is instructed to create the channel.

It’s time to create the method to display a single notification. This method requires a couple of new string resources.

Open res\values\strings.xml and add the following:

<string name="episode_notification_title">New episodes</string>
<string name="episode_notification_text">%1$d new episode(s) for %2$s</string>

The %1$d and %2$s bits are placeholders for parameters that are passed in when this string is accessed.

%1 indicates that it’s a placeholder for the first parameter, $d indicates that this first parameter is a digit. Similarly, %2$s indicates that the second parameter is a string.

In EpisodeUpdateWorker.kt, add a new constant to the companion object:

const val EXTRA_FEED_URL = "PodcastFeedUrl"

Then, add the following method:

private fun displayNotification(podcastInfo: 
    PodcastRepo.PodcastUpdateInfo) {
  // 1
  val contentIntent = Intent(applicationContext, PodcastActivity::class.java)  
  contentIntent.putExtra(EXTRA_FEED_URL, podcastInfo.feedUrl)
  val pendingContentIntent = 
      PendingIntent.getActivity(applicationContext, 0, 
      contentIntent, PendingIntent.FLAG_UPDATE_CURRENT)
  // 2
  val notification = 
      NotificationCompat.Builder(applicationContext, 
          EPISODE_CHANNEL_ID)
      .setSmallIcon(R.drawable.ic_episode_icon)
      .setContentTitle(applicationContext.getString(
          R.string.episode_notification_title))
      .setContentText(applicationContext.getString(
          R.string.episode_notification_text,
          podcastInfo.newCount, podcastInfo.name))
      .setNumber(podcastInfo.newCount)
      .setAutoCancel(true)
      .setContentIntent(pendingContentIntent)
      .build()
  // 4
  val notificationManager = 
      applicationContext.getSystemService(NOTIFICATION_SERVICE)
        as NotificationManager
  // 5
  notificationManager.notify(podcastInfo.name, 0, notification)
}
  1. The notification manager needs to know what content to display when the user taps the notification. You do this by providing a PendingIntent that points to the PodcastActivity.

    When the user taps the notification, the system uses the intent within the PendingIntent to launch the PodcastActivity. The podcast feedUrl is set as an extra on the intent, and you’ll use this information to display the podcast details screen.

  2. The Notification is created with the following options:

    setSmallIcon(): Set to the PodPlay episode icon.

    setContentTitle(): This is the main title shown above the detailed text.

    setContentText(): This is the detailed text. It lets the user know the name of the podcast and the number of new episodes available.

    setNumber(): This tells Android the number of new items associated with this notification. In some cases, this number is shown to the right of the notification.

    setAutoCancel(): Setting this to true tells Android to clear the notification once the user taps on it.

    setContentIntent(): Sets the pending intent that was defined earlier.

  3. The notification manager is retrieved using getSystemService.

  4. The notification manager is instructed to notify the user with the notification object created by the builder.

  5. The first parameter defines a tag, and the second parameter is an id number. These two items combine to create a unique name for the notification. In this case, the podcast name is unique enough, so the id number is always 0. If notify() is called multiple times with the same tag and ID then it will replace any existing notification with the same tag and id.

Finally, you’re ready to implement onWork() with the update logic and trigger the notifications.

Replace doWork() with the following:

  // 1
override suspend fun doWork(): Result = coroutineScope {
  // 2
  val job = async {
    // 3
    val db = PodPlayDatabase.getInstance(applicationContext)
    val repo = PodcastRepo(FeedService.instance,
        db.podcastDao())
    // 4
    repo.updatePodcastEpisodes { podcastUpdates ->
      // 5
      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        createNotificationChannel()
      }
      // 6
      for (podcastUpdate in podcastUpdates) {
        displayNotification(podcastUpdate)
      }
    }
  }
  // 7
  job.await()
  // 8
  Result.success()
}

Here’s what’s happening:

  1. The signature of doWork() deserves some explanation. The syntax Result = coroutineScope { is shortcut way to specify that this function will return the results of the coroutineScope block. coroutineScope is what’s known as a coroutine builder. coroutineScope creates a suspending coroutine block. The last line of the block , Result.success() in this case, is what is returned when the block finishes running. This allows the code inside to be suspended while WorkManager carries on with other tasks.
  2. The async coroutine builder is used to define a coroutine to run the update process in the background. This is simliar to GlobalScope.launch that you have used before. The difference is that it returns a job object that can awaited as shown in step 7.
  3. Instantiate a repo object.
  4. Call repo.updatePodcastEpisodes() to update the podcast episodes in a background thread.
  5. If the device is running Android O or later, create the required notification channel.
  6. Call displayNotification() for each updated podcast.
  7. job.await() is called and suspends the function until the async task coroutine is completed. The next line of code will not execute until the async block finishes.
  8. After all of the podcasts have been processed, call Result.success() to let the work manager know that the job is complete.

WorkManager scheduling

Now that EpisodeUpdateWorker is updating podcast episodes and notifying the user, you’ll finish up by using WorkManager to schedule the EpisodeUpdateWorker.

WorkManager provides several features to control when work is executed. This helps ensure that PodPlay is a good citizen and doesn’t drain battery unnecessarily or adversely impact the performance of other applications.

Besides controlling the interval that your work should execute, you can place other constraints on when the work should execute. These constraints include network, charging state and idle state. For example, with the network type, you can request the job only runs if the network is unmetered (i.e., not on a cell network). You can combine multiple constraints.

An excellent place to configure and start the EpisodeUpdateWorker is in the main podcast Activity. First, you need a new constant to define the job tag.

Open ui\PodcastActivity.kt and add the following line to the companion object:

private const val TAG_EPISODE_UPDATE_JOB = 
   "com.raywenderlich.podplay.episodes"

This defines a unique name for the work request. This is used in the scheduleJobs function defined next.

Add the following method:

private fun scheduleJobs() {
  // 1
  val constraints: Constraints = Constraints.Builder().apply {
    setRequiredNetworkType(NetworkType.CONNECTED)
    setRequiresCharging(true)
  }.build()
  // 2
  val request = PeriodicWorkRequestBuilder<EpisodeUpdateWorker>(
          1, TimeUnit.HOURS)
          .setConstraints(constraints)
          .build()
  // 3
  WorkManager.getInstance(this).enqueueUniquePeriodicWork(
     TAG_EPISODE_UPDATE_JOB,
     ExistingPeriodicWorkPolicy.REPLACE, request)
}

Note: Make sure to select java.util.concurrent for TimeUnit and androidx.work for Constraints when resolving imports.

That’s all you need to kick off a work request with WorkManager.

  1. Create a list of constraints for the worker to run under. WorkManager will not execute your worker until the constraints are met. Contraints are constructed using the Constraints.Builder() function. In this case the following contraints are used.

    setRequiredNetworkType(NetworkType.CONNECTED): Only execute the worker when the device is connected to a network. Other network types include UNMETERED, METERED, and NOT_REQUIRED. UNMETERED is useful if you don’t want the work to execute when connected to a cellular network.

    Note: Be aware that if are experimenting with different options, setting this to NetworkType.UNMETERED may cause the work not to run on the emulator.

    setRequiresCharging(): Only execute the worker when the device is plugged into a power source. This will prevent the worker from draining battery life.

  2. Create a new work request using PeriodicWorkRequestBuilder(). This is one of two primary options for building work requests. The other option is OneTimeWorkRequestBuilder() and it is intended for one time work requests. PeriodicWorkRequestBuilder is for work that you wanted repeated at set intervals.

    There are several constructor variants for PeriodicWorkRequestBuilder(). You are using a version that take two parameters: The repeat interval and a time unit. WorkManager will run the work request once during the interval you specify. It can run at any time during the interval as long as the constraints are met. In this case you are telling it to run once every hour.

    Many additional settings can be applied to PeriodicWorkRequestBuilder, such as an initial delay interval, and input data for the worker. The only setting applied in this case is setConstraints, which applies the contraints you defined in step 1.

  3. Use enqueueUniquePeriodicWork() on an instance of the WorkManager to schedule the work request. The first parameter is a unique name for the work request. Only one work request will run at a time using the name you provide.

    The second parameter, ExistingPeriodicWorkPolicy.REPLACE, specifies that this should replace any existing work with the same name. The other option is ExistingPeriodicWorkPolicy.KEEP and it will allow an existing work request to keep running if there is already one with the same name. Using the REPLACE options is safer when testing different options as it will guarantees that your new options are applied.

    The last parameter is the work request to schedule.

Now you need to call scheduleJobs() when the activity is started. Go back to PodcastActivity.kt and add the following line to the end of onCreate():

scheduleJobs()

Notification Intent

At this point, the episode worker runs, and the notifications work. If the user taps the notification, it activates the PodcastActivity. The only thing left is to handle the notification intent and use it to display the podcast details.

Currently, the only time the app navigates to the podcast details screen is when the user taps a podcast. When this happens, the podcast is made active in the view model and onShowDetails() is called. You’ll simulate this same behavior when the notification intent is received.

First, you need a new method in the podcast view model to set the activate podcast based on a feed URL.

Open viewmodel\PodcastViewModel.kt and add the following method:

fun setActivePodcast(feedUrl: String, callback: (PodcastSummaryViewData?) -> Unit) {
  val repo = podcastRepo ?: return
  repo.getPodcast(feedUrl) {
    if (it == null) {
      callback(null)
    } else {
      activePodcastViewData = podcastToPodcastView(it)
      activePodcast = it
      callback(podcastToSummaryView(it))
    }
  }
}

This method loads the podcast from the database based on the feedUrl. If the podcast is found, it’s converted to a podcast view and set as the active podcast. The podcast summary view data is then passed to the callback.

Now you can look for the intent data in the podcast Activity and use it to set the active podcast and display the details screen.

Open PodcastActivity.kt and add the following to the end of handleIntent():

val podcastFeedUrl = intent.getStringExtra(EpisodeUpdateWorker.EXTRA_FEED_URL)
if (podcastFeedUrl != null) {
  podcastViewModel.setActivePodcast(podcastFeedUrl) {
    it?.let { podcastSummaryView -> onShowDetails(podcastSummaryView) }
  }
}

The podcastFeedUrl is extracted from the Intent. If it’s not null, then setActivePodcast() is called on the view model. After it retrieves the podcast, setActivePodcast() executes the callback and passes in the podcastSummaryView object. Finally, onShowDetails() is called with the podcastSummaryView to display the podcast podcast details screen.

Build and run the app.

You may find it a little difficult to test the new features. You’ll only see evidence that it’s working when one of your subscribed podcasts are updated with new episodes, and this may not happen for days depending on the frequency of the podcast releases.

One way to force the notification to kick in is to remove a single episode when you subscribe to a podcast. This results in the initial subscription missing an episode and causes the podcast update logic to download the missing episode and trigger the notification.

If you want to test with this method, open PodcastViewModel.kt and add the following line in saveActivePodcast() before the call to repo.Save():

it.episodes = it.episodes.drop(1)

This drops the first episode from the Podcast you are subscribing to before it’s saved to the database.

To assist in testing, you may also want to reduce the interval on the work request to 15 minutes, which is the minimum allowed.

Build and run the app.

Add a new podcast subscription and then view the podcast details. The latest episode should be missing from the list.

Close the app and restart. Since WorkManager will normally run the work request once when it is first scheduled, this should trigger the worker to run and update the podcast to add the missing episode.

Here’s the notification area showing two notifications icons for PodPlay:

If you pull down on the notification area you’ll see the notification details:

Tap on a notification, and it launches the podcast details page.

Where to go from here?

After testing, don’t forget to remove the temporary code you added to drop the first podcast when subscribing, and put back in the original repeat interval time.

Congratulations on making it this far! You completed the main podcast management part of the app. In the next chapter, you’ll finally make PodPlay live up to its namesake by implementing the media playback interface.

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