Collecting a Passphrase

Another option is to have the passphrase be stored in the user’s memory. We have them choose a passphrase when we go to create the database, and we have them supply that passphrase again later when opening the database in a fresh app process.

Adding a Passphrase Field

One downside to this approach is that we have to add some UI to our app to collect that passphrase from the user.

To keep the example simple — if perhaps not very pretty — this example just shoves a password EditText and a Button into the layout that we use for the list of to-do items.

<?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=".ui.MainActivity">

  <TextView
    android:id="@+id/empty"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="@string/msg_empty"
    android:textAppearance="?android:attr/textAppearanceMedium"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="parent" />

  <androidx.recyclerview.widget.RecyclerView
    android:id="@+id/items"
    android:layout_width="0dp"
    android:layout_height="0dp"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent" />

  <EditText
    android:id="@+id/passphrase"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:hint="@string/passphrase_hint"
    android:importantForAutofill="no"
    android:inputType="textPassword"
    android:maxLines="1"
    app:layout_constraintBottom_toTopOf="@id/open"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintVertical_chainStyle="packed" />

  <Button
    android:id="@+id/open"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:text="@string/button_open"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@id/passphrase" />

  <androidx.constraintlayout.widget.Group
    android:id="@+id/authGroup"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:visibility="gone"
    app:constraint_referenced_ids="passphrase,open"
    tools:visibility="visible" />

</androidx.constraintlayout.widget.ConstraintLayout>

The objective is to then show those widgets at the outset, so we can collect the passphrase from the user:

ToDoUser, Requesting a Passphrase
ToDoUser, Requesting a Passphrase

Detecting We Need a Passphrase

Our viewmodel will need to track whether or not we need to be asking for the passphrase and should be showing those new widgets. So, we have our view-state — here called RosterViewState — wrap both the list of to-do items and a flag indicating whether or not authentication is required:

data class RosterViewState(
  val items: List<ToDoModel> = listOf(),
  val authRequired: Boolean = false
)

We then have our LiveData from this viewmodel be for this RosterViewState. And, we can go ahead and initialize it to start with authRequired = true, since the flow of this app pretty much guarantees that if this viewmodel is first being created, we are going to need the passphrase:

  private val _states =
    MutableLiveData<RosterViewState>(RosterViewState(authRequired = true))
  val states: LiveData<RosterViewState> = _states

Our view — RosterListFragment — can then observe that stream of view-states and update the visible set of widgets to match:

    motor.states.observe(viewLifecycleOwner) { state ->
      adapter.submitList(state.items)

      when {
        state.authRequired -> {
          binding.authGroup.isVisible = true
          binding.empty.isVisible = false
        }
        state.items.isEmpty() -> {
          binding.authGroup.isVisible = false
          binding.empty.isVisible = true
          binding.empty.setText(R.string.msg_empty)
        }
        else -> {
          binding.authGroup.isVisible = false
          binding.empty.isVisible = false
        }
      }
    }

So, when we launch the app and we get to this screen, initially, we will ask for the passphrase.

Applying the Passphrase

When the user clicks the button, we call an open() function on the viewmodel (RosterMotor), supplying the contents of the EditText:

    binding.open.setOnClickListener {
      motor.open(binding.passphrase.text.toString())
    }

With the previous versions of these samples, RosterMotor could just start working with the database right away, as the passphrase was either hard-coded or app-generated. Now, since we need a user-supplied passphrase, we need to delay opening the database until we have that passphrase, and that is what open() does:

  fun open(passphrase: String) {
    viewModelScope.launch {
      if (repo.openDatabase(context, passphrase)) {
        repo.items().collect { _states.value = RosterViewState(items = it) }
      } else {
        _states.value = RosterViewState(authRequired = true)
      }
    }
  }

openDatabase() on ToDoRepository will open or create the database using the supplied passphrase. It will return true if that succeeds or false otherwise. In the former case, we load our to-do list items and emit a fresh view-state with that data (and with authRequired set to false, the default). In the latter case, we ensure that our LiveData has authRequired = false. The UX will be that if the user mis-types their passphrase, nothing really happens — this is not ideal, but it keeps the example simple.

Creating and Opening the Database

In earlier examples, ToDoRepository received a ToDoEntity.Store as a constructor parameter via dependency inversion. Now, it receives a ToDoDatabase.Factory instead:

class ToDoRepository(
  private val dbFactory: ToDoDatabase.Factory,
  private val appScope: CoroutineScope
) {

That is because while previously Koin could open the database on its own, we now need to wait until the passphrase is available.

ToDoDatabase.Factory knows how to open a database, given a passphrase:

package com.commonsware.todo.repo

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import net.sqlcipher.database.SupportFactory

private const val DB_NAME = "stuff.db"

@Database(entities = [ToDoEntity::class], version = 1)
@TypeConverters(TypeTransmogrifier::class)
abstract class ToDoDatabase : RoomDatabase() {
  abstract fun todoStore(): ToDoEntity.Store

  class Factory {
    fun newInstance(context: Context, passphrase: ByteArray) =
      Room.databaseBuilder(context, ToDoDatabase::class.java, DB_NAME)
        .openHelperFactory(SupportFactory(passphrase))
        .build()
  }
}

The koinModule in KoinApp now sets up a singleton instance of ToDoDatabase.Factory, to satisfy the ToDoRepository requirement:

    single { ToDoDatabase.Factory() }
    single {
      ToDoRepository(
        get(),
        get(named("appScope"))
      )

ToDoRepository now has a db property that holds the ToDoDatabase… if it has been opened. Otherwise, it remains null:

  private var db: ToDoDatabase? = null

  fun isReady() = db != null

The openDatabase() function that our viewmodel called will use that ToDoDatabase.Factory to open the database and populate that db property:

  suspend fun openDatabase(context: Context, passphrase: String): Boolean {
    try {
      db = dbFactory.newInstance(context, passphrase.toByteArray())
      db?.todoStore()?.count()
    } catch (t: Throwable) {
      try { db?.close() } catch (t2: Throwable) { }
      db = null
      Log.e("ToDoUser", "Exception opening database", t)
    }

    return isReady()
  }

openDatabase() uses the ToDoDatabase.Factory to get the ToDoDatabase instance, supplying the passphrase. However that does not trigger the database to be opened. Normally, having the database be opened lazily is a fine Room feature. In this case, though, we have to worry about the possibility that the passphrase is wrong. So, we now have a count() function on our DAO, that just returns a count of the to-do item rows:

  @Dao
  interface Store {
    @Query("SELECT * FROM todos ORDER BY description")
    fun all(): Flow<List<ToDoEntity>>

    @Query("SELECT * FROM todos WHERE id = :modelId")
    fun find(modelId: String?): Flow<ToDoEntity?>

    @Query("SELECT COUNT(*) FROM todos")
    suspend fun count(): Long

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun save(vararg entities: ToDoEntity)

    @Delete
    suspend fun delete(vararg entities: ToDoEntity)
  }

This is designed to be fairly cheap to execute while ensuring that the passphrase works. So, openDatabase() calls count(). If that throws an exception, then presumably the passphrase was mis-entered, so we close() the database and set db back to null to indicate that we have not successfully opened the database. openDatabase() then returns the Boolean indicating success or failure. All of this is done in a suspend function, so it can be performed on a background thread.

The remaining functions on ToDoRepository now use db to access the database and throw an exception if the database is not currently open:

  fun items(): Flow<List<ToDoModel>> =
    db?.todoStore()?.let { store ->
      store.all().map { all -> all.map { it.toModel() } }
    } ?: throw IllegalStateException("database is not open")

  fun find(id: String?): Flow<ToDoModel?> =
    db?.todoStore()?.let { store ->
      store.find(id).map { it?.toModel() }
    } ?: throw IllegalStateException("database is not open")

  suspend fun save(model: ToDoModel) {
    withContext(appScope.coroutineContext) {
      db?.todoStore()?.save(ToDoEntity(model))
        ?: throw IllegalStateException("database is not open")
    }
  }

  suspend fun delete(model: ToDoModel) {
    withContext(appScope.coroutineContext) {
      db?.todoStore()?.delete(ToDoEntity(model))
        ?: throw IllegalStateException("database is not open")
    }
  }

Pros and Cons

The good news is that the user knows the passphrase. Backups of the database can be made and restored, and the user should retain access to them. The database could even be transferred to some other device or platform, and the user can retain access.

The bad news is that the user knows the passphrase. This means that the user might be convinced to give up the passphrase and thereby lose control over their data.


Prev Table of Contents Next

This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.