Unlike the single-concept recipes in app, this module is a complete app: a
Jetpack Compose port of the classic
GithubBrowserSample
that searches GitHub repositories, shows repository details and contributors, shows user profiles,
and lets you sign in with your GitHub account.
It shows how the AppState libraries fit into a real, multi-screen app:
StateStoreholds the authentication state (access token, signed-in login, signed-in user, and sign-in progress) in one place, shared by the network layer, repositories, and every screen. Withstatestore-datastore, the token and login persist across app restarts.Transformhandles state management throughout the app. EachViewModelexposes its UI state as ComposeStateproduced bytransform { ... }instead ofStateFlowpipelines.
AuthStateHolder wraps
the shared StateStore and declares a private StateStoreKey for each piece of authentication
state. Callers read State properties on the holder instead of reaching into the StateStore, so
the keys can't be read or written from anywhere else:
@Serializable
private object AccessTokenKey : StateStoreKey<String>(""), PersistToDataStore
@Serializable
private object LoggedInUserLoginKey : StateStoreKey<String>(""), PersistToDataStore
private object LoggedInUserKey : StateStoreKey<User?>(
defaultValue = null,
autoClearKey = LoggedInUserLoginKey,
shouldClearState = { stateStore -> stateStore.getState(LoggedInUserLoginKey).value.isBlank() }
)
private object LoginFlowStateKey : StateStoreKey<LoginFlowState>(LoginFlowState.Idle)
@Singleton
class AuthStateHolder @Inject constructor(private val stateStore: StateStore) {
val accessToken: State<String?> = derivedStateOf {
stateStore.getState(AccessTokenKey).value.ifBlank { null }
}
val loggedInUserLogin: State<String?> = /* ... */
val loggedInUser: State<User?> = /* ... */
val isLoggedIn: State<Boolean> = /* ... */
val loginFlowState: State<LoginFlowState> = stateStore.getState(LoginFlowStateKey)
fun clear() {
Snapshot.withMutableSnapshot {
stateStore.setState(AccessTokenKey, "")
stateStore.setState(LoggedInUserLoginKey, "")
stateStore.setState(LoggedInUserKey, null)
stateStore.setState(LoginFlowStateKey, LoginFlowState.Idle)
}
}
// ...
}AccessTokenKeyandLoggedInUserLoginKeyimplementPersistToDataStore, so they are saved to and restored from DataStore. A signed-out state is stored as""rather thannullso that logging out is persisted too.LoggedInUserKeyusesautoClearKeyso the cachedUseris cleared automatically when the signed-in login is cleared.LoginFlowStateKeyholds the progress of the GitHub device-flow sign-in (not persisted).- Updates that touch several keys (
setLoggedInUser,clear) run insideSnapshot.withMutableSnapshot, so readers never see a half-updated state.
Warning
PersistToDataStore writes values to disk as plain, unencrypted JSON. This sample persists the
access token that way to keep things simple, but production apps should encrypt auth tokens
before persisting them (for example, with a key stored in the
Android Keystore and
Tink).
AppModule provides a single
@Singleton StateStore, and
GithubApp syncs it to a DataStore file
when the app starts:
private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val dataStoreScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() {
super.onCreate()
authState.primeKeys()
mainScope.launch {
stateStore.syncToDataStore(
filesDir.resolve("github_auth_statestore.json").absolutePath,
dataStoreScope
)
}
}authState.primeKeys()registers the persisted keys beforesyncToDataStoreruns, so their values are restored at startup.syncToDataStoreis launched onDispatchers.Mainso its snapshot listener runs on the main thread, whiledataStoreScope(Dispatchers.IO) is passed to DataStore so file reads and writes stay off the main thread.
Because the authentication state lives in one StateStore instead of being copied into each
class, changes show up everywhere without extra plumbing:
| Component | How it uses AuthStateHolder |
|---|---|
AuthRepository |
Writes the token and user after the GitHub device flow succeeds, and clears them on logout or failure. |
AuthInterceptor |
Reads authState.accessToken.value on every request to add the Authorization header. |
SearchViewModel |
Drives the login dialog through loginFlowState and shows the signed-in user. |
UserViewModel |
Shows a Logout action when you view your own profile. Logging out here also updates the search screen. |
RepoViewModel |
Reloads repository data when the signed-in user changes, so requests use the new credentials. |
Each ViewModel builds its UI state with transform, a headless composition running in
viewModelScope. Inside the block you read Compose state (including StateStore state) directly,
and the block re-runs whenever any of it changes:
val results: State<Resource<List<Repo>>?> = transform(
initialValue = null,
scope = viewModelScope,
dispatcher = dispatcher
) {
val currentQuery = _query.value
val trigger = refreshTrigger.intValue
val activeLogin = authState.loggedInUserLogin.value
if (currentQuery.isEmpty()) {
null
} else {
remember(currentQuery, trigger, activeLogin) { repoRepository.search(currentQuery) }
.collectAsState(initial = null).value
}
}A few patterns to note:
- Mix local and shared state. The block reads the screen's own state (
_query,refreshTrigger) and the shared authentication state (loggedInUserLogin) the same way, with nocombineorflatMapLatest. - Wrap
Flowcreation inremember. Repositories still returnFlows from Room and Retrofit. Creating theFlowinsideremember(keys)and collecting it withcollectAsStaterestarts the request only when a key changes. Withoutremember, each emission would create a newFlowand restart collection. - Reload with keys. Adding
triggeroractiveLoginto therememberkeys is how retry, refresh, and "reload after sign-in" are implemented. - Write state in effects, not in composition. Screens pass navigation arguments to their
ViewModelinsideLaunchedEffect(for exampleLaunchedEffect(login) { setLogin(login) }), and thetransformblocks only read state.
UserViewModel and RepoViewModel share this pattern through small loadForLogin /
loadForRepo helpers.
MainActivity adds rememberViewModelStoreNavEntryDecorator() to NavDisplay, so each
ViewModel is scoped to its Navigation 3 back stack entry. When an entry is popped, its
ViewModel is cleared and its transform blocks stop running.
Select the githubbrowser run configuration in Android Studio, or run:
./gradlew :githubbrowser:installDebug
Search works without signing in. To sign in, tap Login with GitHub, copy the code shown in the dialog, and enter it at https://github.com/login/device.
The sample includes a default GitHub OAuth App client ID with device flow enabled. To use your own,
create an OAuth App,
enable Device Flow, and add its client ID to local.properties:
github.client.id=<your client id>
./gradlew :githubbrowser:testDebugUnitTest
The unit tests run on the JVM and pass a test dispatcher into each ViewModel. They cover:
AuthRepositoryTest: login, logout, andAuthStateHolderupdates.SearchViewModelTest: authentication staying in sync acrossSearchViewModel,UserViewModel, andRepoViewModelthrough the sharedAuthStateHolder.
- AppState:
statestore,statestore-datastore,transform - Hilt with
androidx.hilt:hilt-lifecycle-viewmodel-compose(hiltViewModel()) - Navigation 3 with
lifecycle-viewmodel-navigation3, Room, Retrofit, Coil