Skip to content

Seeding a Room Database via ADB BroadcastReceiver in Kotlin

Real codebase

The code in this article is extracted directly from a production Kotlin Multiplatform personal finance app. All snippets reflect actual implementation, not simplified examples.

Manually tapping through a finance app to recreate accounts, transactions, budgets, and subscriptions every time you wipe the database is not a workflow. It is a tax on iteration speed. This post covers a BroadcastReceiver based database seeder for a Kotlin Multiplatform personal finance app that lets you populate Room entities with a single terminal command. Three non-obvious bugs make this harder than it looks all of them are covered here.

Prerequisites

Kotlin 1.9 or later, Room 2.6 or later, and Koin 3.5 or later. The post assumes you are already familiar with Room entities, DAOs, and basic Koin module setup. ADB must be on your PATH.

Versions used in this post

Room 2.7 · Koin 4.0 · Kotlin 2.1 · Android minSdk 26 · targetSdk 35

The app targeted here is a KMP personal finance tracker with the following Room entities: Tag, Account, AccountTransaction, Subscription, SubscriptionPayment, Expense, ExpensePayment, Budget, and BudgetContribution. All have foreign key relationships. That last detail is what you should look out for in this approach.

Background

Every development workflow that involves a local database needs deterministic, realistic seed data. The typical approaches hardcoding data in Application.onCreate, writing a dedicated test activity, or using Room's createFromAsset all have the same flaw: they are either one-shot (runs once on install), visible in production code, or require building a separate APK variant.

A BroadcastReceiver triggered by ADB gives you a command you can run at any point during development, against the live running app, without modifying any screen code. It also composes cleanly with a Makefile so the entire operation is make seed.

What most seed implementations get wrong

Using OnConflictStrategy.REPLACE for upserts on entities with foreign key children will silently delete those children. SQLite's REPLACE strategy is DELETE + INSERT, which fires the ON DELETE CASCADE rule and wipes every related row.

Implementation

1. The receiver

BroadcastReceiver.onReceive runs on the main thread with a five-second window before Android kills it. Any database work must move off that thread immediately. goAsync() extends the window and returns a PendingResult you must call finish() on when the coroutine completes.

debug/SeedDatabaseReceiver.kt
class SeedDatabaseReceiver : BroadcastReceiver(), KoinComponent {

    private val tagRepo: TagRepository by inject()
    private val expenseRepo: ExpenseRepository by inject()
    private val budgetRepo: BudgetRepository by inject()
    private val subscriptionRepo: SubscriptionRepository by inject()
    private val accountRepo: AccountRepository by inject()

    override fun onReceive(context: Context, intent: Intent) {
        val pendingResult = goAsync()
        CoroutineScope(Dispatchers.IO).launch {
            try {
                seed()
                Log.i(TAG, "Database seeded successfully")
            } catch (e: Exception) {
                Log.e(TAG, "Seed failed: ${e.message}", e)
            } finally {
                pendingResult.finish()
            }
        }
    }

    companion object {
        private const val TAG = "SeedDatabaseReceiver"
    }

    private suspend fun seed() { /* see below */ }
}

KoinComponent is what allows by inject() to resolve repositories at runtime without constructor injection. Because BroadcastReceiver instances are not created by Koin, constructor injection is not available here. As long as the app process is alive and startKoin has been called, inject() resolves correctly.

2. Seed data with stable IDs

Use fixed string IDs across all seed entities. This makes the seeder idempotent: running make seed twice replaces data in place rather than duplicating it, because Room's @Insert(onConflict = OnConflictStrategy.REPLACE) on entities without FK children handles it safely.

debug/SeedDatabaseReceiver.kt
private suspend fun seed() {
    val now = System.currentTimeMillis()
    val day = 86_400_000L

    // Tags must be inserted before any entity that references tagId
    val tags = listOf(
        Tag(id = "tag-food",          tagName = "Food & Dining",  colorHex = "#FF6B35", icon = "restaurant"),
        Tag(id = "tag-transport",     tagName = "Transport",      colorHex = "#4A90D9", icon = "directions_car"),
        Tag(id = "tag-entertainment", tagName = "Entertainment",  colorHex = "#9B59B6", icon = "movie"),
        Tag(id = "tag-health",        tagName = "Health",         colorHex = "#E74C3C", icon = "favorite"),
        Tag(id = "tag-utilities",     tagName = "Utilities",      colorHex = "#F39C12", icon = "bolt"),
    )
    tags.forEach { tagRepo.insertTag(it) }

    val accounts = listOf(
        Account(id = "acc-gtbank", name = "GTBank",  accountNumber = "0123456789", type = AccountType.BANK,          balance = 450_000.0),
        Account(id = "acc-opay",  name = "Opay",    accountNumber = "08012345678", type = AccountType.MOBILE_WALLET, balance = 85_000.0),
        Account(id = "acc-cash",  name = "Cash",    accountNumber = "",            type = AccountType.CASH,          balance = 12_000.0),
    )
    accounts.forEach { accountRepo.upsertAccount(it) }

    // Transactions reference accounts — insert accounts first
    val accountTransactions = listOf(
        AccountTransaction(id = "at-1", accountId = "acc-gtbank", amount =  150_000.0, type = TransactionType.INCOME,   description = "Salary",      date = now - 30 * day),
        AccountTransaction(id = "at-2", accountId = "acc-gtbank", amount =  -25_000.0, type = TransactionType.EXPENSE,  description = "Rent",        date = now - 28 * day),
        AccountTransaction(id = "at-3", accountId = "acc-gtbank", amount =  -15_000.0, type = TransactionType.TRANSFER, description = "Top up Opay", date = now - 20 * day, relatedAccountId = "acc-opay"),
        AccountTransaction(id = "at-4", accountId = "acc-opay",   amount =   15_000.0, type = TransactionType.TRANSFER, description = "From GTBank", date = now - 20 * day, relatedAccountId = "acc-gtbank"),
    )
    accountTransactions.forEach { accountRepo.insertTransaction(it) }

    // Subscriptions, expenses, budgets, and their child payments follow the same pattern
}

The insertion order matters: parent rows must exist before child rows that carry their foreign keys. The sequence here is Tags → Accounts → AccountTransactions → Subscriptions → SubscriptionPayments → Expenses → ExpensePayments → Budgets → BudgetContributions.

3. Manifest registration

AndroidManifest.xml
<receiver
    android:name=".debug.SeedDatabaseReceiver"
    android:exported="true">
    <intent-filter>
        <action android:name="com.fidelis.uwem.debug.SEED_DATABASE" />
    </intent-filter>
</receiver>

android:exported="true" is required. See the pitfalls section for why false silently does nothing on Android 13 and above.

4. Makefile targets

Makefile
APP_ID  := com.fidelis.uwem
SEED_RX := $(APP_ID)/.debug.SeedDatabaseReceiver

seed:
    adb shell am broadcast \
        -n $(SEED_RX) \
        -a com.fidelis.uwem.debug.SEED_DATABASE

logcat-seed:
    adb logcat -s "SeedDatabaseReceiver"

install:
    ./gradlew :composeApp:assembleDebug && \
    adb install -r composeApp/build/outputs/apk/debug/composeApp-debug.apk

Testing

Run make logcat-seed in one terminal before triggering the seed so you see the output in real time:

$ make logcat-seed
--------- beginning of main
I SeedDatabaseReceiver: Database seeded successfully

If the seed fails, the catch block logs the full exception with stack trace to the same tag:

E SeedDatabaseReceiver: Seed failed: FOREIGN KEY constraint failed
    android.database.SQLException: Error code: 787 ...

After a successful seed, the app should be populated with realistic data. The account screen should show three accounts with realistic balances and transaction history visible in each detail screen.

Pitfalls

OnConflictStrategy.REPLACE wipes FK children

Room's @Insert(onConflict = OnConflictStrategy.REPLACE) translates to SQLite INSERT OR REPLACE, which is internally DELETE + INSERT. If the replaced row has child rows linked by a CASCADE foreign key, those children are deleted. The symptom is that transactions disappear from the database every time the account balance is updated. The fix is a targeted @Query("UPDATE accounts SET balance = :balance WHERE id = :id") for balance mutations rather than replacing the entire account row.

android:exported=\"false\" blocks ADB broadcasts on Android 13+

On API 33 and above, explicit ADB broadcasts to non-exported receivers complete with result=0 but deliver nothing. The receiver is never called. result=0 is the default broadcast result code and does not indicate successful delivery. It only means the broadcast was dispatched. Debug receivers must be exported="true".

A compile error in the receiver means the old APK runs silently

If the receiver file fails to compile for example, referencing BudgetPeriod.QUARTERLY when the enum only declares WEEKLY, MONTHLY, YEARLY, and CUSTOM — Gradle fails the build. If you then run make seed without running make install first, the previous APK is still installed, the receiver does not exist in it, and the broadcast completes with result=0 as if everything worked. Always run make install before make seed when receiver code has changed.

Production considerations

This receiver should never ship in a release build. Gate it behind BuildConfig.DEBUG in the manifest using a debug flavour manifest overlay, or strip it with a Gradle productFlavors block that excludes the debug/ source set entirely from the release variant.

The seeder inserts synchronously within a single coroutine on Dispatchers.IO. For larger data sets with hundreds of rows, consider wrapping the entire seed() body in a Room withTransaction block to make the operation atomic. A partially completed seed with failed FK inserts leaves the database in an inconsistent state that can be hard to diagnose.

Koin's by inject() inside a BroadcastReceiver only works while the app process is alive. If the device kills the process and the broadcast wakes it, KoinApplication will not have been initialised. For a seeder that only runs during active development this is acceptable; the app just needs to be open before you run make seed.

Wrapping up

The receiver approach gives you a reusable, process-safe, idempotent database seeder that works against the live app without touching screen code. The three bugs documented here FK cascade from REPLACE, the exported flag, and the stale APK trap each produce the same unhelpful symptom: Broadcast completed: result=0. Knowing which one to check first saves significant time.


Comments