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

26. Podcast Playback
Written by Tom Blankenship

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

At this point, you’ve built a decent podcast management app, but there’s no way to listen to content. Time to fix that!

In this chapter, you’ll learn how to build a media player that plays audio and video podcasts, and integrate it into the Android ecosystem. Building a good media player takes some work. The payoff, however, is an app that works well in the foreground and also while the user performs other tasks on their device.

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/ic_pause_white.png
  • src/main/res/drawable/ic_play_arrow_white.png
  • src/main/res/drawable/ic_episode_icon.png

Also, copy all of the files from the drawable folders, including folders with the -hdpi, -mdpi, -xhdpi, -xxhdpi and -xxxhdpi extensions.

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.

Media player basics

Note: The Media classes mentioned here have backward compatible versions that you’ll use when building the app. The Compat part of the class names have been left out for brevity (i.e., MediaPlayer = MediaPlayerCompat).

MediaPlayer

The built-in core tool that Android provides for media playback is MediaPlayer. This class handles both audio and video and can play content stored locally or streamed from an external URL. MediaPlayer has standard calls for loading media, starting playback, pausing playback and seeking to a playback position.

MediaSession

Android provides another class named MediaSession that is designed to work with any media player, either the built-in MediaPlayer or one of your choosing. The MediaSession provides callbacks for onPlay(), onPause() and onStop() that you’ll use to create and control the media player.

MediaController

The MediaController is used directly by the user interface, which in turn, communicates with a MediaSession, isolating your UI code from the MediaSession. MediaController provides callbacks for major MediaSession events, which you can use to update your UI.

MediaBrowserService

For a better listening experience, you’ll let the podcast play in the background and give the user playback controls from outside of PodPlay. There are many ways a user may want to control audio from outside an app, and MediaBrowserService makes it possible.

MediaBrowser

To control the MediaBrowserService service, you’ll use MediaBrowser. This class connects to the MediaBrowserService service and provides it with a MediaController. Your UI will then use a MediaController to control the playback operations. Other apps can also use their own MediaBrowser to connect to the PodPlay MediaBrowserService.

Building the MediaBrowserService

MediaBrowserService is where all of the hard work of managing the podcast playback happens. You’ll start with a basic implementation that’s just enough to get a podcast playing and then expand the service later.

implementation "androidx.media:media:1.1.0"
import android.os.Bundle
import android.support.v4.media.MediaBrowserCompat
import androidx.media.MediaBrowserServiceCompat

class PodplayMediaService : MediaBrowserServiceCompat() {

  override fun onCreate() {
    super.onCreate()
  }

  override fun onLoadChildren(parentId: String,
      result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
    // To be implemented
  }

  override fun onGetRoot(clientPackageName: String,
      clientUid: Int, rootHints: Bundle?): BrowserRoot? {
    // To be implemented
    return null
  }
}
<service android:name=".service.PodplayMediaService">
  <intent-filter>
    <action android:name="android.media.browse.MediaBrowserService" />
  </intent-filter>
</service>

Create a MediaSession

At the heart of MediaBrowserService is MediaSession. As PodPlay and other apps interact through MediaBrowserService, MediaSession responds. But before it can, you need to create the MediaSession when the service first starts.

private lateinit var mediaSession: MediaSessionCompat
private fun createMediaSession() {
  // 1
  mediaSession = MediaSessionCompat(this, "PodplayMediaService")
  // 2
  setSessionToken(mediaSession.sessionToken)
  // 3
  // Assign Callback
}
class PodplayMediaCallback(val context: Context,
                           val mediaSession: MediaSessionCompat,
                           var mediaPlayer: MediaPlayer? = null) :
    MediaSessionCompat.Callback() {

  override fun onPlayFromUri(uri: Uri?, extras: Bundle?) {
    super.onPlayFromUri(uri, extras)
    println("Playing ${uri.toString()}")
    onPlay()
  }

  override fun onPlay() {
    super.onPlay()
    println("onPlay called")
  }

  override fun onStop() {
    super.onStop()
    println("onStop called")
  }

  override fun onPause() {
    super.onPause()
    println("onPause called")
  }
}
val callBack = PodplayMediaCallback(this, mediaSession)
mediaSession.setCallback(callBack)
createMediaSession()

Connecting the MediaBrowser

There’s no podcast episode player UI in the app yet — which is where you’d typically create the MediaBrowser and connect it to the PodplayMediaService — so for now, you’ll add the MediaBrowser code to the podcast details screen instead.

Create callbacks

Before adding the MediaBrowser object, you need to define the callback classes.

inner class MediaControllerCallback: MediaControllerCompat.Callback() {
  override fun onMetadataChanged(metadata: MediaMetadataCompat?) {
    super.onMetadataChanged(metadata)
    println(
    "metadata changed to ${metadata?.getString(
        MediaMetadataCompat.METADATA_KEY_MEDIA_URI)}")
  }
  
  override fun onPlaybackStateChanged(state: PlaybackStateCompat?) {
    super.onPlaybackStateChanged(state)
    println("state changed to $state")
  }
}
private lateinit var mediaBrowser: MediaBrowserCompat
private var mediaControllerCallback: MediaControllerCallback? = null
private fun registerMediaController(token: MediaSessionCompat.Token) {
  // 1
  val fragmentActivity = activity as FragmentActivity
  // 2
  val mediaController = MediaControllerCompat(fragmentActivity, token)
  // 3
  MediaControllerCompat.setMediaController(fragmentActivity, mediaController)
  // 4
  mediaControllerCallback = MediaControllerCallback()
  mediaController.registerCallback(mediaControllerCallback!!)
}
inner class MediaBrowserCallBacks:
  MediaBrowserCompat.ConnectionCallback() {
    // 1
  override fun onConnected() {
    super.onConnected()
    // 2
    registerMediaController(mediaBrowser.sessionToken)
    println("onConnected")
  }

  override fun onConnectionSuspended() {
    super.onConnectionSuspended()
    println("onConnectionSuspended")
    // Disable transport controls
  }

  override fun onConnectionFailed() {
    super.onConnectionFailed()
    println("onConnectionFailed")
    // Fatal error handling
  }
}

Initialize the MediaBrowser

With the two callback classes created, you’re ready to create the media browser object. This asynchronously kicks off the connection to the browser service.

private fun initMediaBrowser() {
  val fragmentActivity = activity as FragmentActivity
  mediaBrowser = MediaBrowserCompat(fragmentActivity,
      ComponentName(fragmentActivity, 
          PodplayMediaService::class.java),
          MediaBrowserCallBacks(),
          null)
}
initMediaBrowser()

Connect the MediaBrowser

The media browser should be connected when the Activity or Fragment is started. Add the following method:

override fun onStart() {
  super.onStart()
    if (mediaBrowser.isConnected) {
      val fragmentActivity = activity as FragmentActivity  
      if (MediaControllerCompat.getMediaController
          (fragmentActivity) == null) {
        registerMediaController(mediaBrowser.sessionToken)
      }
    } else {
      mediaBrowser.connect()
    }
}

Unregister the controller

The media controller callbacks should be unregistered when the Activity or Fragment is stopped.

override fun onStop() {
  super.onStop()
  val fragmentActivity = activity as FragmentActivity  
  if (MediaControllerCompat.getMediaController(fragmentActivity)
      != null) {
    mediaControllerCallback?.let {
      MediaControllerCompat.getMediaController(fragmentActivity)
          .unregisterCallback(it)
    }
  }
}
I/MediaBrowserService: No root for client com.raywenderlich.podplay from service android.service.media.MediaBrowserService$ServiceBinder$1
E/MediaBrowser: onConnectFailed for ComponentInfo{com.raywenderlich.podplay/com.raywenderlich.podplay.service.PodplayMediaService}
I/System.out: onConnectionFailed

Handle media browsing

To properly handle media browsing, there’s one part of PodplayMediaService you need to complete.

companion object {
  private const val PODPLAY_EMPTY_ROOT_MEDIA_ID = 
      "podplay_empty_root_media_id"
}
return MediaBrowserServiceCompat.BrowserRoot(
    PODPLAY_EMPTY_ROOT_MEDIA_ID, null)
if (parentId.equals(PODPLAY_EMPTY_ROOT_MEDIA_ID)) {
  result.sendResult(null)
}
I/System.out: onConnected

Sending playback commands

With the successful connection in place, it’s time to test out the ability to send play commands and recognize state changes.

interface EpisodeListAdapterListener {
  fun onSelectedEpisode(episodeViewData: EpisodeViewData)
}
class EpisodeListAdapter(
    private var episodeViewList: List<EpisodeViewData>?,
    private val episodeListAdapterListener: 
        EpisodeListAdapterListener) :
    RecyclerView.Adapter<EpisodeListAdapter.ViewHolder>() {
class ViewHolder(
    v: View, private
    val episodeListAdapterListener: 
        EpisodeListAdapterListener) :
    RecyclerView.ViewHolder(v) {
return ViewHolder(LayoutInflater.from(parent.context)
    .inflate(R.layout.episode_item, parent, false),
        episodeListAdapterListener)
init {
  v.setOnClickListener {
    episodeViewData?.let {
      episodeListAdapterListener.onSelectedEpisode(it)
    }
  }
}
private fun startPlaying(
    episodeViewData: PodcastViewModel.EpisodeViewData) {    
  val fragmentActivity = activity as FragmentActivity
  val controller = 
      MediaControllerCompat.getMediaController(fragmentActivity)
  controller.transportControls.playFromUri(
      Uri.parse(episodeViewData.mediaUrl), null)
}
class PodcastDetailsFragment : Fragment(), EpisodeListAdapterListener {
override fun onSelectedEpisode(episodeViewData: EpisodeViewData) {
  // 1
  val fragmentActivity = activity as FragmentActivity
  // 2
  val controller = 
      MediaControllerCompat.getMediaController(fragmentActivity)
  // 3
  if (controller.playbackState != null) {
    if (controller.playbackState.state ==
        PlaybackStateCompat.STATE_PLAYING) {
      // 4
     controller.transportControls.pause()
    } else {
      // 5
      startPlaying(episodeViewData)
    }
  } else {
    // 6
    startPlaying(episodeViewData)
  }
}
episodeListAdapter =
    EpisodeListAdapter(
        podcastViewModel.activePodcastViewData?.episodes,
        this)

Updating media session state

Finally, it’s time to update the media service to set the playback states based on the incoming play commands.

private fun setState(state: Int) {
  var position: Long = -1

  val playbackState = PlaybackStateCompat.Builder()
  .setActions(
      PlaybackStateCompat.ACTION_PLAY or
          PlaybackStateCompat.ACTION_STOP or
          PlaybackStateCompat.ACTION_PLAY_PAUSE or
          PlaybackStateCompat.ACTION_PAUSE)
  .setState(state, position, 1.0f)
  .build()

  mediaSession.setPlaybackState(playbackState)
}
mediaSession.setMetadata(MediaMetadataCompat.Builder()
    .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI,
        uri.toString())
    .build())
setState(PlaybackStateCompat.STATE_PLAYING)
setState(PlaybackStateCompat.STATE_PAUSED)
I/System.out: onConnected
I/System.out: onPlayFromUri https://audio.simplecast.com/2be4cd5d.mp3
I/System.out: onPlay
I/System.out: metadata changed to https://audio.simplecast.com/2be4cd5d.mp3
I/System.out: state changed to PlaybackState {state=3, position=0, buffered position=0, speed=1.0, updated=71964629, actions=519, error code=0, error message=null, custom actions=[], active item id=-1}
I/System.out: onPause
I/System.out: state changed to PlaybackState {state=2, position=0, buffered position=0, speed=1.0, updated=71975052, actions=519, error code=0, error message=null, custom actions=[], active item id=-1}

Using MediaPlayer

Now that you have the MediaBrowser talking to the MediaBrowserService, it’s time to hear some audio. However, it’s up to you to provide the media playback capabilities in response to the media session events. You can use any means you want to play back the media, including third-party media players.

private var mediaUri: Uri? = null
private var newMedia: Boolean = false
private var mediaExtras: Bundle? = null
private fun setNewMedia(uri: Uri?) {
  newMedia = true
  mediaUri = uri
}

Audio Focus

Android uses the concept of audio focus to make sure that apps cooperate with each other and the system, ensuring that audio is played at the appropriate times. Only one app has audio focus at a time, although more than one app can play audio at the same time.

private var focusRequest: AudioFocusRequest? = null
private fun ensureAudioFocus(): Boolean {
  // 1
  val audioManager = this.context.getSystemService(
      Context.AUDIO_SERVICE) as AudioManager

  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    // 2
    val focusRequest = 
      AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
          .run {
          setAudioAttributes(AudioAttributes.Builder().run {
              setUsage(AudioAttributes.USAGE_MEDIA)
              setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
              build()
          })
          build()
          }
    // 3
    this.focusRequest = focusRequest
    // 4
    val result = audioManager.requestAudioFocus(focusRequest)
    // 5
    return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
  } else {
    // 6
    val result = audioManager.requestAudioFocus(null,
        AudioManager.STREAM_MUSIC,
        AudioManager.AUDIOFOCUS_GAIN)
    // 7
    return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
  }
}
if (ensureAudioFocus()) {
  mediaSession.isActive = true
  setState(PlaybackStateCompat.STATE_PLAYING)
}
private fun removeAudioFocus() {
  val audioManager = this.context.getSystemService(
      Context.AUDIO_SERVICE) as AudioManager

  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    focusRequest?.let {
      audioManager.abandonAudioFocusRequest(it)
    }
  } else {
    audioManager.abandonAudioFocus(null)
  }
}
private fun initializeMediaPlayer() {
  if (mediaPlayer == null) {
    mediaPlayer = MediaPlayer()
    mediaPlayer!!.setOnCompletionListener({
      setState(PlaybackStateCompat.STATE_PAUSED)
    })
  }
}
private fun prepareMedia() {
  if (newMedia) {
    newMedia = false
    mediaPlayer?.let { mediaPlayer ->
      mediaUri?.let { mediaUri ->
        mediaPlayer.reset()
        mediaPlayer.setDataSource(context, mediaUri)
        mediaPlayer.prepare()
        mediaSession.setMetadata(MediaMetadataCompat.Builder()
          .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI,
              mediaUri.toString())
        .build())        
      }
    }
  }
}
mediaPlayer?.let {
  position = it.currentPosition.toLong()
}
private fun startPlaying() {
  mediaPlayer?.let { mediaPlayer ->
    if (!mediaPlayer.isPlaying) {
      mediaPlayer.start()
      setState(PlaybackStateCompat.STATE_PLAYING)
    }
  }
}
private fun pausePlaying() {
  removeAudioFocus()
  mediaPlayer?.let { mediaPlayer ->
    if (mediaPlayer.isPlaying) {
      mediaPlayer.pause()
      setState(PlaybackStateCompat.STATE_PAUSED)
    }
  }
}
private fun stopPlaying() {
  removeAudioFocus()
  mediaSession.isActive = false
  mediaPlayer?.let { mediaPlayer ->
    if (mediaPlayer.isPlaying) {
      mediaPlayer.stop()
      setState(PlaybackStateCompat.STATE_STOPPED)
    }
  }
}
if (mediaUri == uri) {
  newMedia = false
  mediaExtras = null
} else {
  mediaExtras = extras
  setNewMedia(uri)
}
initializeMediaPlayer()
prepareMedia()
startPlaying()
pausePlaying()
stopPlaying()

Foreground service

To keep the audio playing, you need to set PodplayMediaService as a foreground service. Any foreground service requires that it display a visible notification to the user. This is done at the time the podcast begins playing.

Media notification

To display the notification, you’ll build it using the same APIs as you did the new episode notification in the last chapter, but this time the expanded notification will display playback controls. You’ll use a special style named MediaStyle on the notification that automatically displays and handles the playback controls.

private fun getPausePlayActions():
    Pair<NotificationCompat.Action, NotificationCompat.Action>  {
  val pauseAction = NotificationCompat.Action(
      R.drawable.ic_pause_white, getString(R.string.pause),
      MediaButtonReceiver.buildMediaButtonPendingIntent(this,
          PlaybackStateCompat.ACTION_PAUSE))

  val playAction = NotificationCompat.Action(
      R.drawable.ic_play_arrow_white, getString(R.string.play),
      MediaButtonReceiver.buildMediaButtonPendingIntent(this,
          PlaybackStateCompat.ACTION_PLAY))

  return Pair(pauseAction, playAction)
}
<string name="pause">Pause</string>
<string name="play">Play</string>
private fun isPlaying(): Boolean {
  if (mediaSession.controller.playbackState != null) {
    return mediaSession.controller.playbackState.state ==
        PlaybackStateCompat.STATE_PLAYING
  } else {
    return false
  }
}
private fun getNotificationIntent(): PendingIntent {
  val openActivityIntent = Intent(this, 
      PodcastActivity::class.java)
  openActivityIntent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
  return PendingIntent.getActivity(
      this@PodplayMediaService, 0, openActivityIntent,
      PendingIntent.FLAG_CANCEL_CURRENT)
}
private const val PLAYER_CHANNEL_ID = "podplay_player_channel"
@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel() {
  val notificationManager =
      getSystemService(Context.NOTIFICATION_SERVICE)
        as NotificationManager
  if (notificationManager.getNotificationChannel
     (PLAYER_CHANNEL_ID) == null) {
    val channel = NotificationChannel(PLAYER_CHANNEL_ID, 
        "Player", NotificationManager.IMPORTANCE_LOW)
    notificationManager.createNotificationChannel(channel)
  }
}
// 1
private fun createNotification(mediaDescription: MediaDescriptionCompat,
                               bitmap: Bitmap?): Notification {

  // 2
  val notificationIntent = getNotificationIntent()
  // 3
  val (pauseAction, playAction) = getPausePlayActions()
  // 4
  val notification = NotificationCompat.Builder(
      this@PodplayMediaService, PLAYER_CHANNEL_ID)
  // 5
  notification
      .setContentTitle(mediaDescription.title)
      .setContentText(mediaDescription.subtitle)
      .setLargeIcon(bitmap)
      .setContentIntent(notificationIntent)
      .setDeleteIntent(
          MediaButtonReceiver.buildMediaButtonPendingIntent
          (this, PlaybackStateCompat.ACTION_STOP))
      .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
      .setSmallIcon(R.drawable.ic_episode_icon)
      .addAction(if (isPlaying()) pauseAction else playAction)
      .setStyle(
              androidx.media.app.NotificationCompat.MediaStyle()
              .setMediaSession(mediaSession.sessionToken)
              .setShowActionsInCompactView(0)
              .setShowCancelButton(true)
              .setCancelButtonIntent(
                  MediaButtonReceiver.
                      buildMediaButtonPendingIntent(
                      this, PlaybackStateCompat.ACTION_STOP)))
  // 6
  return notification.build()
}
private const val NOTIFICATION_ID = 1
private fun displayNotification() {
  // 1
  if (mediaSession.controller.metadata == null) {
    return
  }
  // 2
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    createNotificationChannel()
  }
  // 3
  val mediaDescription = 
      mediaSession.controller.metadata.description
  // 4
  GlobalScope.launch {
    // 5
    val iconUrl = URL(mediaDescription.iconUri.toString())
    // 6
    val bitmap = 
        BitmapFactory.decodeStream(iconUrl.openStream())
    // 7
    val notification = createNotification(mediaDescription, 
        bitmap)
    // 8
    ContextCompat.startForegroundService(
        this@PodplayMediaService,
        Intent(this@PodplayMediaService, 
            PodplayMediaService::class.java))
    // 9
    startForeground(PodplayMediaService.NOTIFICATION_ID, 
        notification)
  }
}
interface PodplayMediaListener {
  fun onStateChanged()
  fun onStopPlaying()
  fun onPausePlaying()
}
var listener: PodplayMediaListener? = null
if (state == PlaybackStateCompat.STATE_PAUSED ||
    state == PlaybackStateCompat.STATE_PLAYING) {
  listener?.onStateChanged()
}
listener?.onStopPlaying()
listener?.onPausePlaying()
class PodplayMediaService : MediaBrowserServiceCompat(), 
    PodplayMediaListener {
override fun onStateChanged() {
  displayNotification()
}

override fun onStopPlaying() {
  stopSelf()
  stopForeground(true)
}

override fun onPausePlaying() {
  stopForeground(false)
}
callBack.listener = this

Media metadata

There’s still one missing part: You haven’t told the media service about the details of the podcast episode yet. You need to pass in the additional episode details and add them to the media session metadata.

controller.transportControls.playFromUri(
    Uri.parse(episodeViewData.mediaUrl), null)
val viewData = podcastViewModel.activePodcastViewData ?: return
val bundle = Bundle()
bundle.putString(MediaMetadataCompat.METADATA_KEY_TITLE,
    episodeViewData.title)
bundle.putString(MediaMetadataCompat.METADATA_KEY_ARTIST,
    viewData.feedTitle)
bundle.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI,
    viewData.imageUrl)

controller.transportControls.playFromUri(
    Uri.parse(episodeViewData.mediaUrl), bundle)
mediaExtras?.let { mediaExtras ->
  mediaSession.setMetadata(MediaMetadataCompat.Builder()
  .putString(MediaMetadataCompat.METADATA_KEY_TITLE, 
      mediaExtras.getString(
          MediaMetadataCompat.METADATA_KEY_TITLE))
  .putString(MediaMetadataCompat.METADATA_KEY_ARTIST,   
      mediaExtras.getString(
          MediaMetadataCompat.METADATA_KEY_ARTIST))
  .putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI,   
      mediaExtras.getString(
          MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI))
  .build())
}

Final pieces

One more item is required to stop the playback if the user dismisses the app from the recent applications list. Add the following method to PodplayMediaService:

override fun onTaskRemoved(rootIntent: Intent?) {
  super.onTaskRemoved(rootIntent)
  mediaSession.controller.transportControls.stop()
}
<receiver
    android:name="androidx.media.session.MediaButtonReceiver" >
  <intent-filter>
    <action android:name="android.intent.action.MEDIA_BUTTON" />
  </intent-filter>
</receiver>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
val artworkUrl100: String,
itunesPodcast.artworkUrl100,

From left to right, Android Oreo (8), Android Marshmallow (6), Android Lollipop (5)
Pwiw xawr vu codwh, Ivpqeug Onaa (3), Aqypoiz Foylmfefdes (0), Uggzuiz Jotdesos (7)

Android Marshmallow Lockscreens
Axbdaoz Suzgnmoynoq Navqvlseurj

Android Wear
Atdteer Yeam

Where to go from here?

That was a lot of work to get playback working, but it’s worth it to have podcasts that play correctly in the background. Take a break and find a relaxing podcast to listen to while you get ready for the next chapter.

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 accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now