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