Data Persistence in KOTLIN Android

This tutorial goes in depth about data persistence in Android, as well as exploring the repository pattern. By the end of the tutorial, you will be able to build a repository that can connect to multiple data sources, and then use this repository to download files from an API and save them on a device. You will know multiple ways to store (persist) data directly on a device and the frameworks accessible to do this. When dealing with a filesystem, you will learn how it’s partitioned and how you can read and write files in different locations and using different frameworks.

Introduction

With the repository pattern, you will be able to retrieve data from a server and store it locally in a centralized way. The pattern is useful in situations where the same data is required in multiple places, thereby avoiding code duplication while also keeping ViewModels clean of any unnecessary extra logic.

If you look into the Settings app on your device, or the Settings feature of many apps, you will see some similarities. A list of items with toggles that can be on or off. This is achieved through SharedPreferences and PreferenceFragmentsSharedPreferences is a way that allows you to store values in a file in key-value pairs. It has specialized mechanisms for reading and writing, thereby removing the concerns regarding threading. It’s useful for small amounts of data and eliminates the need for something such as Room.

In this chapter, you will also learn about the Android filesystem and how it’s structured into external and internal memory. You’ll also develop your understanding of read and write permissions, how to create FileProvider class in order to offer other apps access to your files, and how you can save those files without requesting permissions on the external drives. You’ll also see how to download files from the internet and save them on the filesystem.

Another concept that will be explored in this chapter is using the Camera application to take photos and videos on your application’s behalf and save them to external storage using FileProviders.

Repository

Repository is a pattern that helps developers keep code for data sources separate from activities and ViewModels. It offers centralized access to data that can then be unit tested:

Figure 11.1: Diagram of repository architecture

Figure 11.1: Diagram of repository architecture

In the preceding diagram, you can see the central role the repository plays in an application’s code. Its responsibilities include:

  • Keeping all the data sources (SQLite, Network, File System) required by your activity or the application
  • Combining and transforming the data from multiple sources into a single output required at your activity level
  • Transferring data from one data source to another (saving the result of a network call to Room)
  • Refreshing expired data (if necessary)

Room, network layer, and FileManager represent the different types of data sources your repository can have. Room may be used to save large amounts of data from the network, while the filesystem can be used to store small amounts (SharedPreferences) or whole files.

ViewModel will have a reference to your repository and will deliver the results to the activity, which will display the result.

NOTE

Repositories should be organized based on domains, which means your app should have different repositories for different domains and not one giant repository.

Exercise 11.01: Creating a Repository

In this exercise, we will create an app in Android Studio that connects to the API located at https://jsonplaceholder.typicode.com/posts using Retrofit and retrieves a list of posts that will then be saved using Room. The UI will display the title and the body of each post in RecyclerView. We will implement this using the repository pattern with ViewModel.

In order to complete this exercise, we will need to build the following:

  • A network component responsible for downloading and parsing the JSON file
  • A Room database responsible for storing the data with one entity
  • A repository that manages the data between the components built previously
  • ViewModel that accesses the repository
  • An activity with RecyclerView model that displays the data

Perform the following steps to complete this exercise:

  1. Let’s begin by adding the required libraries to the app/build.gradle folder:  
implementation "androidx.constraintlayout :constraintlayout:2.0.4"
implementation 'androidx.recyclerview:recyclerview:1.1.0'
def lifecycle_version = "2.2.0"
implementation "androidx.lifecycle:lifecycle-extensions :$lifecycle_version"
def room_version = "2.2.5"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation 'com.squareup.retrofit2:retrofit:2.6.2'
implementation 'com.squareup.retrofit2:converter-gson:2.6.2'
implementation 'com.google.code.gson:gson:2.8.6'
testImplementation 'junit:junit:4.12'
testImplementation 'android.arch.core:core-testing:2.1.0'
testImplementation 'org.mockito:mockito-core:2.23.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso- core:3.3.0

We will need to group the classes that will deal with API communication. We will do this by creating an API package that will contain the classes required for networking.

2. Next, we define a Post class, which will map the data in the JSON file. Each field in the JSON file representing a post will be defined in our new model:

data class Post(@SerializedName("id") val id: Long, @SerializedName("userId") val userId: Long, @SerializedName("title") val title: String, @SerializedName("body") val body: String)

3. Next, we create a PostService interface, which will be responsible for loading the data from the server through Retrofit. The class will have one method for retrieving the list of posts and will perform an HTTP GET call to retrieve the data:

interface PostService {    @GET("posts")    fun getPosts(): Call<List<Post>>}

3. Next, let’s set up our Room database, which will contain one entity and one data access object. Let’s define a db package for this.

The PostEntity class will have similar fields to the Post class:

@Entity(tableName = "posts") data class PostEntity(@PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long, @ColumnInfo(name = "userId") val userId: Long, @ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "body") val body: String)

PostDao should contain methods for storing a list of posts and retrieving the list of posts:

@Daointerface PostDao {
  @Insert(onConflict = OnConflictStrategy.REPLACE) fun insertPosts(posts: List < PostEntity > ) @Query("SELECT * FROM posts") fun loadPosts(): LiveData < List < PostEntity >>
}

5. And finally, in the case of the Room configuration, the Post database should look like this:

@Database(entities = [PostEntity::class], version = 1) abstract class PostDatabase: RoomDatabase() {
  abstract fun postDao(): PostDao
}

It’s time to move into the Repository territory. So, let’s create a repository package.

6. Previously, we defined two types of Post, one modeled on the JSON and one entity. Let’s define a PostMapper class that converts from one to the other:

class PostMapper {
  fun serviceToEntity(post: Post): PostEntity {
    return PostEntity(post.id, post.userId, post.title, post.body)
  }
}

7. Let’s now define a repository interface that will be responsible for loading the data. The repository will load the data from the API and store it using Room and will then provide LiveData with the Room entity that the UI layer will then consume:

interface PostRepository {    fun getPosts(): LiveData<List<PostEntity>>}

8. Now, let’s provide the implementation for this:

class PostRepositoryImpl(private val postService: PostService, private val postDao: PostDao, private val postMapper: PostMapper, private val executor: Executor): PostRepository {
  override fun getPosts(): LiveData < List < PostEntity >> {
    postService.getPosts().enqueue(object: Callback < List < Post >> {
      override fun onFailure(call: Call < List < Post >> , t: Throwable) {}
      override fun onResponse(call: Call < List < Post >> , response: Response < List < Post >> ) {
        response.body()?.let {
          posts -> executor.execute {
            postDao.insertPosts(posts.map {
              post -> postMapper.serviceToEntity(post)
            })
          }
        }
      }
    }) return postDao.loadPosts()
  }
}

If you look at the preceding code, you can see that when the posts are loaded, we will make an asynchronous call to the network to load the posts. When the call finishes, we update Room with a new list of posts on a separate thread. The method will always return what Room returns. This is because when the data eventually changes in Room, it will be propagated to the observers.

9. Let’s now set up our dependencies. Because we have no dependency injection framework, we will have to rely on the Application class, which means we will need a RepositoryApplication class in which we will initialize all the services that the repository will require and then create the repository:class RepositoryApplication :

Application() {
  lateinit
  var postRepository: PostRepository override fun onCreate() {
    super.onCreate() val retrofit = Retrofit.Builder().baseUrl("https://jsonplaceholder.typicode.com/").addConverterFactory(GsonConverterFactory.create()).build() val postService = retrofit.create < PostService > (PostService::class.java) val notesDatabase = Room.databaseBuilder(applicationContext, PostDatabase::class.java, "post-db").build() postRepository = PostRepositoryImpl(postService, notesDatabase.postDao(), PostMapper(), Executors.newSingleThreadExecutor())
  }
}

10. Add RepositoryApplication to android:name in the <application> tag in AndroidManifest.xml.

11. Add internet permission to the AndroidManifest.xml file:

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

12. Let’s now define our ViewModel:

class PostViewModel(private val postRepository: PostRepository): ViewModel() {
  fun getPosts() = postRepository.getPosts()
}

13. The view_post_row.xml layout file for each row will be as follows:

< ? xml version = "1.0"
encoding = "utf-8" ? > < androidx.constraintlayout.widget.ConstraintLayout xmlns : android = "http://schemas.android.com/apk/res/android"
xmlns: app = "http://schemas.android.com/apk/res-auto"
android: layout_width = "match_parent"
android: layout_height = "wrap_content"
android: padding = "10dp" > < TextView android: id = "@+id/view_post_row_title"
android: layout_width = "wrap_content"
android: layout_height = "wrap_content"
app: layout_constraintStart_toStartOf = "parent"
app: layout_constraintTop_toTopOf = "parent" / > < TextView android: id = "@+id/view_post_row_body"
android: layout_width = "wrap_content"
android: layout_height = "wrap_content"
android: layout_marginTop = "5dp"
app: layout_constraintStart_toStartOf = "parent"
app: layout_constraintTop_toBottomOf = "@id/view_post_row_title" / > < /androidx.constraintlayout.widget.ConstraintLayout>
The activity_main.xml layout file
for our activity will be as follows:
  <
  ? xml version = "1.0"
encoding = "utf-8" ? > < androidx.constraintlayout.widget.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" > < androidx.recyclerview.widget.RecyclerView android: id = "@+id/activity_main_recycler_view"
android: layout_width = "0dp"
android: layout_height = "0dp"
app: layout_constraintBottom_toBottomOf = "parent"
app: layout_constraintLeft_toLeftOf = "parent"
app: layout_constraintRight_toRightOf = "parent"
app: layout_constraintTop_toTopOf = "parent" / > < /androidx.constraintlayout.widget.ConstraintLayout>

14. The PostAdapter class for the rows will be as follows:

class PostAdapter(private val layoutInflater: LayoutInflater): RecyclerView.Adapter < PostAdapter.PostViewHolder > () {
  private val posts = mutableListOf < PostEntity > () override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PostViewHolder = PostViewHolder(layoutInflater.inflate(R.layout.view_post_row, parent, false)) override fun getItemCount() = posts.size override fun onBindViewHolder(holder: PostViewHolder, position: Int) {
    holder.bind(posts[position])
  }
  fun updatePosts(posts: List < PostEntity > ) {
    this.posts.clear() this.posts.addAll(posts) this.notifyDataSetChanged()
  }
  inner class PostViewHolder(containerView: View): RecyclerView.ViewHolder(containerView) {
    private val titleTextView: TextView = containerView.findViewById < TextView > (R.id.view_post_row_title) private val bodyTextView: TextView = containerView.findViewById < TextView > (R.id.view_post_row_body) fun bind(postEntity: PostEntity) {
      bodyTextView.text = postEntity.body titleTextView.text = postEntity.title
    }
  }
}

15. And finally, the MainActivity file will be as follows:

class MainActivity: AppCompatActivity() {
private lateinit
var postAdapter: PostAdapter override fun onCreate(savedInstanceState: Bundle ? ) {
super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) postAdapter = PostAdapter(LayoutInflater.from(this)) val recyclerView = findViewById < RecyclerView > (R.id.activity_main_recycler_view) recyclerView.adapter = postAdapter recyclerView.layoutManager = LinearLayoutManager(this) val postRepository = (application as RepositoryApplication).postRepository val postViewModel = ViewModelProvider(this, object: ViewModelProvider.Factory {
override fun < T: ViewModel ? > create(modelClass: Class < T > ) : T {
return PostViewModel(postRepository) as T
}
}).get(PostViewModel::class.java) postViewModel.getPosts().observe(this, Observer {
postAdapter.updatePosts(it)
})
}
}

If you run the preceding code, you will see the following output:

Figure 11.2: Output of Exercise 11.01

Figure 11.2: Output of Exercise 11.01

You can now turn the internet on and off and close and re-open the app to see that the data that was initially persisted will continue to be displayed. In the current implementation, the error handling is left empty for now. This means that in case something goes wrong when retrieving the list of posts, the user will not be informed of this. This may become a problem and make users frustrated. Most applications have some error message or other displayed on their user interface, with one of the most common error messages being Something went wrong. Please try again, which is used as a generic placeholder when the error is not properly identified.

Exercise 11.02: Adding Error Handling

In this exercise, we will modify the previous exercise. In the case of an internet error, we will ensure that it will display a toast with the message Something went wrong. In the process of adding error handling, we will also need to remove the dependency between the UI and the entity classes by creating a new model class that will hold the relevant data.

In order to handle the error, we will need to build the following:

  • A new model class containing just the body and text
  • A sealed class containing three inner classes for success, error, and loading
  • A mapping function between our new model and the network post

Perform the following steps to complete this exercise:

  1. Let’s start with our new model. This type of model is common when combined with the repository pattern and the reason for this is simple. The new models may contain data that is specific for this screen that requires some extra logic (let’s say you have a user that has firstName and lastName, but your UI requires you to display both in the same TextView. By creating a new model with a name field, you can solve this issue and also unit test the conversion and avoid moving that concatenation on your UI layer):
data class UiPost(val title: String, val body: String)

2. And now to our new sealed class. The subclasses of this sealed class contain all the states of the data loading. The Loading state will be emitted when the repository starts loading the data, the Success state will be emitted when the repository has successfully loaded the data and contains the list of posts, and the Error state will be emitted when an error occurs:

sealed class Result {
  object Loading: Result() class Success(val uiPosts: List < UiPost > ): Result() class Error(val throwable: Throwable): Result()
}

3. The mapping method in PostMapper will look like this. It has an extra method that will convert the data extract from the API to the UI model, which will only have the fields necessary for the UI to be properly displayed:

class PostMapper {
fun serviceToEntity(post: Post): PostEntity {
return PostEntity(post.id, post.userId, post.title, post.body)
}
fun serviceToUi(post: Post): UiPost {
return UiPost(post.title, post.body)
}
}

4. Now, let’s modify PostRepository:

interface PostRepository {
fun getPosts(): LiveData < Result >
}

5. And now let’s modify PostRepositoryImpl. Our result will be MutableLiveData that will begin with the Loading value and, based on the status of the HTTP request, it will either send a Success message with a list of items or an Error message with the error Retrofit encountered. This approach will no longer rely on showing the stored values at all times. When the request is successful, the output from the HTTP call will be passed instead of the output from Room:

override fun getPosts(): LiveData < Result > {
  val result = MutableLiveData < Result > () result.postValue(Result.Loading) postService.getPosts().enqueue(object: Callback < List < Post >> {
    override fun onFailure(call: Call < List < Post >> , t: Throwable) {
      result.postValue(Result.Error(t))
    }
    override fun onResponse(call: Call < List < Post >> , response: Response < List < Post >> ) {
      if (response.isSuccessful) {
        response.body()?.let {
          posts -> executor.execute {
            postDao.insertPosts(posts.map {
              post -> postMapper.serviceToEntity(post)
            }) result.postValue(Result.Success(posts.map {
              post -> postMapper.serviceToUi(post)
            }))
          }
        }
      } else {
        result.postValue(Result.Error(RuntimeException("Unexpected error")))
      }
    }
  }) return result
}

6. In the activity where you observe the live data, the following changes need to be implemented. Here, we will check each state and update the UI accordingly. If there is an error, we show an error message; if successful, we show the list of items; and when it is loading, we show a progress bar, indicating to the user that work is being done in the background:

postViewModel.getPosts().observe(this, Observer {
      result -> when(result) {
          is Result.Error -> {
            Toast.makeText(applicationContext, R.string.error_message, Toast.LENGTH_LONG).show() result.throwable.printStackTrace()
          }
          is Result.Loading -> { // TODO show loading spinner                }                is Result.Success -> {                    postAdapter.updatePosts(result.uiPosts)                }            }        })

7. And finally, your adapter should be as follows:

class PostAdapter(private val layoutInflater: LayoutInflater): RecyclerView.Adapter < PostAdapter.PostViewHolder > () {
  private val posts = mutableListOf < UiPost > () override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PostViewHolder = PostViewHolder(layoutInflater.inflate(R.layout.view_post_row, parent, false)) override fun getItemCount(): Int = posts.size override fun onBindViewHolder(holder: PostViewHolder, position: Int) {
    holder.bind(posts[position])
  }
  fun updatePosts(posts: List < UiPost > ) {
    this.posts.clear() this.posts.addAll(posts) this.notifyDataSetChanged()
  }
  inner class PostViewHolder(containerView: View): RecyclerView.ViewHolder(containerView) {
    private val titleTextView: TextView = containerView.findViewById < TextView > (R.id.view_post_row_title) private val bodyTextView: TextView = containerView.findViewById < TextView > (R.id.view_post_row_body) fun bind(post: UiPost) {
      bodyTextView.text = post.body titleTextView.text = post.title
    }
  }
}

When you run the preceding code, you should see the screen presented in Figure 11.3:

Figure 11.3: Output of Exercise 11.02

Figure 11.3: Output of Exercise 11.02

From this point on, the repository can be expanded in multiple ways:

  • Adding algorithms that will request the data only after a certain time has passed
  • Defining a more complex result class that will be able to store the cached data as well as an error message
  • Adding in-memory caching
  • Adding swipe-to-refresh functionality that will refresh the data when RecyclerView is swiped down and connecting the loading widget to the Loading state

Preferences

Imagine you are tasked with integrating a third-party API that uses something such as OAuth to implement logging in with Facebook, Google, and suchlike. The way these mechanisms work is as follows: they give you a token that you have to store locally and that can then be used to send other requests to access user data. The questions you’re faced with are: How can you store that token? Do you use Room just for one token? Do you save the token in a separate file and implement methods for writing the file? What if that file has to be accessed in multiple places at the same time? SharedPreferences is an answer to these questions. SharedPreferences is a functionality that allows you to save Booleans, integers, floats, longs, strings, and sets of strings into an XML file. When you want to save new values, you specify what values you want to save for the associated keys, and when you are done, you commit the change, which will trigger the save to the XML file in an asynchronous way. The SharedPreferences mappings are also kept in memory, so that when you want to read these values it’s instantaneous, thereby removing the need for an asynchronous call to read the XML file.

The standard way of accessing SharedPreferences data is through the SharedPreferences objects and the more recent EncryptedSharedPreferences option (if you wish to keep your data encrypted). There is also a specialized implementation through PreferenceFragments. These are useful in situations where you want to implement a settings-like screen where you want to store different configuration data that the user wishes to adjust.

SharedPreferences

The way to access the SharedPreference object is through the Context object:

val prefs = getSharedPreferences(“my-prefs-file”, Context.MODE_PRIVATE)

The first parameter is where you specify the name of your preferences, and the second is how you want to expose the file to other apps. Currently, the best mode is the private one. All of the others present potential security risks.

There is a specialized implementation for accessing the default SharedPreferences file, which is used by PreferenceFragment:

PreferenceManager.getDefaultSharedPreferences(context)

If you want to write data into your preferences file, you first need to get access to the Preferences editor. The editor will give you access to writing the data. You can then write your data in the editor. Once you finish writing, you will have to apply the changes that will trigger persistence to the XML file and will change the in-memory values as well. You have two choices for applying the changes on your preference file: apply or commitapply will save your changes in memory instantly, but then the writing to the disk will be asynchronous, which is good if you want to call this from your app’s main thread. commit does everything synchronously and gives you a boolean result informing you if the operation was successful. In practice, apply tends to be favored over commit.

     val editor = prefs.edit()

     editor.putBoolean(“my_key_1”, true)

     editor.putString(“my_key_2”, “my string”)

     editor.putLong(“my_key_3”, 1L)

     editor.apply()

Now, you want to clear your entire data. The same principle will apply; you’ll need the editorclear, and apply:

     val editor = prefs.edit()

     editor.clear()

     editor.apply()

If you want to read the values you previously saved, you can use the SharedPreferences object to read the stored values. In case there is no saved value, you can opt for a default value to be returned instead.

     prefs.getBoolean(“my_key_1”, false)

     prefs.getString(“my_key_2”, “”)

     prefs.getLong(“my_key_3”, 0L)

Exercise 11.03: Wrapping SharedPreferences

We’re going to build an application that displays TextViewEditText, and a button. TextView will display the previous saved value in SharedPreferences. The user can type new text, and when the button is clicked, the text will be saved in SharedPreferences and TextView will display the updated text. We will need to use ViewModel and LiveData in order to make the code more testable.

In order to complete this exercise, we will need to create a Wrapper class, which will be responsible for saving the text. This class will return the value of the text as LiveData. This will be injected into our ViewModel, which will be bound to the activity:

  1. Let’s begin by adding the appropriate libraries to app/build.gradle:
implementation "androidx.constraintlayout:constraintlayout:2.0.4"
def lifecycle_version = "2.2.0"
implementation "androidx.lifecycle:lifecycle- extensions:$lifecycle_version"
testImplementation 'junit:junit:4.12'
testImplementation 'android.arch.core:core-testing:2.1.0'
testImplementation 'org.mockito:mockito-core:2.23.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'

2. Let’s make our Wrapper class, which will listen for changes in SharedPreferences and update the value of LiveData when the preferences change. The class will contain methods to save the new text and to retrieve LiveData:

const val KEY_TEXT = "keyText"
class PreferenceWrapper(private val sharedPreferences: SharedPreferences) {
  private val textLiveData = MutableLiveData < String > () init {
    sharedPreferences.registerOnSharedPreferenceChangeListener {
      _,
      key -> when(key) {
        KEY_TEXT -> {
          textLiveData.postValue(sharedPreferences.getString(KEY_TEXT, ""))
        }
      }
    }
  }
  fun saveText(text: String) {
    sharedPreferences.edit().putString(KEY_TEXT, text).apply()
  }
  fun getText(): LiveData < String > {
    textLiveData.postValue(sharedPreferences.getString(KEY_TEXT, "")) return textLiveData
  }
}

Notice the top of the file. We’ve added a listener so that when our SharedPreferences values change, we can look up the new value and update our LiveData model. This will allow us to observe the LiveData for any changes and just update the UI. The saveText method will open the editor, set the new value, and apply the changes. The getText method will read the last saved value, set it in LiveData, and return the LiveData object. This is helpful in scenarios where the app is opened and we want to access the last value prior to the app closing.

3. Now, let’s set up the Application class with the instance of the preferences:

class PreferenceApplication: Application() {
  lateinit
  var preferenceWrapper: PreferenceWrapper override fun onCreate() {
    super.onCreate() preferenceWrapper = PreferenceWrapper(getSharedPreferences("prefs", Context.MODE_PRIVATE))
  }
}

4. Now, let’s add the appropriate attributes in the application tag to AndroidManifest.xml:

android:name=".PreferenceApplication"

5. And now, let’s build the ViewModel component:

class PreferenceViewModel(private val preferenceWrapper: PreferenceWrapper): ViewModel() {
  fun saveText(text: String) {
    preferenceWrapper.saveText(text)
  }
  fun getText(): LiveData < String > {
    return preferenceWrapper.getText()
  }
}

6. Finally, let’s define our activity_main.xml layout file:

activity_main.xml9 < TextView10 android: id = "@+id/activity_main_text_view"
11 android: layout_width = "wrap_content"
12 android: layout_height = "wrap_content"
13 android: layout_marginTop = "50dp"
14 app: layout_constraintLeft_toLeftOf = "parent"
15 app: layout_constraintRight_toRightOf = "parent"
16 app: layout_constraintTop_toTopOf = "parent" / > 1718 < EditText19 android: id = "@+id/activity_main_edit_text"
20 android: layout_width = "200dp"
21 android: layout_height = "wrap_content"
22 android: inputType = "none"
23 app: layout_constraintLeft_toLeftOf = "parent"
24 app: layout_constraintRight_toRightOf = "parent"
25 app: layout_constraintTop_toBottomOf = "@id/activity_main_text_view" / > 2627 < Button28 android: id = "@+id/activity_main_button"
29 android: layout_width = "wrap_content"
30 android: layout_height = "wrap_content"
31 android: inputType = "none"
32 android: text = "@android:string/ok"
33 app: layout_constraintLeft_toLeftOf = "parent"
34 app: layout_constraintRight_toRightOf = "parent"
35 app: layout_constraintTop_toBottomOf = "@id/activity_main_edit_text" / >

7. And finally, in MainActivity, perform the following steps:class MainActivity :

AppCompatActivity() {
  override fun onCreate(savedInstanceState: Bundle ? ) {
    super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val preferenceWrapper = (application as PreferenceApplication).preferenceWrapper val preferenceViewModel = ViewModelProvider(this, object: ViewModelProvider.Factory {
      override fun < T: ViewModel ? > create(modelClass: Class < T > ) : T {
        return PreferenceViewModel(preferenceWrapper) as T
      }
    }).get(PreferenceViewModel::class.java) preferenceViewModel.getText().observe(this, Observer {
      findViewById < TextView > (R.id.activity_main_text_view).text = it
    }) findViewById < Button > (R.id.activity_main_button).setOnClickListener {
      preferenceViewModel.saveText(findViewById < EditText > (R.id.activity_main_edit_text).text.toString())
    }
  }
}

The preceding code will produce the output presented in Figure 11.4:

Figure 11.4: Output of Exercise 11.03

Figure 11.4: Output of Exercise 11.03

Once you insert a value, try closing the application and re-opening it. The app will display the last persisted value.

PreferenceFragment

As mentioned previously, PreferenceFragment is a specialized implementation of a fragment that relies on SharedPreferences in order to store user settings. Its features include storing Booleans based on on/off toggles, storing text based on dialogs displayed to the user, storing string sets based on single and multi-choice dialogs, storing integers based on SeekBars, and categorizing the sections and linking to other PreferenceFragment classes.

While PreferenceFragment classes are part of the Android framework, they are marked as deprecated, which means that the recommended approach for fragments is to rely on the Jetpack Preference library, which introduces PreferenceFragmentCompatPreferenceFragmentCompat is useful for ensuring backward compatibility between newer Android frameworks and older ones.

In order to build a PreferenceFragment class, two things are required:

  • A resource in the res/xml folder, where the structure of your preferences will be structured
  • A class extending PreferenceFragment, which will link the XML file with the fragment

If you want to access the values that your PreferenceFragment stored from non-PreferenceFragment resources, you can access the SharedPreference object using the PreferenceManager.getDefaultSharedPreferences(context) method. The keys to accessing the values are the keys you defined in the XML file.

An example of a preference XML file named settings_preference.xml would look something like this:

<?xml version=”1.0″ encoding=”utf-8″?>

<PreferenceScreen xmlns:app=”http://schemas.android.com/apk/res-auto”&gt;

    <PreferenceCategory app:title=”Primary settings”>

        <SwitchPreferenceCompat

            app:key=”work_offline”

            app:title=”Work offline” />

        <Preference

            app:icon=”@mipmap/ic_launcher”

            app:key=”my_key”

            app:summary=”Summary”

            app:title=”Title” />

    </PreferenceCategory>

</PreferenceScreen>

For every preference, you have the ability to show icons, a title, a summary, a current value, and whether it’s selectable. An important thing is the key and how to link it to your Kotlin code. You can use the strings.xml file to declare non-translatable strings, which you can then extract in your Kotlin code.

Your PreferenceFragment will look similar to this:

class MyPreferenceFragment : PreferenceFragmentCompat() {

    override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {

        setPreferencesFromResource(R.xml.settings_preferences, rootKey)

    }

}

The onCreatePreferences method is abstract, and you will need to implement it in order to specify the XML resource for your preferences through the setPreferencesFromResource method.

You can also access the preferences programmatically using the findPreference method:

findPreference<>(key)

This will return an object that will extend from Preference. The nature of the object should match the type declared in the XML for that particular key. You can modify the Preference object programmatically and change the desired fields.

You can also build a Settings screen programmatically using createPreferenceScreen(Context) on the PreferenceManager class that’s inherited in PreferenceFragment:

val preferenceScreen = preferenceManager.createPreferenceScreen(context)

You can use the addPreference(Preference) method on the PreferenceScreen container to add a new Preference object:

val editTextPreference = EditTextPreference(context)

editTextPreference.key = “key”

editTextPreference.title = “title”

val preferenceScreen = preferenceManager.createPreferenceScreen(context)

preferenceScreen.addPreference(editTextPreference)

setPreferenceScreen(preferenceScreen)

Let’s now move on to the next exercise to customize your settings.

Exercise 11.04: Customized Settings

In this exercise, we’re going to build the settings for a VPN app. The product requirements for the settings page are as follows:

  • Connectivity: Network scan – Toggle; Frequency – SeekBar
  • Configuration: IP address – Text; Domain – Text
  • More: This will open a new screen containing one option named Use mobile data, with a toggle and a non-selectable option below containing the text Manage your mobile data wisely.

Perform the following steps to complete this exercise:

Let’s start by adding the Jetpack Preference library:implementation ‘androidx.preference:preference-ktx:1.1.1’

In res/values, create a file named preference_keys.xml and let’s define the key for the More preferences screen:<?xml version=”1.0″ encoding=”utf-8″?><resources>    <string name=”key_mobile_data” translatable=”false”>mobile_data</string></resources>

Create the xml folder in res if it’s not available.

Create the preferences_more.xml file in the res/xml folder.

In the preferences_more.xml file, add the following preferences:<?xml version=”1.0″ encoding=”utf-8″?><PreferenceScreen xmlns:app= “http://schemas.android.com/apk/res-auto”>    <SwitchPreferenceCompat        app:key=”@string/key_mobile_data”        app:title=”@string/mobile_data&#8221; />    <Preference        app:selectable=”false”        app:summary=”@string/manage_data_wisely” /></PreferenceScreen>

In strings.xml, add the following strings:<string name=”mobile_data”>Mobile data</string><string name=”manage_data_wisely”>Manage your data wisely</string>

Create a PreferenceFragment class called MorePreferenceFragment:class MorePreferenceFragment : PreferenceFragmentCompat() {    override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {        setPreferencesFromResource(R.xml.preferences_more, rootKey)    }}We are done with the More section. Let’s now create the main section.

Let’s create the keys for the main preference section. In preference_keys.xml, add the following:<string name=”key_network_scan” translatable=”false”>network_scan</string><string name=”key_frequency” translatable=”false”>frequency</string><string name=”key_ip_address” translatable=”false”>ip_address</string><string name=”key_domain” translatable=”false”>domain</string>

In res/xml, create the preferences_settings.xml file.

Now, define your preferences according to the specs:<?xml version=”1.0″ encoding=”utf-8″?><PreferenceScreen xmlns:app= “http://schemas.android.com/apk/res-auto”>    <PreferenceCategory app:title=”@string/connectivity”>        <SwitchPreferenceCompat            app:key=”@string/key_network_scan”            app:title=”@string/network_scan” />        <SeekBarPreference            app:key=”@string/key_frequency”            app:title=”@string/frequency” />    </PreferenceCategory>    <PreferenceCategory app:title=”@string/configuration”>        <EditTextPreference            app:key=”@string/key_ip_address”            app:title=”@string/ip_address” />        <EditTextPreference            app:key=”@string/key_domain”            app:title=”@string/domain” />    </PreferenceCategory>    <Preference        app:fragment=”com.android.testable.preferencefragments .MorePreferenceFragment”        app:title=”@string/more” /></PreferenceScreen>Notice the last part. That is how we establish the link between one PreferenceFragment and another. By default, the system will do the transition for us, but there is a way to override this behavior in case we want to update our UI.

In strings.xml, make sure you have the following values:<string name=”connectivity”>Connectivity</string><string name=”network_scan”>Network scan</string><string name=”frequency”>Frequency</string><string name=”configuration”>Configuration</string><string name=”ip_address”>IP Address</string><string name=”domain”>Domain</string><string name=”more”>More</string>

Create a fragment called SettingsPreferenceFragment.

Add the following setup:class SettingsPreferenceFragment : PreferenceFragmentCompat() {    override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {        setPreferencesFromResource(R.xml.preferences_settings, rootKey)    }}

Now, let’s add Fragments to our activity.

In activity_main.xml, define a FrameLayout tag to contain the fragments:<?xml version=”1.0″ encoding=”utf-8″?><FrameLayout xmlns:android=”http://schemas.android.com/apk/res/android”    xmlns:tools=”http://schemas.android.com/tools”    android:layout_width=”match_parent”    android:layout_height=”match_parent”    tools:context=&#8221;.MainActivity”    android:id=”@+id/fragment_container”/>

And finally, in MainActivity, perform the following steps:class MainActivity : AppCompatActivity(),    PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {    override fun onCreate(savedInstanceState: Bundle?) {        super.onCreate(savedInstanceState)        setContentView(R.layout.activity_main)        if (savedInstanceState == null) {            supportFragmentManager.beginTransaction()                .replace(R.id.fragment_container, SettingsPreferenceFragment())                .commit()        }    }    override fun onPreferenceStartFragment(        caller: PreferenceFragmentCompat?,        pref: Preference    ): Boolean {        val args = pref.extras        val fragment = supportFragmentManager.fragmentFactory.instantiate(            classLoader,            pref.fragment        )        fragment.arguments = args        fragment.setTargetFragment(caller, 0)        supportFragmentManager.beginTransaction()            .replace(R.id.fragment_container, fragment)            .addToBackStack(null)            .commit()        return true    }}Look at onPreferenceStartFragment from the PreferenceFragmentCompat.OnPreferenceStartFragmentCallback interface. This allows us to intercept the switch between fragments and add our own behavior. The first half of the method will use the inputs of the method to create a new instance of MorePreferenceFragment, while the second half performs the fragment transaction. Then, we return true because we have handled the transition ourselves.

Running the preceding code will produce the following output:Figure 11.5: Output of Exercise 11.04
Figure 11.5: Output of Exercise 11.04We can now monitor the changes to preferences and display them in the UI. We can apply this functionality to the IP address and domain sections to display what the user typed as a summary.

Let’s now modify SettingsPreferenceFragment to programmatically set a listener for when values change, which will display the new value in the summary. We will also need to set the saved values when the screen is first opened. We will need to locate preferences we want to modify using findPreference(key). This allows us to programmatically modify a preference. We can also register listeners on the preference, which will give us access to the new value. In our case, we can register a listener for when the IP address changes, so we can update the summary of the field based on what was introduced in EditText by the user:class SettingsPreferenceFragment :

PreferenceFragmentCompat() {
  override fun onCreatePreferences(savedInstanceState: Bundle ? , rootKey : String ? ) {
    setPreferencesFromResource(R.xml.preferences_settings, rootKey) val ipAddressPref = findPreference < EditTextPreference > (getString(R.string.key_ip_address)) ipAddressPref?.setOnPreferenceChangeListener {
      preference,
      newValue -> preference.summary = newValue.toString() true
    }
    val domainPref = findPreference < EditTextPreference > (getString(R.string.key_domain)) domainPref?.setOnPreferenceChangeListener {
      preference,
      newValue -> preference.summary = newValue.toString() true
    }
    val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) ipAddressPref?.summary = sharedPrefs.getString(getString(R.string.key_ip_address), "") domainPref?.summary = sharedPrefs.getString(getString(R.string.key_domain), "")
  }
}

PreferenceFragment is a good way of building settings-like functionality for any application. Its integration with SharedPreferences and built-in UI components allow developers to build elements quicker than usual and solve many issues with regard to handling the clicks and elements inserted for each setting element.

Files

We’ve discussed Room and SharedPreferences and specified how the data they store is written to files. You may ask yourself, where are these files stored? These particular files are stored in internal storage. Internal storage is a dedicated space for every app that other apps are unable to access (unless the device is rooted). There is no limit to the amount of storage your app uses. However, users have the ability to delete your app’s files from the Settings menu. Internal storage occupies a smaller part of the total available space, which means that you should be careful when it comes to storing files in internal storage. There is also external storage. The files your app stores are accessible to other apps and the files from other apps are accessible to your app:

Note

In Android Studio, you can use the Device File Explorer tool to navigate through the files on the device or emulator. Internal storage is located in /data/data/{packageName}. If you have access to this folder, this means that the device is rooted. Using this, you can visualize the database files and the SharedPreferences files.

Figure 11.6: Android Device File Explorer

Figure 11.6: Android Device File Explorer

Internal Storage

Internal storage requires no permissions from the user. To access the internal storage directories, you can use one of the following methods from the Context object:

  • getDataDir(): Returns the root folder of your application sandbox.
  • getFilesDir(): A dedicated folder for application files; recommended for usage.
  • getCacheDir(): A dedicated folder where files can be cached. Storing files here does not guarantee that you can retrieve them later because the system may decide to delete this directory to free memory. This folder is linked to the Clear Cache option in Settings.
  • getDir(name, mode): Returns a folder that will be created if it does not exist based on the name specified.

When users use the Clear Data option from Settings, most of these folders will be deleted, bringing the app to a similar state as a fresh install. When the app is uninstalled, then these files will be deleted as well.

A typical example of reading a cache file is as follows:

        val cacheDir = context.cacheDir

        val fileToReadFrom = File(cacheDir, “my-file.txt”)

        val size = fileToReadFrom.length().toInt()

        val bytes = ByteArray(size)

        val tmpBuff = ByteArray(size)

        val fis = FileInputStream(fileToReadFrom)

        try {

            var read = fis.read(bytes, 0, size)

            if (read < size) {

                var remain = size – read

                while (remain > 0) {

                    read = fis.read(tmpBuff, 0, remain)

                    System.arraycopy(tmpBuff, 0, bytes,                                      size – remain, read)

                    remain -= read

                }

            }

        } catch (e: IOException) {

            throw e

        } finally {

            fis.close()

        }

The preceding example will read from my-file.txt, located in the Cache directory, and will create FileInputStream for that file. Then, a buffer will be used that will collect the bytes from the file. The collected bytes will be placed in the bytes byte array, which will contain all of the data read from that file. Reading will stop when the entire length of the file has been read.

Writing to the my-file.txt file will look something like this:

        val bytesToWrite = ByteArray(100)

        val cacheDir = context.cacheDir

        val fileToWriteIn = File(cacheDir, “my-file.txt”)

        try {

            if (!fileToWriteIn.exists()) {

                fileToWriteIn.createNewFile()

            }

            val fos = FileOutputStream(fileToWriteIn)

            fos.write(bytesToWrite)

            fos.close()

        } catch (e: Exception) {

            e.printStackTrace()

        }

What the preceding example does is take the byte array you want to write, create a new File object, create the file if it doesn’t exist, and write the bytes into the file through FileOutputStream.

Note

There are many alternatives to dealing with files. The readers (StreamReaderStreamWriter, and so on) are better equipped for character-based data. There are also third-party libraries that help with disk I/O operations. One of the most common third parties that help with I/O operations is called Okio. It started life as part of the OkHttp library, which is used in combination with Retrofit to make API calls. The methods provided by Okio are the same methods it uses to write and read data in HTTP communications.

External Storage

Reading and writing in external storage requires user permissions for reading and writing. If write permission is granted, then your app has the ability to read the external storage. Once these permissions are granted, then your app can do whatever it pleases on the external storage. That may present a problem because users may not choose to grant these permissions. However, there are specialized methods that offer you the possibility to write on the external storage in folders dedicated to your application.

Some of the most common ways of accessing external storage are from the Context and Environment objects:

  • Context.getExternalFilesDir(mode): This method will return the path to the directory on the external storage dedicated to your application. Specifying different modes (pictures, movies, and so on) will create different subfolders depending on how you want your files saved. This method does not require permissions.
  • Context.getExternalCacheDir(): This will point toward the application’s cache directory on the external storage. The same considerations should be applied to this cache folder as to the internal storage option. This method does not require permissions.
  • The Environment class has access to paths of some of the most common folders on the device. However, on newer devices, apps may not have access to those files and folders.NoteAvoid using hardcoded paths to files and folders. The Android operating system may shift the location of folders around depending on the device or operating system.

FileProvider

This represents a specialized implementation of ContentProviders that is useful in organizing the file and folder structure of your application. It allows you to specify an XML file in which you define how your files should be split between internal and external storage if you choose to do so. It also gives you the ability to grant access to other apps to your files by hiding the path and generating a unique URI to identify and query your file.

FileProvider gives you the choice to pick between six different folders where you can set up your folder hierarchies:

  • Context.getFilesDir() (files-path)
  • Context.getCacheDir() (cache-path)
  • Environment.getExternalStorageDirectory() (external-path)
  • Context.getExternalFilesDir(null) (external-files-path)
  • Context.getExternalCacheDir() (external-cache-path)
  • First result of Context.getExternalMediaDirs() (external-media-path)

The main benefits of FileProvider are the abstractions it provides in organizing your files since leaving the developer to define the paths in an XML file and, more importantly, if you chose to use it to store files on the external storage, you do not have to ask for permissions from the user. Another benefit is the fact that it makes sharing of internal files easier while giving the developer control of what files other apps can access without exposing their real location.

Let us understand better through the following example:

<paths xmlns:android=”http://schemas.android.com/apk/res/android”&gt;

    <files-path name=”my-visible-name” path=”/my-folder-name” />

</paths>

The preceding example will make FileProvider use the internal files directory and create a folder named my-folder-name. When the path is converted to a URI, then the URI will use my-visible-name.

Storage Access Framework (SAF)

The SAF is a file picker introduced in Android KitKat that apps can use for their users to pick files with a view to being processed or uploaded. You can use it in your app for the following scenarios:

  1. Your app requires the user to process a file saved on the device by another app (photos and videos).
  2. You want to save a file on the device and give the user the choice of where they want the file to be saved and the name of the file.
  3. You want to offer the files your application uses to other apps for scenarios similar to scenario number 1.

This is again useful because your app will avoid read and write permissions and still write and access external storage. The way this works is based on intents. You can start an activity for a result with Intent.ACTION_OPEN_DOCUMENT or Intent.ACTION_CREATE_DOCUMENT. Then, in onActivityResult, the system will give you a URI that grants you temporary permissions to that file, allowing you to read and write.

Another benefit of the SAF is the fact that the files don’t have to be on a device. Apps such as Google Drive expose their content in the SAF and when a Google Drive file is selected, it will be downloaded to the device and the URI will be sent as a result. Another important thing to mention is the SAF’s support for virtual files, meaning that it will expose Google docs, which have their own format, but when those docs are downloaded through the SAF, their formats will be converted to a common format such as PDF.

Asset Files

Asset files are files you can package as part of your APK. If you’ve used apps that played certain videos or GIFs when the app is launched or as part of a tutorial, odds are that the videos were bundled with the APK. To add files to your assets, you need the assets folder inside your project. You can then group your files inside your assets using folders.

You can access these files at runtime through the AssetManager class, which itself can be accessed through the context object. AssetManager offers you the ability to look up the files and read them, but it does not permit any write operations:

        val assetManager = context.assets

        val root = “”

        val files = assetManager.list(root)

        files?.forEach {

            val inputStream = assetManager.open(root + it)

        }

The preceding example lists all files inside the root of the assets folder. The open function returns inputStream, which can be used to read the file information if necessary.

One common usage of the assets folder is for custom fonts. If your application uses custom fonts, then you can use the assets folder to store font files.

Exercise 11.05: Copying Files

Note

For this exercise, you will need an emulator. You can do so by selecting the Tools | AVD Manager in Android Studio. Then, you can create one with the Create Virtual Device option, selecting the type of emulator, clicking Next, and then selecting an x86 image. Any image larger than Lollipop should be acceptable for this exercise. Next, you can give your image a name and click Finish.

Let’s create an app that will keep a file named my-app-file.txt in the assets directory. The app will display two buttons called FileProvider and SAF. When the FileProvider button is clicked, the file will be saved on the external storage inside the app’s external storage dedicated area (Context.getExternalFilesDir(null)). The SAF button will open the SAF and allow the user to indicate where the file should be saved.

In order to implement this exercise, the following approach will be adopted:

  • Define a file provider that will use the Context.getExternalFilesDir(null) location.
  • Copy my-app-file.txt to the preceding location when the FileProvider button is clicked.
  • Use Intent.ACTION_CREATE_DOCUMENT when the SAF button is clicked and copy the file to the location provided.
  • Use a separate thread for the file copy to comply with the Android guidelines.
  • Use the Apache IO library to help with the file copy functionality, by providing methods that allow us to copy data from an InputStream to an OutputStream.

The steps for completion are as follows:

  1. Let’s start with our Gradle configuration:
implementation 'commons-io:commons-io:2.6'
testImplementation 'org.mockito:mockito-core:2.23.0'

2. Create the my-app-file.txt file in the main/assets folder. Feel free to fill it up with the text you want to be read. If the main/assets folder doesn’t exist, then you can create it. In order to create the assets folder, you can right-click on the main folder and select New and then select Directory and name it assets. This folder will now be recognized by the build system and any file inside it will also be installed on the device along with the app.

3. We can also define a class that will wrap AssetManager and define a method to access this particular file:

class AssetFileManager(private val assetManager: AssetManager) {
  fun getMyAppFileInputStream() = assetManager.open("my-app-file.txt")
}

4. Now, let’s work on the FileProvider aspect. Create the xml folder in the res folder. Define file_provider_paths.xml inside the new folder. We will define external-files-path, name it docs, and place it in the docs/ folder:

< ? xml version = "1.0"
encoding = "utf-8" ? > < paths > < external - files - path name = "docs"
path = "docs/" / > < /paths>

5. Next, we need to add FileProvider to the AndroidManifest.xml file and link it with the new path we defined:      

< provider android: name = "androidx.core.content.FileProvider"
android: authorities = "com.android.testable.files"
android: exported = "false"
android: grantUriPermissions = "true" > < meta - data android: name = "android.support                               .FILE_PROVIDER_PATHS"
android: resource = "@xml/file_provider_paths" / > < /provider>

6. The name will point to the FileProvider path that’s part of the Android Support library. The authorities field represents the domain your application has (usually the package name of the application). The exported field indicates if we wish to share our provider with other apps, and grantUriPermissions indicates if we wish to grant other applications access to certain files through the URI. The meta-data links the XML file we defined previously with FileProvider.

7. Define the ProviderFileManager class, which is responsible for accessing the docs folder and writing data into the file:

class ProviderFileManager(private val context: Context, private val fileToUriMapper: FileToUriMapper, private val executor: Executor) {
  private fun getDocsFolder(): File {
    val folder = File(context.getExternalFilesDir(null), "docs") if (!folder.exists()) {
      folder.mkdirs()
    }
    return folder
  }
  fun writeStream(name: String, inputStream: InputStream) {
    executor.execute {
      val fileToSave = File(getDocsFolder(), name) val outputStream = context.contentResolver.openOutputStream(fileToUriMapper.getUriFromFile(context, fileToSave), "rw") IOUtils.copy(inputStream, outputStream)
    }
  }
}

getDocsFolder will return the path to the docs folder we defined in the XML. If the folder does not exist, then it will be created. The writeStream method will extract the URI for the file we wish to save and, using the Android ContentResolver class, will give us access to the OutputStream class of the file we will be saving into. Notice that FileToUriMapper doesn’t exist yet. The code is moved into a separate class in order to make this class testable.

8. The FileToUriMapper class looks like this:

class FileToUriMapper {
  fun getUriFromFile(context: Context, file: File): Uri {
    return FileProvider.getUriForFile(context, "com.android.testable.files", file)
  }
}

The getUriForFile method is part of the FileProvider class and its role is to convert the path of a file into a URI that can be used by ContentProviders/ContentResolvers to access data. Because the method is static, it prevents us from testing properly.Notice the test rule we used. This comes in handy when testing files. What it does is supply the test with the necessary files and folders and when the test finishes, it will remove all the files and folders.

9. Let’s now move on to defining our UI for the activity_main.xml file:

activity_main.xml9 < Button10 android: id = "@+id/activity_main_file_provider"
11 android: layout_width = "wrap_content"
12 android: layout_height = "wrap_content"
13 android: layout_marginTop = "200dp"
14 android: text = "@string/file_provider"
15 app: layout_constraintEnd_toEndOf = "parent"
16 app: layout_constraintStart_toStartOf = "parent"
17 app: layout_constraintTop_toTopOf = "parent" / > 1819 < Button20 android: id = "@+id/activity_main_saf"
21 android: layout_width = "wrap_content"
22 android: layout_height = "wrap_content"
23 android: layout_marginTop = "50dp"
24 android: text = "@string/saf"
25 app: layout_constraintEnd_toEndOf = "parent"
26 app: layout_constraintStart_toStartOf = "parent"
27 app: layout_constraintTop_toBottomOf = "@id/activity_main_file_provider" / >

10. Now, let’s define our MainActivity class:

class MainActivity: AppCompatActivity() {
  private val assetFileManager: AssetFileManager by lazy {
    AssetFileManager(applicationContext.assets)
  }
  private val providerFileManager: ProviderFileManager by lazy {
    ProviderFileManager(applicationContext, FileToUriMapper(), Executors.newSingleThreadExecutor())
  }
  override fun onCreate(savedInstanceState: Bundle ? ) {
    super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) findViewById < Button > (R.id.activity_main_file_provider).setOnClickListener {
      val newFileName = "Copied.txt"
      providerFileManager.writeStream(newFileName, assetFileManager.getMyAppFileInputStream())
    }
  }
}

For this example, we chose MainActivity to create our objects and inject data into the different classes we have. If we execute this code and click the FileProvider button, we don’t see an output on the UI. However, if we look with Android Device File Explorer, we can locate where the file was saved. The path may be different on different devices and operating systems. The paths could be as follows:

mnt/sdcard/Android/data/<package_name>/files/docs

sdcard/Android/data/<package_name>/files/docs

storage/emulated/0/Android/data/<package_name>/files/docs

The output will be as follows:

Figure 11.7: Output of copy through FileProvider

Figure 11.7: Output of copy through FileProvider

  1. Let’s add the logic for the SAF button. We will need to start an activity pointing toward the SAF with the CREATE_DOCUMENT intent in which we specify that we want to create a text file. We will then need the result of the SAF so we can copy the file to the location selected by the user. In MainActivity in onCreateMethod, we can add the following:
findViewById < Button > (R.id.activity_main_saf).setOnClickListener {
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
    val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
      addCategory(Intent.CATEGORY_OPENABLE) type = "text/plain"
      putExtra(Intent.EXTRA_TITLE, "Copied.txt")
    }
    startActivityForResult(intent, REQUEST_CODE_CREATE_DOC)
  }
}

What the preceding code will do is to create an intent to create a document with the name of Copied.txt and the text/plain MIME (Multipurpose Internet Mail Extensions) type (which is suitable for text files). This code will only run in Android versions bigger than KitKat.

2. Let’s now tell the activity how to handle the result of the document creation. We will receive a URI object with an empty file selected by the user. We can now copy our file to that location. In MainActivity, we add onActivityResult, which will look like this:    

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent ? ) {
  if (requestCode == REQUEST_CODE_CREATE_DOC && resultCode == Activity.RESULT_OK) {
    data?.data?.let {
      uri ->
    }
  } else {
    super.onActivityResult(requestCode, resultCode, data)
  }
}

3. We now have the URI. We can add a method to ProviderFileManager that will copy our file to a location given by uri:   

fun writeStreamFromUri(name: String, inputStream: InputStream, uri: Uri) {
  executor.execute {
    val outputStream = context.contentResolver.openOutputStream(uri, "rw") IOUtils.copy(inputStream, outputStream)
  }
}

4. And we can invoke this method from the onActivityResult method of MainActivity like this:       

if (requestCode == REQUEST_CODE_CREATE_DOC && resultCode == Activity.RESULT_OK) {
  data?.data?.let {
    uri -> val newFileName = "Copied.txt"
    providerFileManager.writeStreamFromUri(newFileName, assetFileManager.getMyAppFileInputStream(), uri)
  }
}

If we run the preceding code and click on the SAF button, we will see the output presented in Figure 11.8:

Figure 11.8: Output of copy through the SAF

Figure 11.8: Output of copy through the SAF

If you choose to save the file, the SAF will be closed and our activity’s onActivityResult method will be called, which will trigger the file copy. Afterward, you can navigate the Android Device File Manager tool to see whether the file was saved properly.

Scoped Storage

Since Android 10 and with further updates in Android 11, the notion of Scoped Storage was introduced. The main idea behind this is to allow apps to gain more control of their files on the external storage and prevent other apps from accessing these files. The consequences of this mean that READ_EXTERNAL_STORAGE and WRITE_EXTERNAL_STORAGE will only apply for files the user interacts with (like media files). This discourages apps to create their own directories on the external storage and instead stick with the one already provided to them through the Context.getExternalFilesDir.

FileProviders and Storage Access Framework are a good way of keeping your app’s compliance with the scoped storage practices because one allows the app to use the Context.getExternalFilesDir and the other uses the built-in File Explorer app which will now avoid files from other applications in the Android/data and Android/obb folders on the external storage.

Camera and Media Storage

Android offers a variety of ways to interact with media on an Android device, from building your own camera application and controlling how users take photos and videos to using the existing camera application and instructing it on how to take photos and videos. Android also comes with a MediaStore content provider, allowing applications to extract information about media files that are set on the device and shared between applications. This is useful in situations where you want a custom display for media files that exist on the device (such as a photo or music player application) and in situations where you use the MediaStore.ACTION_PICK intent to select a photo from the device and want to extract the information about the selected media image (this is usually the case for older applications where the SAF cannot be used).

In order to use an existing camera application, you will need to use the MediaStore.ACTION_IMAGE_CAPTURE intent to start a camera application for a result and pass the URI of the image you wish to save. The user will then go to the camera activity, take the photo, and then you handle the result of the operation:

        val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)

        intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri)

        startActivityForResult(intent, REQUEST_IMAGE_CAPTURE)

The photoUri parameter will represent the location of where you want your photo to be saved. It should point to an empty file with a JPEG extension. You can build this file in two ways:

  • Create a file on the external storage using the File object (this requires the WRITE_EXTERNAL_STORAGE permission) and then use the Uri.fromFile() method to convert it into a URI – no longer applicable on Android 10 and above
  • Create a file in a FileProvider location using the File object and then use the FileProvider.getUriForFile() method to obtain the URI and grant it permissions if necessary. – the recommended approach for when your app targets Android 10 and Android 11NoteThe same mechanism can be applied to videos using MediaStore.ACTION_VIDEO_CAPTURE.

If your application relies heavily on the camera features, then you can exclude the application from users whose devices don’t have cameras by adding the <uses-feature> tag to the AndroidManifest.xml file. You can also specify the camera as non-required and query whether the camera is available using the Context.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) method.

If you wish to have your file saved in MediaStore, there are multiple ways to achieve this:

  1. Send an ACTION_MEDIA_SCANNER_SCAN_FILE broadcast with the URI of your media:
val intent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE) intent.data = photoUri sendBroadcast(intent)

2. Use the media scanner to scan files directly: 

val paths = arrayOf("path1", "path2") val mimeTypes = arrayOf("type1", "type2") MediaScannerConnection.scanFile(context, paths, mimeTypes) {
  path,
  uri ->
}

3. Insert the media into ContentProvider directly using ContentResolver

val contentValues = ContentValues() contentValues.put(MediaStore.Images.ImageColumns.TITLE, "my title") contentValues.put(MediaStore.Images.ImageColumns.DATE_ADDED, timeInMillis) contentValues.put(MediaStore.Images.ImageColumns.MIME_TYPE, "image/*") contentValues.put(MediaStore.Images.ImageColumns.DATA, "my-path") val newUri = contentResolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentValues) newUri?.let {
    val outputStream = contentResolver.openOutputStream(newUri) // Copy content in outputstream            }

Note
The MediaScanner functionality no longer adds files from Context.getExternalFilesDir in Android 10 and above. Apps should rely on the insert method instead if they chose to share their media files with the rest of the apps.

Exercise 11.06: Taking Photos

We’re going to build an application that has two buttons: the first button will open the camera app to take a photo, and the second button will open the camera app to record a video. We will use FileProvider to save the photos to the external storage (external-path) in two folders: pictures and movies. The photos will be saved using img_{timestamp}.jpg, and the videos will be saved using video_{timestamp}.mp4. After a photo and video have been saved, you will copy the file from the FileProvider into the MediaStore so it will be visible for other apps:

  1. Let’s add the libraries in app/build.gradle :    
implementation 'commons-io:commons-io:2.6'
testImplementation 'org.mockito:mockito-core:2.23.0'

We will be targeting Android 11 which means that we need the following configuration in app/build.gradle…compileSdkVersion 30    defaultConfig {        …        targetSdkVersion 30        …    }…

2. We will need to request the WRITE_EXTERNAL_STORAGE permission for devices that have less than Android 10, which means we need the following in AndroidManifest.xml:

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

3. Let’s define a FileHelper class, which will contain methods that are harder to test in the test package:

class FileHelper(private val context: Context) {
  fun getUriFromFile(file: File): Uri {
    return FileProvider.getUriForFile(context, "com.android.testable.camera", file)
  }
  fun getPicturesFolder(): String = Environment.DIRECTORY_PICTURES fun getVideosFolder(): String = Environment.DIRECTORY_MOVIES
}

4. Let’s define our FileProvider paths in res/xml/file_provider_paths.xml. Make sure to include the appropriate package name for your application in FileProvider:

< ? xml version = "1.0"
encoding = "utf-8" ? > < paths > < external - path name = "photos"
path = "Android/data /com.android.testable.camera/files/Pictures" / > < external - path name = "videos"
path = "Android/data /com.android.testable.camera/files/Movies" / > < /paths>

5. Let’s add the file provider paths to the AndroidManifest.xml file:

< provider android: name = "androidx.core.content.FileProvider"
android: authorities = "com.android.testable.camera"
android: exported = "false"
android: grantUriPermissions = "true" > < meta - data android: name = "android.support .FILE_PROVIDER_PATHS"
android: resource = "@xml/file_provider_paths" / > < /provider>

6. Let’s now define a model that will hold both the Uri and the associated path for a file:

data class FileInfo(val uri: Uri, val file: File, val name: String, val relativePath: String, val mimeType: String)

7. Let’s create a ContentHelper class that will provide us with the data required for the ContentResolver. We will define two methods for accessing the Photo and Video content Uri and two methods that will create the ContentValues. We do this because of the static methods required to obtain Uris and the ContentValues creation which makes this functionality hard to test. The code below is truncated for space. The full code you need to add can be found via the link below.MediaContentHelper.kt  

class MediaContentHelper {
  fun getImageContentUri(): Uri =
    if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
      MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
    } else {
      MediaStore.Images.Media.EXTERNAL_CONTENT_URI
    }
  fun generateImageContentValues(fileInfo: FileInfo) = ContentValues().apply {
    this.put(MediaStore.Images.Media.DISPLAY_NAME, fileInfo.name)
    if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
      this.put(MediaStore.Images.Media.RELATIVE_PATH, fileInfo.relativePath)
    }
    this.put(MediaStore.Images.Media.MIME_TYPE, fileInfo.mimeType)
  }

8. Now, let’s create the ProviderFileManager class, where we will define methods to generate files for photos and videos that will then be used by the camera and the methods that will save to the media store. Again, the code has been truncated for brevity. Please see the link below for the full code that you need to use:ProviderFileManager.kt

class ProviderFileManager(private val context: Context, private val fileHelper: FileHelper, private val contentResolver: ContentResolver, private val executor: Executor, private val mediaContentHelper: MediaContentHelper) {
    fun generatePhotoUri(time: Long): FileInfo {
      val name = "img_$time.jpg"
      val file = File(context.getExternalFilesDir(fileHelper.getPicturesFolder()), name)
      return FileInfo(fileHelper.getUriFromFile(file), file, name, fileHelper.getPicturesFolder(), "image/jpeg")
    }

Notice how we defined the root folders as context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) and context.getExternalFilesDir(Environment.DIRECTORY_MOVIES). This connects to file_provider_paths.xml and it will create a set of folders called Movies and Pictures in the application’s dedicated folder on the external storage. The insertToStore method is where the files will be then copied to the MediaStore. First, we will create an entry into that store which will give us a Uri for that entry. Next, we copy the contents of our files from the Uri generated by the FileProvider into the OutputStream pointing to the MediaStore entry.

9. Let’s define the layout for our activity in res/layout/activity_main.xml:

activity_main.xml < Button android: id = "@+id/photo_button"
android: layout_width = "wrap_content"
android: layout_height = "wrap_content"
android: text = "@string/photo" / > < Button android: id = "@+id/video_button"
android: layout_width = "wrap_content"
android: layout_height = "wrap_content"
android: layout_marginTop = "5dp"
android: text = "@string/video" / >

10. Let’s create the MainActivity class where we will check if we need to request the WRITE_STORAGE_PERMISSION, request it if we need to, and after it was granted open the camera to take a photo or a video. As above, the code has been truncated for brevity. You can access the full code using the link shown:MainActivity.kt

class MainActivity: AppCompatActivity() {
    companion object {
      private
      const val REQUEST_IMAGE_CAPTURE = private
      const val REQUEST_VIDEO_CAPTURE = private
      const val REQUEST_EXTERNAL_STORAGE =
    }
    private lateinit
    var providerFileManager: ProviderFileManager private
    var photoInfo: FileInfo ? = null private
    var videoInfo: FileInfo ? = null private
    var isCapturingVideo = false override fun onCreate(savedInstanceState: Bundle ? ) {
        super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) providerFileManager = ProviderFileManager(applicationContext, FileHelper(applicationContext), contentResolver, Executors.newSingleThreadExecutor(), MediaContentHelper())

If we execute the preceding code, we will see the following:Figure 11.9: Output of Exercise 11.06
Figure 11.9: Output of Exercise 11.06

By clicking on either of the buttons, you will be redirected to the camera application where you can take a photo or a video if you are running the example on Android 10 and above. If you’re running on lower Android versions then the permissions will be asked first. Once you have taken your photo and confirmed it, you will be taken back to the application. The photo will be saved in the location you defined in FileProvider:Figure 11.10: The location of the captured files through the camera app
Figure 11.10: The location of the captured files through the camera appIn the preceding screenshot, you can see where the files are located with the help of the Android Studio Device File Explorer.

Modify MainActivity and add the onActivityResult method to trigger the save of the files to the MediaStore:    

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent ? ) {
  when(requestCode) {
    REQUEST_IMAGE_CAPTURE -> {
      providerFileManager.insertImageToStore(photoInfo)
    }
    REQUEST_VIDEO_CAPTURE -> {
      providerFileManager.insertVideoToStore(videoInfo)
    }
    else -> {
      super.onActivityResult(requestCode, resultCode, data)
    }
  }
}

If you open any file exploring apps like the “Files” app or the Gallery or Google Photos app, you will be able to see the videos and pictures taken.Figure 11.11: The files from the app present in the File Explorer app

Figure 11.11: The files from the app present in the File Explorer app

Activity 11.01: Dog Downloader

You are tasked with building an application that will target Android versions above API 21 that will display a list of URLs for dog photos. The URL you will connect to is https://dog.ceo/api/breed/hound/images/random/{number}, where number will be controlled through a Settings screen where the user can choose the number of URLs they want to be displayed. The Settings screen will be opened through an option presented on the home screen. When the user clicks on a URL, the image will be downloaded locally in the application’s external cache path. While the image is being downloaded, the user will see an indeterminate progress bar. The list of URLs will be persisted locally using Room.

The technologies that will be used are the following:

  • Retrofit for retrieving the list of URLs and for downloading files
  • Room for persisting the list of URLs
  • SharedPreferences and PreferencesFragment for storing the number of URLs to retrieve
  • FileProvider for storing the files in the cache
  • Apache IO for writing the files
  • Repository for combining all the data sources
  • LiveData and ViewModel for handling the logic from the user
  • RecyclerView for the list of Items

The response JSON will look similar to this:

{

    “message”: [

        “https://images.dog.ceo/breeds/hound- afghan/n02088094_4837.jpg”,

        “https://images.dog.ceo/breeds/hound- basset/n02088238_13908.jpg”,

        “https://images.dog.ceo/breeds/hound- ibizan/n02091244_3939.jpg”

    ],

    “status”: “success”

}

Perform the following steps to complete this activity:

  1. Create an api package that will contain the network-related classes.
  2. Create a data class that will model the response JSON.
  3. Create a Retrofit Service class that will contain two methods. The first method will represent the API call to return a list of breeds, and the second method will represent the API call to download the file.
  4. Create a storage package and, inside the storage package, create a room package.
  5. Create the Dog entity, which will contain an autogenerated ID and a URL.
  6. Create the DogDao class, which will contain methods to insert a list of Dogs, delete all the Dogs, and query all Dogs. The delete method is required because the API model does not have any unique identifiers.
  7. Inside the storage package, create a preference package.
  8. Inside the preference package, create a wrapper class around SharedPreferences that will return the number of URLs we need to use. The default will be 10.
  9. In res/xml, define your folder structure for FileProvider. The files should be saved in the root folder of the external-cache-path tag.
  10. Inside the storage package, create a filesystem package.
  11. Inside the filesystem package, define a class that will be responsible for writing InputStream into a file in FileProvider, using Context.externalCacheDir.
  12. Create a repository package.
  13. Inside the repository package, create a sealed class that will hold the result of an API call. The subclasses of the sealed class will be SuccessError, and Loading.
  14. Define a Repository interface that will contain two methods, one to load the list of URLs, and the other to download a file.
  15. Define a DogUi model class that will be used in the UI layer of your application and that will be created in your repository.
  16. Define a mapper class that will convert your API models into entities and entities into UI models.
  17. Define an implementation for Repository that will implement the preceding two methods. The repository will hold references to DogDao, the Retrofit Service class, the Preferences wrapper class, the class managing the files, the Dog mapping class, and an Executor class for multithreading. When downloading the files, we will be using the filename extracted from the URL.
  18. Create a class that will extend Application, which will initialize the repository.
  19. Define the ViewModel used by your UI, which will have a reference to Repository and will call Repository to load the URL list and download the images.
  20. Define your UI, which will be composed of two activities:
    • The activity displays the list of URLs and will have the click action to start the downloads. This activity will have a progress bar, which will be displayed when the download takes place. The screen will also have a Settings option, which will open the Settings screen.
    • The Settings activity, which will display one setting indicating the number of URLs to load.

Summary

In this chapter, we’ve analyzed the different ways of persisting data in Android and how to centralize them through the repository pattern. We’ve started with a look at the pattern itself to see how we can organize the data sources by combining Room and Retrofit.

Then, we moved on to analyze alternatives to Room when it comes to persisting data. We looked first at SharedPreferences and how they constitute a handy solution for data persistence when it’s in a key-value format and the amount of data is small. We then looked at how you can use SharedPreferences to save data directly on the device, and then we examined PreferenceFragments and how they can be used to take in user input and store it locally.

Next, we looked over something that was in continuous change when it comes to the Android framework. That is the evolution of the abstractions regarding the filesystem. We started with an overview of the types of storage Android has and then took a more in-depth look at two of the abstractions: FileProvider, which your app can use to store files on the device and share them with others if there is a need to do so, and the SAF, which can be used to save files on the device in a location selected by the user.

We also used the benefits of FileProvider to generate URIs for files in order to use the camera applications to take photos and record videos and save them in the application’s files, while also adding them to MediaStore.

The activity performed in this chapter combines all the elements discussed above to illustrate the point that even though you have to balance multiple sources inside an application, you can do it in a more readable way.

Written by

XR Developer responsible for end-to-end development of XR solutions spanning multiple domains, by using various XR and WebXR libraries.

Leave a Reply