teaching machines

SENG 440: Lecture 12 – Media Player and Ringtones

Dear students,

Most of the big ideas of mobile computing are behind us. We’ve discussed the event-driven model of computing, offloading tasks from the main thread onto background threads, persisting data, connecting to web services, and the service-oriented architecture of mobile devices. For the rest of the semester, we just have fun. Today we have a look at playing media using Android’s MediaPlayer class. We will do so in the context of creating an app that lets us compose ringtones using Nokia’s antiquated Ringtone Text Transfer Language (RTTTL) specification.

Before we forget, here’s your TODO for next time:

  • For your next quarter sheet, compose your own ringtone using the app we write today. However, instead of writing your RTTTL on paper, share it on Slack #ringtone before next lecture.

Let’s start creating our app, which we’ll call Rattler.

RTTTL

The RTTTL specification provides a standard for describing simple, monophonic tunes. Here are some example songs:

Fifth:d=4,o=5,b=63:8P,8G5,8G5,8G5,2D#5
Auld Lang:d=4,o=6,b=101:g5,c,8c,c,e,d,8c,d,8e,8d,c,8c,e,g,2a,a,g,8e,e,c,d,8c,d,8e,8d,c,8a5,a5,g5,2c
MahnaMahna:d=16,o=6,b=125:c#,c.,b5,8a#.5,8f.,4g#,a#,g.,4d#,8p,c#,c.,b5,8a#.5,8f.,g#.,8a#.,4g,8p,c#,c.,b5,8a#.5,8f.,4g#,f,g.,8d#.,f,g.,8d#.,f,8g,8d#.,f,8g,d#,8c,a#5,8d#.,8d#.,4d#,8d#.

As you will see, it emerged in an era when sound technology in most phones was limited. A ringtone in RTTTL is composed of these terminals:

octave: [4-7]
letter: [abhcdefg]
rest: p
accidental: #
dot: .
duration: 1 | 2 | 4 | 8 | 16 | 32

A limited set of octaves are supported. The letter H refers to the note directly below C. Some countries call this H, reserving B for what other countries call B-flat. Only sharps are supported. If you need an A-flat, use the enharmonic G-sharp.

We have the following production to form a note:

note: duration? letter accidental? octave? dot? | duration? rest dot?

We produce a sequence of notes by separating them with commas:

sequence: (note (',' note)*)?

And we specify a complete ringtone with three colon-delimited sections: a title (which can’t be longer than 10 characters); values for the default duration, default octave, and tempo; and the note sequence.

song: title ':d=' duration ',o=' octave ',b=' [0-9]+ ':' sequence

This format is our ultimate output, but we want to make it slightly easier on the user. Let’s construct our user interface to make writing these songs easier.

UI

We want users to be able to compose tunes that we will eventually encode in RTTTL. We’ll create a UI that allows the user to enter a title, the beats per minute, and a space-separated sequence of notes. (Commas are noisy.) While it’d be amazing to let the user actually plop notes down on a musical staff, that’s going to have to wait for another day. We’ll stick with text. As such, we just need three text fields:

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  tools:context=".MainActivity">

  <EditText
    android:id="@+id/titleBox"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:layout_marginStart="8dp"
    android:layout_marginTop="8dp"
    android:layout_marginEnd="8dp"
    android:ems="10"
    android:hint="Song title..."
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent" />

  <EditText
    android:id="@+id/beatsPerMinuteBox"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:layout_marginStart="8dp"
    android:layout_marginTop="8dp"
    android:layout_marginEnd="8dp"
    android:ems="10"
    android:hint="Beats per minute..."
    android:inputType="number"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@+id/titleBox" />

  <EditText
    android:id="@+id/notesBox"
    android:layout_width="0dp"
    android:layout_height="0dp"
    android:layout_marginStart="8dp"
    android:layout_marginTop="8dp"
    android:layout_marginEnd="8dp"
    android:layout_marginBottom="8dp"
    android:ems="10"
    android:gravity="top"
    android:hint="Enter song..."
    android:inputType="textMultiLine"
    android:text="4c4 4c4 4g4 4g4 4a4 4a4 2g4"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@+id/beatsPerMinuteBox" />
</android.support.constraint.ConstraintLayout>

Our main activity will display this UI and grab references to the three widgets:

class MainActivity : Activity() {
  private lateinit var notesBox: EditText
  private lateinit var titleBox: EditText
  private lateinit var beatsPerMinuteBox: EditText

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    titleBox = findViewById(R.id.titleBox)
    beatsPerMinuteBox = findViewById(R.id.beatsPerMinuteBox)
    notesBox = findViewById(R.id.notesBox)
  }
}

This creates the view and controller. Now let’s attend to the model.

Song

We’ll need a model of our song that will encapsulate the three pieces of state (title, beats per minute, and note sequence) and express the song in RTTTL form. The title and beats per minute can be droppped directly in the RTTTL, but we’ll need to turn the space-separated notes sequence to a comma-separated one. Our first draft might look something like this, which splits the notes on whitespace and then reassembles them with intervening commas:

class Song(var title: String, var beatsPerMinute: Int, var notes: String) {
  fun toRtttl() = "$title:d=4,o=5,b=$beatsPerMinute:${notes.split("\\s+".toRegex()).joinToString(",")}"
}

Notice I named the method toRtttl. Lowercasing acronyms in identifiers seems to be the modern practice. It makes camel case more consistent and addresses those awkward situations where two acronyms butt up against each other, e.g., HttpUrlConnection rather than HTTPURLConnection. Some really unfortunate names can occur when words blur together, as in domain names. A relatively mild one is childrenswear.co.uk.

Model-View Synchronizing

As the user is composing, let’s maintain a Song instance. We’ll give it some default values for the time being:

private var song = Song("Mary Had", 100, "4c4 4c4 4g4 4g4 4a4 4a4 2g4")

When the song has changes, we will need to synchronize the UI to match the underlying model. We’ll add a helper method for this:

override fun onCreate(savedInstanceState: Bundle?) {
  ...
  syncUI()
}

private fun syncUI() {
  titleBox.setText(song.title)
  beatsPerMinuteBox.setText(song.beatsPerMinute.toString())
  notesBox.setText(song.notes)
}

Notice that we update all three widgets. It may be that only one piece of state changed, and we therefore only need to update one widget. If updating the UI were more expensive, we’d want to be more selective.

When the UI has changes, we will need to synchronize the song to match the changes. It’s much more natural to selectively synchronize here, because we have individual event handlers for each widget. We’ll use TextWatchers since these are EditTexts. This is code we’ll add to onCreate:

titleBox.addTextChangedListener(object : TextWatcher {
  override fun afterTextChanged(p0: Editable?) {}
  override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {}
  override fun onTextChanged(text: CharSequence?, p1: Int, p2: Int, p3: Int) {
    song.title = titleBox.text.toString()
  }
})

notesBox.addTextChangedListener(object : TextWatcher {
  override fun afterTextChanged(p0: Editable?) {}
  override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {}
  override fun onTextChanged(text: CharSequence?, p1: Int, p2: Int, p3: Int) {
    song.notes = notesBox.text.toString()
  }
})

beatsPerMinuteBox.addTextChangedListener(object : TextWatcher {
  override fun afterTextChanged(p0: Editable?) {}
  override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {}
  override fun onTextChanged(text: CharSequence?, p1: Int, p2: Int, p3: Int) {
    try {
      song.beatsPerMinute = beatsPerMinuteBox.text.toString().toInt()
    } catch (e: NumberFormatException) {
    }
  }
})

Our app is now a simple song editor. It’s time to make the edited song playable.

ActionBar

To play the composed song, we need a play button. Small little bits of interface like this fit nicely in the action bar that shows up at the top of our activity. This toolbar shows up automatically if we are using a theme that includes it and subclass AppCompatActivity.

For maximal compatibility for older versions of Android, the Android docs recommend not using an action bar theme but including it yourself in your layout. We will ignore their advice to keep things simple.

To get actions to show up in our action bar, we need to provide a menu of them in res/menu. We’ll add this file to our res/menu/actionbar.xml:

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:app="http://schemas.android.com/apk/res-auto"
  xmlns:android="http://schemas.android.com/apk/res/android">
  <item
    android:id="@+id/playButton"
    android:icon="@drawable/play_button"
    android:title="Play"
    app:showAsAction="always" />
  <item
    android:id="@+id/setRingtoneButton"
    android:title="Set as ringtone"
    app:showAsAction="never" />
</menu>

We’ll include the ringtone action, but we’ll handle that later. We get our play button drawable from Google’s Material Design assets. We could grab it from the Material website, or right-click on res and click New / Vector Asset.

To associate this menu with the action bar, we define onCreateOptionsMenu:

override fun onCreateOptionsMenu(menu: Menu): Boolean {
  menuInflater.inflate(R.menu.actionbar, menu)
  return true
}

The return value indicates whether or not we want to display the menu. We do.

To handle clicks on the menu items, we implement onOptionsItemSelected:

override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
  R.id.playButton -> {
    play()
    true
  }
  else -> {
    super.onOptionsItemSelected(item)
  }
}

The play method doesn’t exist yet. We’ll write it to play our song with Android’s MediaPlayer.

MediaPlayer

MediaPlayer is designed to play a single audio or video resource. You can play its resource one time or multiple times, but once you no longer need its services, you are advised to free it because it consumes a lot of resources. Let’s track a player as an instance variable. We’ll make it nullable to minimize any chance for memory leaks:

private var player: MediaPlayer? = null

Our play method will create an instance and set it playing:

private fun play() {
  player = MediaPlayer.create(this, uri)
  player.play()
}

But this code needs a Uri. Our song doesn’t have one; it only lives in memory. We must commit it to a file—a temporary one is fine—in order to play it:

val file = File.createTempFile("rattler_", ".rtttl", cacheDir)
song.write(file)

When the song has finished playing, let’s release the media player with this method:

private fun releasePlayer() {
  player?.stop()
  player?.release()
  player = null
}

To invoke this code, we hook into the player’s completion event, where we can also delete our temporary file:

player = MediaPlayer.create(this, Uri.fromFile(file))
player.start()
player.setOnCompletionListener {
  releasePlayer()
  file.delete()
}

In case the user hits play a bunch of times, let’s also stop any currently playing song at the start of play. All told, our method will end up looking something like this:

private fun play() {
  releasePlayer()

  val file = File.createTempFile("rtttl_", ".rtttl", cacheDir)
  song.write(file)

  player = MediaPlayer.create(this, Uri.fromFile(file)).apply {
    start()
    setOnCompletionListener {
      releasePlayer()
      file.delete()
    }
  }
}

If the app goes into the background, it would be nice of us to stop the player. Let’s add an onStop:

override fun onStop() {
  super.onStop()
  releasePlayer()
}

We can now play the song within the app. But let’s set it as the device’s ringtone!

Setting Ringtone

Programmatically setting the device’s ringtone is underdocumented. StackOverflow has several strains of some old code that doesn’t work on modern Android. The core idea is that we need to get an RTTTL file registered in the device’s media library, and then provide a URI to the media library’s version of the file to RingtoneManager.

The first step is get the RTTTL file written to a public location—on external storage. Once it’s there, we force the media library to observe this new file and add it to its collection. We’ll add a setRingtone method to accomplish this:

private fun setRingtone() {
  val file = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_RINGTONES), "rattler.rtttl")
  song.write(file)

  MediaScannerConnection.scanFile(this, arrayOf(file.absolutePath), null) { _, uri ->
    RingtoneManager.setActualDefaultRingtoneUri(this, RingtoneManager.TYPE_RINGTONE, uri)
    runOnUiThread {
      Toast.makeText(this, "Ringtone is set.", Toast.LENGTH_SHORT).show()
    }
  }
}

The scanning doesn’t happen on the UI thread, so the Toast call will fail if we’re not careful. We schedule it to run there with the runOnUiThread call.

We’d have to do something similar if our app added images that we want the user to see in the image gallery.

This code will not automatically work. Writing to external storage is considered a dangerous permission. Changing the ringtone also requires a special permission. We must declare both of these in the manifest:

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_SETTINGS" />

Since WRITE_EXTERNAL_STORAGE is dangerous, we must get runtime approval from the user. WRITE_SETTINGS is strange in that we can’t just ask the user if it’s okay to adjust the system settings. We must ask them to actively enable the permission. The code to handle all these permissions really gets gross, so I created the PermittedActivity to manage all of this out of your view:

package org.twodee.rattler

import android.app.AlertDialog
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.support.v4.app.ActivityCompat
import android.support.v7.app.AppCompatActivity

open class PermittedActivity : AppCompatActivity() {
  private val requests: MutableMap<Int, PermissionsRequest> = mutableMapOf()

  fun requestPermissions(permissions: Array<String>, requestId: Int, onSuccess: () -> Unit, onFailure: () -> Unit) {
    // The WRITE_SETTINGS permission must be granted using a different
    // scheme. Frustrating.
    val hasWriteSettings = permissions.contains(android.Manifest.permission.WRITE_SETTINGS)
    val needsWriteSettings = hasWriteSettings && !Settings.System.canWrite(this)
    val remaining = if (hasWriteSettings) {
      permissions.filter { it != android.Manifest.permission.WRITE_SETTINGS }
    } else {
      permissions.toList()
    }

    // If we're on early Android, runtime requests are not needed,
    // so we assume permission has already been granted by listing
    // the permissions in the manifest.
    val ungranted = when {
      Build.VERSION.SDK_INT < Build.VERSION_CODES.M -> listOf()
      else -> remaining.filter { ActivityCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED }
    }

    // If all but the WRITE_SETTINGS permission has been granted...
    if (ungranted.isEmpty()) {
      if (needsWriteSettings) {
        requests[requestId] = PermissionsRequest(needsWriteSettings, onSuccess, onFailure)
        promptForWriteSettings(requestId)
      } else {
        onSuccess()
      }
    }

    // Otherwise, request the ungranted permissions.
    else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
      requests[requestId] = PermissionsRequest(needsWriteSettings, onSuccess, onFailure)
      ActivityCompat.requestPermissions(this, ungranted.toTypedArray(), requestId)
    }
  }

  override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)

    // If all the requested permissions have been granted, we're ready to
    // trigger success! Right? Maybe. We might still need WRITE_SETTINGS.
    requests[requestCode]?.let { request ->
      if (grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
        if (request.needsWriteSettings) {
          promptForWriteSettings(requestCode)
        } else {
          request.onSuccess.invoke()
          requests.remove(requestCode)
        }
      } else {
        request.onFailure.invoke()
        requests.remove(requestCode)
      }
    }
  }

  override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)

    // Our last hurdle... Did we get WRITE_SETTINGS?
    requests[requestCode]?.let { request ->
      if (Settings.System.canWrite(this)) {
        request.onSuccess.invoke()
      } else {
        request.onFailure.invoke()
      }
      requests.remove(requestCode)
    }
  }

  private fun promptForWriteSettings(requestId: Int) {
    val builder = AlertDialog.Builder(this)
    builder.setMessage("This operation requires the ability to modify system settings. Please grant this permission on the next screen.")
    builder.setPositiveButton("Okay") { _, _ ->
      startActivityForResult(Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS, Uri.parse("package:$packageName")), requestId)
    }
    builder.show()
  }

  class PermissionsRequest(val needsWriteSettings: Boolean, val onSuccess: () -> Unit, val onFailure: () -> Unit)
}

Once we subclass PermittedActivity, we can wrap our call to setRingtone in another method that first gains the appropriate permissions:

private fun setRingtoneMaybe() {
  val permissions = arrayOf(android.Manifest.permission.WRITE_EXTERNAL_STORAGE, android.Manifest.permission.WRITE_SETTINGS)
  requestPermissions(permissions, 100, {
    setRingtone()
  }, {
    Toast.makeText(this, "Unable to set ringtone.", Toast.LENGTH_LONG).show()
  })
}

Finally, we can add a clause to when in onOptionsItemSelected:

R.id.setRingtoneButton -> {
  setRingtoneMaybe()
  true
}

That’s enough for today. See you next time!

Sincerely,

P.S. It’s time for a haiku!

You are so ping prone
A thing owned by my ringtone
Bow down to King Phone!

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *