Let’s Roll the Dice
In The Busy Coder’s Guide to Android Development, one of the sample apps is a “diceware” app, to help you generate a passphrase made up of a series of randomly-selected words, such as correct horse battery staple
. However, that sample puts most of the work inside a single fragment, which is messy. So, let’s rebuild that app, hiding all of the data-loading details in a repository, with a view-model to mediate communications between the fragment and the repository. The results can be found in the Diceware/Repository
sample project.
The Repository
In our case, the words come from two locations: a “baked in” word list in assets/
and a word list of the user’s choosing, obtained via ACTION_OPEN_DOCUMENT
. However, the data structure for each is the same: a list of words, one per line. Hence, we do not have a sophisticated data model, only a list of strings. So our repository does not need to worry about normalizing disparate model objects, though we might if we obtained words from some Web service. And, our repository does not need to worry about data modification, as the word lists are treated as read-only.
However, we still need a nice reactive API. The code for getting the words from a user-chosen document is a bit different from the code for getting the words from an asset. Moreover, if we want to cache the words, we need to handle the case where we have not yet loaded the words and the case where the words are cached.
API
In the end, what our UI needs is a set of randomly-selected words, with the UI providing the number of words and the source of those words.
To that end, Repository
has a single instance method that is exposed to the rest of the app: getWords()
. It takes the Uri
representing the data source and the number of words to return. The words themselves will be a List
of String
objects. We wrap that in an RxJava Single
, as we do not know how long it will take to come up with those words at compile time, since the word list from the data source may not be cached yet. However, we do know that this is a one-shot event, and so Single
makes more sense than does a generic Observable
.
The Repository
is a singleton, so we will have a static
method named get()
to retrieve that singleton, given a Context
to use for lazy initialization.
Implementation
getWords()
breaks the problem down into two pieces: getting the full word list and then choosing a random subset of those words:
Single<List<String>> getWords(Uri source, final int count) {
return(getWordsFromSource(source)
.map(strings -> (randomSubset(strings, count))));
}
The map()
operator delegates the “choose a random subset” work to a randomSubset()
method, which uses a SecureRandom
instance to choose the words:
private List<String> randomSubset(List<String> words, int count) {
List<String> result=new ArrayList<>();
int size=words.size();
for (int i=0;i<count;i++) {
result.add(words.get(random.nextInt(size)));
}
return(result);
}
getWordsFromSource()
needs to look to see if we have a cached copy of the word list for the requested Uri
. If not, we need to arrange to load and cache those words; otherwise, we can just use the cache. Our cache is ConcurrentHashMap
mapping the Uri
to the word list:
private final ConcurrentHashMap<Uri, List<String>> cache=new ConcurrentHashMap<>();
getWordsFromSource()
checks the cache and creates an Single
chain based on whether or not the words are cached:
synchronized private Single<List<String>> getWordsFromSource(Uri source) {
List<String> words=cache.get(source);
final Single<List<String>> result;
if (words==null) {
result=Single.just(source)
.subscribeOn(Schedulers.io())
.map(uri -> (open(uri)))
.map(in -> (readWords(in)))
.doOnSuccess(strings -> cache.put(source, strings));
}
else {
result=Single.just(words);
}
return(result);
}
If the words are cached, our job is simple: just return that word list, wrapped in an Single
, for getWords()
to use to come up with the random subset.
If the words are not yet cached, we:
- Wrap the
Uri
in anSingle
to start a chain - Use an
open()
method to get anInputStream
on the contents identified by thatUri
(or pulling in our one-and-only asset if theUri
seems to point to assets):
private InputStream open(Uri uri) throws IOException {
String scheme=uri.getScheme();
String path=uri.getPath();
if ("file".equals(scheme) && path.startsWith("/android_asset")) {
return(ctxt.getAssets().open(ASSET_FILENAME));
}
ContentResolver cr=ctxt.getContentResolver();
cr.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
return(cr.openInputStream(uri));
}
- Use a
readWords()
method to convert thatInputStream
into a word list:
private static List<String> readWords(InputStream in) throws IOException {
InputStreamReader isr=new InputStreamReader(in);
BufferedReader reader=new BufferedReader(isr);
String line;
List<String> result=new ArrayList<>();
while ((line = reader.readLine())!=null) {
String[] pieces=line.split("\s");
if (pieces.length==2) {
result.add(pieces[1]);
}
}
return(result);
}
- Arrange to do all that work on a background thread
- As a side effect, put the word list in the cache for later use, via
doOnSuccess()
The ViewModel
The Repository
API is fairly clean, isolating the caching and data loading and stuff behind a reactive response. However, there is one hiccup: each call to getWords()
results in a new Single
. This is somewhat of a headache for the UI, as we would need a fresh subscription — via a fresh LiveData
— whenever the user asks for a new set of words, or changes the word count, or opens a new word list. That is on top of having to manage the subscriptions across lifecycle events.
What would be nice is if the UI could have a single Observable
, on which all the words would come in, regardless of the trigger (including getting a set of words on first launch). We would still have to deal with lifecycle events, but we have LiveData
for that.
So, this app has a ViewModel
implementation — named PassphraseViewModel
— that offers a LiveData
of the incoming words that the UI can use, in addition to tracking our current word source and word count across configuration changes.
Repository Integration
The PassphraseViewModel
constructor takes, among other things, a Context
as a parameter, to use to retrieve the Repository
singleton, held in a field named repo
. We also have source
and count
fields to hold how many words we should retrieve and where we should retrieve them from, initialized to some starter values:
private final Repository repo;
private Uri source=Uri.parse("file:///android_asset/eff_short_wordlist_2_0.txt");
private int count=6;
private Disposable sub=Disposables.empty();
Getting words to the UI is handled by a words()
method, that returns a LiveData
for random word list subsets. That LiveData
is in the form of a liveWords
field:
LiveData<List<String>> words() {
return(liveWords);
}
As noted, though, we will get multiple Single
instances from the Repository
, one for each getWords()
call that we need. If we want the UI to use this stable LiveData
instance, we need a way to feed different Single
results to it over time.
The approach that PassphraseViewModel
takes is to have liveWords
be a MutableLiveData
:
private final MutableLiveData<List<String>> liveWords=new MutableLiveData<>();
The PassphraseViewModel
has a refresh()
method. Partly, this is used literally for a “refresh” operation, to load a fresh batch of words given the current count
and source
values. In fact, everything else that needs to trigger loading words routes through refresh()
. refresh()
calls the getWords()
method that we have on Repository
and forwards the events to the liveWords
by using a Java 8 method reference to tie the subscribe()
of the Single
to postValue()
of the liveWords
:
void refresh() {
sub.dispose();
sub=repo.getWords(source, count)
.observeOn(Schedulers.io())
.subscribe(liveWords::postValue);
}
The net effect is that every time refresh()
is called, the liveWords
eventually will deliver a new random subset of the current word list.
Saving State
We need to get the source
and the count
from the UI, for use in refresh()
. And, along the way, we can hold onto that information across configuration changes, since this is a ViewModel
. Plus, we can also have the PassphraseViewModel
store this information in the saved instance state Bundle
, so the view-model is the single “source of truth” for the current source
and count
.
To that end, the constructor on PassphraseViewModel
takes a saved instance state Bundle
as input and — if the Bundle
is not null
— populates the source
and count
from its contents:
PassphraseViewModel(Context ctxt, Bundle state) {
repo=Repository.get(ctxt);
if (state!=null) {
source=state.getParcelable(STATE_SOURCE);
count=state.getInt(STATE_COUNT, 6);
}
refresh();
}
The constructor also calls refresh()
, to queue up the first random set of words, so we can populate the UI as quickly as possible.
PassphraseViewModel
then has its own onSaveInstanceState()
, where it fills in the state Bundle
using the same keys that its constructor uses to read the values out:
void onSaveInstanceState(Bundle state) {
state.putParcelable(STATE_SOURCE, source);
state.putInt(STATE_COUNT, count);
}
(hat tip to Danny Preussler for the idea of centralizing both view-model and saved instance state logic in the ViewModel
)
The Factory
However, by default, the Architecture Components’ ViewModel
system has no way to create an instance of PassphraseViewModel
. After all, it has no idea what this Bundle
is.
To help with that, PassphraseViewModel
has a Factory
nested class that implements ViewModelProvider.Factory
. This provides the “glue” for tying the Architecture Components to PassphraseViewModel
, by creating an instance of PassphraseViewModel
as needed:
static class Factory implements ViewModelProvider.Factory {
private final Bundle state;
private final Context ctxt;
Factory(Context ctxt, Bundle state) {
this.ctxt=ctxt.getApplicationContext();
this.state=state;
}
@NonNull
@Override
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
return((T)new PassphraseViewModel(ctxt, state));
}
}
When we create an instance of the Factory
, we need to provide a Context
(such as the Activity
hosting our UI) and the incoming saved instance state Bundle
, for the Factory
to pass along to the newly-created instance.
The Fragment
The launcher (and only) activity — MainActivity
— simply sets up a PassphraseFragment
:
package com.commonsware.android.diceware;
import android.os.Bundle;
import android.support.v4.app.FragmentActivity;
public class MainActivity extends FragmentActivity {
@Override
protected void onCreate(Bundle state) {
super.onCreate(state);
if (getSupportFragmentManager().findFragmentById(android.R.id.content) == null) {
getSupportFragmentManager().beginTransaction()
.add(android.R.id.content,
new PassphraseFragment()).commit();
}
}
}
All of the real UI/UX work resides in the fragment.
The UI
The UI for PassphraseFragment
consists of a TextView
for the words, wrapped in a CardView
to make it a bit more aesthetically interesting:
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="8dp">
<android.support.v7.widget.CardView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:padding="8dp">
<TextView
android:id="@+id/passphrase"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="20sp"
android:typeface="monospace" />
</android.support.v7.widget.CardView>
</FrameLayout>
The core of our UI setup is in onViewCreated()
:
@Override
public void onViewCreated(View view, Bundle state) {
super.onViewCreated(view, state);
passphrase=view.findViewById(R.id.passphrase);
viewModel=ViewModelProviders
.of(this, new PassphraseViewModel.Factory(getActivity(), state))
.get(PassphraseViewModel.class);
updateMenu();
viewModel.words().observe(this,
words -> passphrase.setText(TextUtils.join(" ", words)));
}
Here, we:
- Retrieve the
passphrase
TextView
to hold our random word list subset - Obtain our
PassphraseViewModel
, by way ofViewModelProviders
and ourFactory
- Update our menu, described in the next section
-
observe()
ourLiveData
, taking the list of words and populating theTextView
, joining those words with spaces
For a newly-created PassphraseViewModel
, the constructor’s call to refresh()
will give us some words to show automatically. On a configuration change, our LiveData
will hand back our last set of words automatically. Plus, the LiveData
handles the rest of the lifecycle work for us.
The Menu
The fragment also has a menu, with a drop-down for the word count, a “refresh” item to get a fresh random subset of words, and an “open” item to choose a word list document:
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/word_count"
android:showAsAction="ifRoom"
android:title="@string/menu_words">
<menu>
<group android:checkableBehavior="single">
<item
android:id="@+id/word_count_4"
android:title="4" />
<item
android:id="@+id/word_count_5"
android:title="5" />
<item
android:id="@+id/word_count_6"
android:checked="true"
android:title="6" />
<item
android:id="@+id/word_count_7"
android:title="7" />
<item
android:id="@+id/word_count_8"
android:title="8" />
<item
android:id="@+id/word_count_9"
android:title="9" />
<item
android:id="@+id/word_count_10"
android:title="10" />
</group>
</menu>
</item>
<item
android:id="@+id/refresh"
android:icon="@drawable/ic_cached_white_24dp"
android:showAsAction="ifRoom"
android:title="@string/menu_refresh" />
<item
android:id="@+id/open"
android:enabled="false"
android:showAsAction="never"
android:title="@string/open" />
</menu>
The “refresh” item ties directly to the refresh()
method on the view-model, while the word count items update their checked state and route to a setCount()
method on the view-model:
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch(item.getItemId()) {
case R.id.open:
open();
return(true);
case R.id.refresh:
viewModel.refresh();
return(true);
case R.id.word_count_4:
case R.id.word_count_5:
case R.id.word_count_6:
case R.id.word_count_7:
case R.id.word_count_8:
case R.id.word_count_9:
case R.id.word_count_10:
item.setChecked(!item.isChecked());
viewModel.setCount(Integer.parseInt(item.getTitle().toString()));
return(true);
}
return(super.onOptionsItemSelected(item));
}
The “open” item routes to an open()
method, which brings up an ACTION_OPEN_DOCUMENT
activity for the user to choose a word list:
private void open() {
Intent i=
new Intent()
.setType("text/plain")
.setAction(Intent.ACTION_OPEN_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE);
startActivityForResult(i, REQUEST_OPEN);
}
If we get a document, that is passed over to a setSource()
method on the view-model:
@Override
public void onActivityResult(int requestCode, int resultCode,
Intent resultData) {
if (resultCode==Activity.RESULT_OK) {
viewModel.setSource(resultData.getData());
}
}
The setCount()
and setSource()
methods on PassphraseViewModel
not only update their respective fields, but they also call refresh()
, to deliver a fresh set of words based on the new count or new source of words:
void setSource(Uri source) {
this.source=source;
refresh();
}
void setCount(int count) {
this.count=count;
refresh();
}
As a result, when the user chooses any of those action bar items, if there is an actual state change (e.g., a new count), we get a new roster of words.
We also need to set the checked state of the word count items based on the count
, either from the default value or from our state (view-model or saved instance state). Since we do not know whether the menu or the view-model will be set up first, we call a central updateMenu()
method from a couple of places to check the right action bar item:
private void updateMenu() {
if (menu!=null && viewModel!=null) {
MenuItem checkable=menu.findItem(WORD_COUNT_MENU_IDS[viewModel.getCount()-4]);
if (checkable!=null) {
checkable.setChecked(true);
}
}
}
And, our fragment’s onSaveInstanceState()
forwards that Bundle
to the PassphraseViewModel
for saving the state:
@Override
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
viewModel.onSaveInstanceState(outState);
}
The Event Flow
When our fragment is created, we create our view-model. It, in turn, asks the repository to give us our initial random subset of words, triggering some background file I/O to read in the initial word list. When that is done, the random words wind their way to the fragment, which pops them into the UI.
When the user requests different words — a different count, a different source, or just words that maybe they might like better — the fragment updates the view-model, which in turn asks the repository for words for the now-current word count and word source. Eventually, another random subset of words make its way to fragment, which displays them using the same code as before.
On a configuration change, our newly-recreated fragment winds up connecting to the same view-model as before, courtesy of the Architecture Components’ ViewModel
system. Our LiveData
gives us back the words we were showing in the previous fragment, so we can show them again.
So, each layer has its role in the event flow:
- The fragment manages the UI, including the menu
- The repository manages the data loading and random-subset work
- The view-model mediates the communications between them, folding all of the disparate
Single
objects from the repository into a single stream of events for the fragment to consume
And, of course, the user gets a reasonably-secure passphrase to use for some app or site that needs it:
Prev Table of Contents Next
This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.