Skip to content
Malachi de AElfweald edited this page Dec 10, 2018 · 75 revisions

Goal

Learn how to load, save and convert data.

Table of Contents

Data is a necessary part of any application. We will show you a few ways you can store and retrieve data on Android.

buildConfigField and BuildConfig.java

In your IDE menu bar, click on Navigate and notice the shortcut for Class.... For me it is currently Ctrl+N. For a Mac, it is Command+O. It will be different for various platforms, and can also be changed by you in your File | Settings | Keymap. While you can use the menu to open this dialog, it is worth learning as you will use it a lot.

You can also find a list of the default shortcuts here.

Open that dialog now.

Search for BuildConfig and open the found class.

It's important to click inside the search field. If it is not in focus, you could accidentally edit your source code.

/**
 * Automatically generated file. DO NOT MODIFY
 */
package com.aboutobjects.curriculum.readinglist;

public final class BuildConfig {
  public static final boolean DEBUG = Boolean.parseBoolean("true");
  public static final String APPLICATION_ID = "com.aboutobjects.curriculum.readinglist";
  public static final String BUILD_TYPE = "debug";
  public static final String FLAVOR = "";
  public static final int VERSION_CODE = 1;
  public static final String VERSION_NAME = "1.0";
}

You will note that even though we selected a Kotlin project, this file was still created as a Java class.

This class has some constants for things like your application package, version and whether it is the debug build.

You will also notice that it tells you not to modify it. So how do you add new values to it?

Open your module-level build.gradle. That's the one that says build.gradle (Module: app).

int

First, let's look at how to store a number. Inside the android/defaultConfig closure, add this line:

buildConfigField "int", "SAMPLE_NUMBER", '123'

This is specifying that we want an int named SAMPLE_NUMBER with a value of 123.

Once you make this change, the IDE will tell you that a sync is necessary.

AS Sync

This is because you have changed the rules of the build and it needs to regenerate files. The reason this wasn't necessary earlier is because you were only modifying resources and not the build script itself.

Click the Sync Now button.

It is possible to automatically sync whenever a change is made. Unfortunately, this has the side effect on constantly syncing and slowing you down while you are making a lot of changes to those files.

Once the sync has finished, go back to your BuildConfig.java and notice the new entries.

// Fields from default config.
public static final int SAMPLE_NUMBER = 123;

You might be wondering why the number has to be wrapped in single-quotes. Try removing them in your build.gradle and re-syncing.

Once you have completed that test, change the code back and re-sync.

boolean

What about true/false instead of a number?

In the same way, add another entry to your build.gradle:

buildConfigField "boolean", "SAMPLE_BOOLEAN", 'true'

Once you sync, the new value will show up in your BuildConfig.

You might be asking why the value 'true' instead of just true. Try it.

String

That's all well and good, but you would like something a bit more complex right? Maybe a string? We've got you covered.

buildConfigField "String", "SAMPLE_STRING", '"This is a string"'

Sync and verify.

Now wait a sec, why the double-quotes inside the single-quotes? Try with just double quotes and look at your BuildConfig results.

Unlike the other tests, the auto-generation completes; but the code it generates would not be viable.

rootProject.ext

Gradle has the ability to specify variables in the project-level build.gradle. How would you then use them inside the module-level build.gradle buildConfigField?

In your project-level build.gradle, let's add a global variable. That's the one that says build.gradle (Project: ReadingList).

If you checked out our git repository, it would read build.gradle (Project: __name_of_git_directory__).

After buildscript but before allprojects add a new project.ext closure at the same level as those two, like so:

// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
    ext.kotlin_version = '1.3.10'
    repositories {
        google()
        jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.2.1'
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"

        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}

project.ext {
    sampleVariable = "This is a sample variable"
}

allprojects {
    repositories {
        google()
        jcenter()
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

Now, we want to reference that sampleVariable. Back in your module-level build.gradle, add another buildConfigField:

buildConfigField "String", "SAMPLE_VARIABLE", "\"${rootProject.ext.sampleVariable}\""

You might have noticed that the IDE wanted to sync when you modified the first file. Since we do not have it set to auto-sync, we were able to edit both files before syncing.

Sync and look at your BuildConfig.

You are most likely wondering why we used double-quotes on the outside instead of single-quotes and then escaped the inside one. Try using '"${rootProject.ext.sampleVariable}"' instead and see what happens.

The single-quotation marks prevent the variable from being processed, thus treating the ${ as a string literal instead of as instructions.

Before moving on, your BuildConfig.java should have these entries:

// Fields from default config.
public static final boolean SAMPLE_BOOLEAN = true;
public static final int SAMPLE_NUMBER = 123;
public static final String SAMPLE_STRING = "This is a string";
public static final String SAMPLE_VARIABLE = "This is a sample variable";

SharedPreferences

Google recently introduced the Android KTX (Kotlin Extensions) to reduce some of the boilerplate code. Let's use that for this example.

Migrate to AndroidX

Google also recently started changing the package structure of their APIs. In order to use KTX, we need to first migrate our application to the new API structure, AndroidX. You can read more about the change here.

Refactor AndroidX 1

First, in the toolbar go to Refactor | Migrate to AndroidX.

Refactor AndroidX 2

It will give you a warning. Select Migrate

Refactor AndroidX 3

It shows you what it will do. Choose Do Refactor.

Add Android KTX

In the dependencies closure of your module-level build.gradle, add the KTX core dependency

implementation 'androidx.core:core-ktx:1.0.1'

You can find the latest version of the various KTX modules on the Android Developer site.

Sync and re-deploy your project.

At this point, your application should look exactly the same -- but we have added some new functionality for us to use.

Editing with KTX

SharedPreferences are often used to keep track of session or local data. It might be used to keep track of the timestamp the user last posted a picture or a webservice token for API access.

Our application doesn't really have any functionality yet, so for now let's do something simple.

Open your BookListActivity.

Inside the class, we'll add a companion object. If you are unfamiliar with Kotlin, think of this as a block of static variables and functions in Java.

package com.aboutobjects.curriculum.readinglist

import androidx.appcompat.app.AppCompatActivity

class BookListActivity : AppCompatActivity() {
    companion object {

    }
}

We'll add a couple static variables inside that block.

The timestampPattern comes directly from the Javadocs for SimpleDateFormat. Specifying const is like final in Java.

// from https://docs.oracle.com/javase/7/docs/api/java/text/SimpleDateFormat.html
const val timestampPattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX"

KEY_TIMESTAMP will be a unique key to be stored in our SharedPreferences. Often strings in Kotlin have variables embedded in them, as in this case. The ${BookListActivity::class.java.name} portion translates to the equivalent of BookListActivity.class.getName() in Java. Unfortunately, Kotlin does not recognize this as something that can be a constant.

val KEY_TIMESTAMP = "${BookListActivity::class.java.name}::timestamp"

At this point, you should have something like:

package com.aboutobjects.curriculum.readinglist

import androidx.appcompat.app.AppCompatActivity

class BookListActivity : AppCompatActivity() {
    companion object {
        // from https://docs.oracle.com/javase/7/docs/api/java/text/SimpleDateFormat.html
        const val timestampPattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX"

        val KEY_TIMESTAMP = "${BookListActivity::class.java.name}::timestamp"
    }
}

Now we will add some additional variables and functions inside BookListActivity (outside of the companion object).

The timestampFormat value is created the first time it is called because of the by lazy keywords.

private val timestampFormat: SimpleDateFormat by lazy {
    // Don't set Locale.getDefault() in companion because user may change it at runtime
    SimpleDateFormat(timestampPattern, Locale.getDefault())
}

When the class does not yet have an import for the class you are referencing, the IDE will try to suggest possible solutions. If you click on the red highlighted word, the IDE will prompt you to select one. I tend to use Alt+Enter to bring up the suggestions, but it will also add a clickable lightbulb in the left gutter near the line numbers.

Make sure to use the import java.text.SimpleDateFormat not the icu one.

If you put this in the companion object, the IDE will complain that Locale.getDefault() should not be called statically.

Next, we have a function called timestamp that just uses the above format to convert the current time to a String and return it.

private fun timestamp(): String {
    return timestampFormat.format(Calendar.getInstance().time)
}

When we specify the prefs value, we are giving it the name of a file to use (samplePrefs.xml), and telling it what mode to use (MODE_PRIVATE). The getSharedPreferences method exists on the parent AppCompatActivity.

private val prefs: SharedPreferences by lazy {
    getSharedPreferences("samplePrefs", Context.MODE_PRIVATE)
}

Next, we are going to override a method in the parent class. Press Ctrl+O and select the onCreate(savedInstanceState: Bundle?): Unit method. Click OK. There are multiple onCreate methods; make sure you select the correct one.

With all the boilerplate out of the way, we now use the Android KTX inside the new onCreate to just store (with the putString) when the user last launched this activity.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_book_list)
    prefs.edit {
        putString(KEY_TIMESTAMP, timestamp())
    }
}

The IDE may try to get you to auto-import the android.provider.Settings.* from the putString call. That is the wrong import. Click on the word edit in prefs.edit and it will prompt you to import the androidx.core.content.edit. Once that is completed, the putString method is no longer red. In general, it's a good idea to resolve imports outside-in and top-down.

The final class should look something like this:

package com.aboutobjects.curriculum.readinglist

import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.edit
import java.text.SimpleDateFormat
import java.util.*

class BookListActivity : AppCompatActivity() {
    companion object {
        // from https://docs.oracle.com/javase/7/docs/api/java/text/SimpleDateFormat.html
        const val timestampPattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX"

        val KEY_TIMESTAMP = "${BookListActivity::class.java.name}::timestamp"
    }

    private val timestampFormat: SimpleDateFormat by lazy {
        // Don't set Locale.getDefault() in companion because user may change it at runtime
        SimpleDateFormat(timestampPattern, Locale.getDefault())
    }

    private fun timestamp(): String {
        return timestampFormat.format(Calendar.getInstance().time)
    }

    private val prefs: SharedPreferences by lazy {
        getSharedPreferences("samplePrefs", Context.MODE_PRIVATE)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_book_list)
        prefs.edit {
            putString(KEY_TIMESTAMP, timestamp())
        }
    }
}

Save and run your application.

So far, nothing has changed on the UI. We can still take a look at the file that was created.

On the bottom right corner of the editor, click on the tab that says Device File Explorer.

You are going to navigate to data/data/com.aboutobjects.curriculum.readinglist/shared_prefs.

Shared Prefs

Right-click on samplePrefs.xml and choose Open.

If it fails the first time, try to Open it a second time.

It should look something like this:

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <string name="com.aboutobjects.curriculum.readinglist.BookListActivity::timestamp">2018-11-15T13:14:47.515-08:00</string>
</map>

Reading from SharedPrefs

Now that we are storing some data in our SharedPreferences, maybe we can retrieve data as well?

Update the UI

Let's start by providing ourselves something to update.

strings.xml

Open your strings.xml. If you don't remember where it is, you can also navigate to it on the menu from Navigate | File.

Add a new entry:

<string name="last_login">Your last login was: %1$s</string>

While you could just use %s, the build system will throw warnings if you don't include the format position.

styles.xml

Open your styles.xml and add a new style for our information.

<style name="LightInfo">
    <item name="android:textSize">12sp</item>
    <item name="android:textColor">@color/cornsilk</item>
</style>

activity_book_list.xml

Open your activity_book_list.xml and add another TextView between the title and the image.

  • Give it an id of login_text
  • Use the style LightInfo
  • Adjust the constraints so that it is between the title and image.
  • Use tools:text instead of android:text to point to our new @string/last_login. We aren't actually setting the text value here, just setting a temporary value to be displayed in the Design window.

End result should look something like

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@android:color/black"
        tools:context=".BookListActivity">

    <TextView
        android:id="@+id/hello_text"
        style="@style/LightTitle"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/hello"
        app:layout_constraintBottom_toTopOf="@+id/login_text"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>

    <TextView
        android:id="@+id/login_text"
        style="@style/LightInfo"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        tools:text="@string/last_login"
        app:layout_constraintBottom_toTopOf="@+id/earth_image"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/hello_text"/>

    <ImageView
        android:id="@+id/earth_image"
        android:src="@drawable/earth"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/login_text" />

</androidx.constraintlayout.widget.ConstraintLayout>

BookListActivity.kt

Now we need to update the activity to set the text. Open BookListActivity.

Inside our companion object, add

const val displayPattern = "yyyy.MM.dd 'at' HH:mm:ss z"

Add a format value (not inside the companion object)

private val displayFormat: SimpleDateFormat by lazy { 
    SimpleDateFormat(displayPattern, Locale.getDefault())
}

Then, inside the onCreate, before we change the preference value, let's update the UI.

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

    prefs.getString(KEY_TIMESTAMP, null)?.let {
        val time = timestampFormat.parse(it)
        findViewById<TextView>(R.id.login_text).text = resources.getString(R.string.last_login, displayFormat.format(time))
    }

    prefs.edit {
        putString(KEY_TIMESTAMP, timestamp())
    }
}

Let's walk through that change.

We ask the SharedPreferences to give us the String value associated with KEY_TIMESTAMP. If it has not been saved before (ie: first time the application has been launched), we want it to return null.

We then have the ?.let which means that we only want to run that closure if the value returned is NOT null. So, if it successfully loaded a previously saved timestamp, it will execute that closure.

We take that returned value (it) and convert it to a time value using the same formatter that was used to save it -- because we know that is the format it was saved as.

We then convert that new time value to the new display format, pass that as a parameter (remember the %1$s) to our String resource and save it to the view associated with our layout id.

Note: We are using findViewById here as a stepping stone. In later chapters we will be using a more efficient approach.

Save and deploy the application.

Last Login

Wait a moment, then deploy again. Did the timestamp change?

If not, the IDE may have been updating the information on the screen live. Click the stop icon, then the play icon. Did it change that time?

Re-validating first time use

So it is updating the last login, but how can you make sure that it works properly the first time?

In your emulator, open the application drawer and long press on the ReadingList icon.

Clear Data 1

Click on App info.

Clear Data 2

Click on FORCE STOP.

Clear Data 2b

Click on Ok

Clear Data 2

Click on Storage.

Clear Data 3

Click on Clear Storage then OK if prompted.

Note: These instructions could be completely different for other phones, but the general idea should always be available somewhere in the Settings application.

Relaunch your application.

Clear Data 4

Kotlin data classes and Gson

Kotlin data classes are very similar to a standard Java POJO, except that all the getters and setters are auto-generated (usually).

Gson is a popular method for reading and writing Json data.

We will generate our models using Kotlin data classes; then use Gson to serialize them to Json and de-serialize them back into models.

Asset Directory

First, let's create a directory to store some sample data.

Create an Assets Directory

Right-click on the app folder and select New | Folder | Assets Folder.

Configure Component

Configure Component

The next screen will give you the option of specifying that the folder is only for a particular variant/flavor (in this case your debug or release build). We are going to leave main selected, which means it applies to all variants/flavors.

Click Finish.

Download Sample Data

Now that we have a directory to save a file into, let's grab some data.

Save this file into app/src/main/assets.

Creating a Model

Open the new file in the IDE. You'll find it inside a new app/assets category. This will provide the basis for our model.

We can see that the Json has a "title" and an array called "books".

Each book in the array has a "title", "author" and "year".

The author further has a "firstName" and a "lastName".

Creating a new package

Let's start by creating a new package to hold our models and keep everything organized.

Open app/java/com/aboutobjects/curriculum/readinglist in the project window.

Note that java is really a shortcut to src/main/java or src/debug/java or src/release/java. The IDE is simplifying it for us.

Note also that we are choosing com not com (androidTest) or com (test)

This is the main directory for our application. In general, any new packages we create should be under it.

Right-click on the readinglist package and choose New | Package. Enter the name model and click OK.

New Model Directory

The IDE should now show the model package and the BookListActivity under the readinglist package.

Creating Kotlin Data Classes

Most of us will naturally start at the top of the Json and work our way down. If you do that, you will create the "title" variable, then realize you can't create the ReadingList model until you create the Book model. While creating that model, you'll realize that you can't finish it until you create the Author model.

It is in your best interest to fully understand the model you are trying to represent before you start coding it. This is especially true when different sub-models tend to have the same parameters and could be represented by the same type of object (like say, a "thumbnail"). Based on that, and our analysis above - let's work our way inside out and start with models that have no nested dependencies.

The Author model

From the Json

"author" : {
  "firstName" : "Ernest",
  "lastName" : "Hemingway"
},

While the "author" tag itself belongs to the containing model, we can use it to name our model. We'll name our model Author, and it will have two fields, firstName and lastName.

Right-click on our new model package and choose New | Kotlin File/Class.

New Kotlin Class

Name it Author and choose a type of Class. Click OK.

By default, it only gives us

package com.aboutobjects.curriculum.readinglist.model

class Author {
}

What we are going to do is change it from class to data class and then replace {...} with (list of parameters), like so:

package com.aboutobjects.curriculum.readinglist.model

data class Author(
    val firstName: String? = null,
    val lastName: String? = null
)

What we have done here is specified that the firstName and lastName parameters should be supplied to the constructor; and said that they can be null (generally safer bet if you ever talk to a server).

That's it. The Author model is done.

If you wanted to do some more complicated logic, like say picking a display name based on the values of first and last names; we could do that, but let's hold off.

The Book model

Now that the Author model is complete, we have everything we need to complete the Book model.

{
  "title" : "For Whom the Bell Tolls",
  "author" : {
    "firstName" : "Ernest",
    "lastName" : "Hemingway"
  },
  "year" : "1951"
},

Like last time, we will add a new Book model, make it a data class and specify the parameters.

Unlike last time, the author parameter will be of type Author? instead of String?, so that it uses our newly created model.

package com.aboutobjects.curriculum.readinglist.model

data class Book(
    val title: String? = null,
    val author: Author? = null,
    val year: String? = null
)

Pretty straight forward, right?

Let's do one more.

The ReadingList model

The ReadingList model is a little different than the others. It has a title and an array of books. We can specify that we want it to be an array by using a List of a specific type. It will look like this.

package com.aboutobjects.curriculum.readinglist.model

data class ReadingList(
    val title: String? = null,
    val books: List<Book> = emptyList()
)

You will notice that the books parameter defaults to emptyList() instead of null. That allows you to do things like readingList.books.size without checking if it is null or not.

We now have a ReadingList model that contains a list of Book models that each contain an Author model.

Setup Gson

Open your module-level build.gradle and add a new dependency.

implementation 'com.google.code.gson:gson:2.8.5'

Then Sync.

Open your BookListActivity and add a new val.

private val gson: Gson by lazy {
    Gson()
}

There are many ways you can customize that instance (pretty printing for example), documented in the Gson User Guide.

For now, we just want you to get in the habit of re-using the same instance. Down the road, you will move it out of the activity entirely and into somewhere common.

Deserializing

Let's see whether we can use our new models to read the Json that we downloaded earlier.

In your BookListActivity, add a new const to the companion object

const val JSON_FILE = "BooksAndAuthors.json"

Add a new function

private fun loadJson(): ReadingList? {
    return try{
        val reader = InputStreamReader(assets.open(JSON_FILE))
        gson.fromJson(reader, ReadingList::class.java)
    } catch (e: Exception) {
        // @TODO introduce logging
        null
    }
}

The assets.open can throw an IOException and the gson call can throw a couple Gson-specific errors. We will learn about debugging and logging shortly. For now, we will treat any error as a failure to load the data by returning null.

This function could be collapsed into one line, but was expanded to make it easier to read. In essence, it is reading the file you downloaded earlier, from the assets directory, and passing it into Gson which will try to convert it into a ReadingList type of model.

In your onCreate, after the setContentView let's add another line

loadJson()?.let {
    findViewById<TextView>(R.id.hello_text).text = "${it.books.size} books loaded"
}

Here, we will replace the "Hello, Earth" text with some hardcoded text if the Json file loaded properly.

Deploy your app.

Gson loaded

You will notice that the IDE warns you that we hardcoded the text and suggests using a String resource instead (as we discussed earlier).

While this is temporary code (we are going to replace this UI with the actual book list shortly), see if you can figure out how to do what it asks. Hint: Look at the rest of your onCreate method.

Note: Normally we would not recommend loading data in the onCreate method as it can be slow. This will be discussed in more depth later.

Serializing

Now that we can load data, how about saving it to Json as well?

In the loadJson()?.let closure that we just created, let's add a bit more.

try{
    val writer = FileWriter(File(filesDir, JSON_FILE))
    gson.toJson(it, writer)
    writer.flush()
    writer.close()
}catch(e: Exception){
    // @TODO introduce logging
}

Here, we are asking Gson to save our Kotlin data class back out to a new file with the same name, but in the filesDir.

Examine Results

Run your application.

Gson Output

On the bottom right corner of your IDE, open the Device File Explorer. Similar to how we opened the shared_prefs earlier, we are going to open data/data/com.aboutobjects.curriculum.readinglist/files/BooksAndAuthors.json.

If you don't see a files directory, right-click on the com.aboutobjects.curriculum.readinglist directory and select Synchronize.

Now, you'll notice that your Json is one really long unwieldy line. Let's see what we can do about that.

Close the file.

Back in your BookListActivity, edit your gson val:

private val gson: Gson by lazy {
    GsonBuilder()
        .setPrettyPrinting()
        .create()
}

Re-run your application and re-open the on-device file.

Much better, right?

Note about Databases

There are many other ways to save and restore data. A common way, locally, has been sqlite3. There are also graph database options like Neo4j that work quite well on Android. Google recently introduced Room, which is a persistence library and acts in much the same way. Those are all larger topics that could be discussed in other classes.

It's also quite common for the server to be the source of data. We'll be talking about that during the chapter on Networking.

Clone this wiki locally