How GSY GitHub App Uses DataStore for Persisting User Preferences in Android

The GSY GitHub App Compose uses Android DataStore (Preferences) to persist authentication credentials and app language settings through type-safe key-value storage, exposing reactive flows for real-time UI updates and interceptor injection.

The carguo/gsygithubappcompose repository demonstrates a production-grade implementation of DataStore for persisting user preferences in a Jetpack Compose application. Unlike legacy SharedPreferences, DataStore provides coroutine-safe, transactional storage with built-in type safety that survives process restarts.

Core Architecture of DataStore Implementation

The application separates concerns through interface abstraction and dependency injection, ensuring the UI and network layers remain decoupled from storage implementation details.

UserPreferencesDataStore and Interface Abstraction

Concrete storage logic resides in UserPreferencesDataStore.kt, which implements the IUserPreferencesDataStore interface defined in IUserPreferencesDataStore.kt. This abstraction exposes three critical preference keys as stringPreferencesKey instances: auth token, username, and user ID.

The interface provides reactive access through Flow<String?> streams and suspend functions for persistence:

  • saveAuthToken(token: String)
  • saveUsername(username: String)
  • saveUserId(userId: String)
  • clearAll()
  • authToken: Flow<String?>
  • username: Flow<String?>
  • userId: Flow<String?>

According to the source code in core/common/src/main/java/com/shuyu/gsygithubappcompose/core/common/datastore/UserPreferencesDataStore.kt, writes occur through context.dataStore.edit { preferences -> ... }, which atomically updates the underlying preferences.pb file.

Dependency Injection with DataStoreModule

The DataStoreModule.kt file in core/common/src/main/java/com/shuyu/gsygithubappcompose/core/common/di/ binds the concrete implementation to the interface using Hilt:

@Module
@InstallIn(SingletonComponent::class)
abstract class DataStoreModule {
    @Binds
    abstract fun bindUserPreferencesDataStore(
        impl: UserPreferencesDataStore
    ): IUserPreferencesDataStore
}

This module also provides a singleton LanguageDataStore, demonstrating how the app scales DataStore usage beyond authentication data.

Reading and Writing Preferences

The repository layer handles credential persistence after successful authentication, while network interceptors consume these preferences to inject authorization headers.

Persisting Credentials After Login

In data/src/main/java/com/shuyu/gsygithubappcompose/data/repository/UserRepository.kt, the UserRepository persists OAuth or token-based credentials immediately after validation:

suspend fun login(token: String): Result<User> {
    val user = apiService.getAuthenticatedUser("token $token")
    preferencesDataStore.saveAuthToken(token)        // ← persisted
    preferencesDataStore.saveUsername(user.login)   // ← persisted
    preferencesDataStore.saveUserId(user.id.toString())
    return Result.success(user)
}

Logout operations call preferencesDataStore.clearAll(), which atomically removes all stored keys. The repository exposes login state through a derived flow:

fun isLoggedIn(): Flow<Boolean> =
    preferencesDataStore.authToken.map { it?.isNotEmpty() == true }

Reactive Token Consumption in Network Layer

The TokenInterceptor in core/network/src/main/java/com/shuyu/gsygithubappcompose/core/network/interceptor/TokenInterceptor.kt demonstrates how to bridge DataStore's asynchronous API with synchronous interceptor requirements.

The interceptor maintains an in-memory MutableStateFlow cache that updates from the DataStore flow:

init {
    interceptorScope.launch {
        userPreferencesDataStore.authToken.collect { newToken ->
            _token.value = newToken
        }
    }
}

When intercepting requests, it falls back to synchronous reading if the cache is empty:

override fun intercept(chain: Interceptor.Chain): Response {
    var currentToken = _token.value
    if (currentToken == null) {
        currentToken = runBlocking { userPreferencesDataStore.authToken.first() }
        _token.value = currentToken
    }
    currentToken?.let {
        request = request.newBuilder()
            .header("Authorization", "token $it")
            .build()
    }
    return chain.proceed(request)
}

Language Preferences with DataStore

Beyond authentication, the app uses DataStore for UI configuration through LanguageDataStore.kt in core/common/src/main/java/com/shuyu/gsygithubappcompose/core/common/datastore/.

This component persists the selected app_language using the same patterns as user credentials:

suspend fun setAppLanguage(lang: AppLanguage) {
    languageDataStore.saveAppLanguage(lang)   // writes to DataStore
}

The LanguageDataStore exposes appLanguage as Flow<AppLanguage>, allowing the UI layer to react to locale changes without manual polling.

Summary

  • Interface abstraction decouples storage implementation from consumers through IUserPreferencesDataStore.
  • Reactive streams provide real-time updates via Kotlin Flow, eliminating manual synchronization between components.
  • Atomic transactions ensure data consistency through DataStore.edit { } blocks that write to preferences.pb.
  • Synchronous fallback using runBlocking { flow.first() } bridges async DataStore with synchronous network interceptor requirements.
  • Scalable pattern demonstrated by parallel implementations for authentication (UserPreferencesDataStore) and configuration (LanguageDataStore).

Frequently Asked Questions

What is DataStore and why does GSY GitHub App use it instead of SharedPreferences?

DataStore is a Jetpack library providing type-safe, coroutine-aware key-value storage using protocol buffers. The GSY GitHub App uses it because it guarantees type safety (preventing ClassCastException), supports atomic transactions (all-or-nothing writes), and integrates natively with Kotlin coroutines and Flow. Unlike SharedPreferences, DataStore migrates data asynchronously and never blocks the UI thread on I/O operations.

How does the app handle authentication token persistence across process restarts?

The UserRepository persists the token immediately after successful OAuth validation using preferencesDataStore.saveAuthToken(token). This writes to the DataStore file on disk via context.dataStore.edit. Upon process restart, the TokenInterceptor subscribes to userPreferencesDataStore.authToken as a Flow, maintaining an in-memory MutableStateFlow cache that updates automatically when stored values change.

Can DataStore be used synchronously when immediate values are needed?

While DataStore is designed for asynchronous access, the TokenInterceptor demonstrates a safe synchronous fallback pattern: runBlocking { userPreferencesDataStore.authToken.first() }. This blocks the current thread only during the first read if the in-memory cache is empty, ensuring HTTP headers can be injected immediately while maintaining the reactive architecture for subsequent updates.

How does the app separate user preferences from other settings like language?

The codebase uses separate DataStore instances managed through distinct classes. UserPreferencesDataStore handles authentication data (token, username, ID) while LanguageDataStore manages UI configuration (app_language). Both implement similar patterns—exposing Flows and suspend functions—but inject separately via DataStoreModule, preventing key collisions and allowing independent testing and lifecycle management.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →