Android Jetpack Libraries
Expert integration patterns for core Jetpack libraries used in Android Automotive and standard Android apps. Follows Jetpack official guides and the Now in Android reference practices.
When to Use This Skill
- Adding or reviewing Room entities, DAOs, migrations, or relations.
- Implementing offline-first data sync with Room + Retrofit.
- Choosing between DataStore (Preferences vs Proto) and SharedPreferences.
- Setting up WorkManager for background jobs, constraints, and chaining.
- Implementing infinite scroll / paginated lists with Paging 3.
- Lifecycle collection of Flow / LiveData (repeatOnLifecycle, collectAsStateWithLifecycle).
Room — Local Database
Setup
// Entity
@Entity(tableName = "media_items")
data class MediaItemEntity(
@PrimaryKey val id: String,
val title: String,
val durationMs: Long,
val artworkUrl: String,
val cachedAt: Long = System.currentTimeMillis(),
)
// DAO
@Dao
interface MediaItemDao {
@Query("SELECT * FROM media_items ORDER BY title ASC")
fun observeAll(): Flow<List<MediaItemEntity>> // Flow for reactive updates
@Query("SELECT * FROM media_items WHERE id = :id")
suspend fun getById(id: String): MediaItemEntity?
@Upsert
suspend fun upsertAll(items: List<MediaItemEntity>)
@Query("DELETE FROM media_items WHERE cachedAt < :threshold")
suspend fun deleteOlderThan(threshold: Long)
}
// Database
@Database(
entities = [MediaItemEntity::class],
version = 2,
exportSchema = true,
)
abstract class AppDatabase : RoomDatabase() {
abstract fun mediaItemDao(): MediaItemDao
}
Dependency injection (Hilt)
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "app_db")
.addMigrations(MIGRATION_1_2)
.build()
@Provides
fun provideMediaItemDao(db: AppDatabase): MediaItemDao = db.mediaItemDao()
}
Migrations
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE media_items ADD COLUMN cachedAt INTEGER NOT NULL DEFAULT 0")
}
}
Rules
- Always
exportSchema = trueand commit the schema JSON to version control. - Use
@Upsert(Room 2.5+) instead of@Insert(onConflict = REPLACE). - Return
Flowfrom queries for reactive updates; usesuspendfor one-shot reads/writes. - Run migrations in CI: test with
MigrationTestHelper. - Never access
RoomDatabaseon the main thread.
Relations
data class ArtistWithAlbums(
@Embedded val artist: ArtistEntity,
@Relation(
parentColumn = "id",
entityColumn = "artistId",
)
val albums: List<AlbumEntity>,
)
@Transaction
@Query("SELECT * FROM artists")
fun observeArtistsWithAlbums(): Flow<List<ArtistWithAlbums>>
DataStore — Preferences & Settings
Preferences DataStore (key-value)
private val Context.dataStore by preferencesDataStore(name = "settings")
class SettingsRepository @Inject constructor(
@ApplicationContext private val context: Context,
) {
companion object {
val THEME_KEY = intPreferencesKey("theme")
val NOTIFICATIONS_KEY = booleanPreferencesKey("notifications_enabled")
}
val theme: Flow<Int> = context.dataStore.data
.catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e }
.map { prefs -> prefs[THEME_KEY] ?: 0 }
suspend fun setTheme(theme: Int) {
context.dataStore.edit { it[THEME_KEY] = theme }
}
}
Proto DataStore (type-safe, structured)
Use Proto DataStore when you have complex settings objects. Define a .proto schema
and use the generated classes. Prefer it over Preferences DataStore for non-trivial settings.
Rules
- Prefer DataStore over SharedPreferences in new code — it is coroutine-safe.
- Always add
.catch { e -> if (e is IOException) emit(emptyPreferences()) }to handle corruption. - Inject DataStore via Hilt — never pass context to call site.
WorkManager — Background Tasks
Worker (coroutine-based)
@HiltWorker
class SyncMediaWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val syncRepository: MediaSyncRepository,
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
return try {
syncRepository.syncAll()
Result.success()
} catch (e: Exception) {
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
}
}
Enqueue with constraints
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
val syncRequest = PeriodicWorkRequestBuilder<SyncMediaWorker>(
repeatInterval = 6,
repeatIntervalTimeUnit = TimeUnit.HOURS,
).setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"sync_media",
ExistingPeriodicWorkPolicy.KEEP,
syncRequest,
)
Rules
- Use
HiltWorker+@AssistedInjectfor dependency injection in Workers. - Always use
enqueueUniqueWorkorenqueueUniquePeriodicWorkto avoid duplicate jobs. - Implement retry logic with exponential backoff; limit
runAttemptCount. - For immediate one-time work use
OneTimeWorkRequest; for recurring usePeriodicWorkRequest.
Paging 3 — Infinite Lists
PagingSource
class MediaPagingSource @Inject constructor(
private val apiService: MediaApiService,
) : PagingSource<Int, MediaItem>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, MediaItem> {
val page = params.key ?: 1
return try {
val response = apiService.getMedia(page = page, size = params.loadSize)
LoadResult.Page(
data = response.items.map { it.toDomain() },
prevKey = if (page == 1) null else page - 1,
nextKey = if (response.items.isEmpty()) null else page + 1,
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, MediaItem>): Int? =
state.anchorPosition?.let { anchor ->
state.closestPageToPosition(anchor)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchor)?.nextKey?.minus(1)
}
}
ViewModel + Compose
@HiltViewModel
class MediaListViewModel @Inject constructor(
private val pagingSource: MediaPagingSource,
) : ViewModel() {
val pagingData: Flow<PagingData<MediaItem>> = Pager(
config = PagingConfig(pageSize = 20, prefetchDistance = 5),
pagingSourceFactory = { pagingSource },
).flow.cachedIn(viewModelScope)
}
// Compose
@Composable
fun MediaListScreen(viewModel: MediaListViewModel = hiltViewModel()) {
val pagingItems = viewModel.pagingData.collectAsLazyPagingItems()
LazyColumn {
items(count = pagingItems.itemCount, key = { pagingItems.peek(it)?.id ?: it }) { index ->
val item = pagingItems[index]
if (item != null) MediaItemRow(item = item)
}
pagingItems.apply {
when (loadState.append) {
is LoadState.Loading -> item { CircularProgressIndicator() }
is LoadState.Error -> item { RetryButton(onClick = ::retry) }
else -> Unit
}
}
}
}
Lifecycle — Safe Collection Patterns
Fragment / Activity
// Lifecycle-safe — stops collection when STOPPED, restarts when STARTED
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { render(it) }
}
}
Compose
// collectAsStateWithLifecycle automatically uses STARTED lifecycle
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LiveData → StateFlow migration
// Legacy LiveData (avoid in new code)
viewModel.items.observe(viewLifecycleOwner) { render(it) }
// Modern replacement
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.items.collect { render(it) }
}
}
Rules
- Never use
lifecycleScope.launchWhenStarted— it is deprecated; userepeatOnLifecycle. - Never collect
Flowin a plainlifecycleScope.launchwithoutrepeatOnLifecycle— it leaks. - Use
collectAsStateWithLifecycle()in Compose (notcollectAsState()).
AOSP — Jetpack in Android.bp
Jetpack libraries are available as AOSP prebuilts or AAOS platform libraries:
android_app {
name: "MyApp",
static_libs: [
// AndroidX (bundled as AAPT2 AARs in AOSP prebuilts)
"androidx.lifecycle_lifecycle-viewmodel-ktx",
"androidx.room_room-runtime",
"androidx.room_room-ktx",
"androidx.work_work-runtime-ktx",
"androidx.paging_paging-runtime",
"androidx.datastore_datastore-preferences",
// Hilt
"hilt_android",
],
plugins: [
"room_annotation_processor", // Room compiler
"hilt_compiler",
],
...
}
Prerequisites
- Android Studio (Flamingo or newer) or AOSP build environment set up.
- Android SDK Platform-Tools installed (
adbon PATH). - Target device or emulator running Android 11+ (API 30+).
- For AOSP modules:
repotool, AOSP source synced,lunchtarget configured.
Step-by-Step Workflows
Step 1: Add Jetpack dependencies
Use the AndroidX BOM or version catalog (libs.versions.toml); keep versions centralized.
Step 2: Define the data layer
Implement Room @Entity, @Dao, and @Database; or configure DataStore Proto/Preferences.
Step 3: Create the Repository
Wrap Jetpack data sources; expose data as Flow<T> for reactive consumption.
Step 4: Schedule background work (WorkManager)
Define a CoroutineWorker; enqueue with WorkManager.getInstance().enqueue() + constraints.
Step 5: Observe in ViewModel / UI
Collect Flow in viewModelScope; expose as StateFlow to Compose or View-based UI.
Troubleshooting
- Room
IllegalStateException: Migration required— incrementDATABASE_VERSIONand provide aMigrationobject; or usefallbackToDestructiveMigration()for dev builds. - WorkManager not executing — check
WorkInfo.StateviagetWorkInfoByIdLiveData(); ensure battery optimization is not restricting the app. DataStore IOExceptionon first read — DataStore file may be corrupted; handle the exception and callupdateData { defaultProto }to reset.- Paging
LoadStatestuck inLoading— thePagingSource.load()is not returning aLoadResult.Erroron failure; add proper error handling.
Pre-Commit Checklist
- Room: schema exported, migrations tested,
@Transactionon relations. - DataStore:
catch(IOException)guard present, injected via Hilt. - WorkManager: unique work name, retry logic, constraints defined.
- Paging:
getRefreshKeyimplemented,LoadStatehandled in UI. - Flow collected inside
repeatOnLifecycleorcollectAsStateWithLifecycle. - No
LiveData.observe()without properviewLifecycleOwner. - Database operations on IO dispatcher only.