Build-along tutorial · Kotlin Multiplatform · Android Telecom

Call Blocker, from an empty directory

Nine steps to an Android app that is your phone's dialer, sees an incoming call before it rings, and refuses the calls you told it to. Every step ends in a command to run and a specific thing you must see.

Step 01ran from zero

An empty directory that compiles

No app yet. A green build, so every later failure is readable.

No app yet, no UI, nothing to look at. The goal is a green build, because every later failure is easier to read when you know the build itself is sound.

basics Five files

Create these, and nothing else. Paths are relative to your project root.

gradle/libs.versions.toml
[versions]
kotlin = "2.2.20"
agp = "8.13.2"

[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }

The catalog is the single place a version number appears. Without it you get versions scattered across build files, and the failure mode is not a clear error — it is two modules resolving different copies of the same library and a cryptic crash at runtime.

settings.gradle.kts
rootProject.name = "CallBlocker"

pluginManagement {
    repositories { google(); gradlePluginPortal(); mavenCentral() }
}

dependencyResolutionManagement {
    repositories { google(); mavenCentral() }
}

include(":shared")
include(":androidApp")
build.gradle.kts (root)
plugins {
    alias(libs.plugins.kotlin.multiplatform) apply false
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.android.library) apply false
}

WHY apply false. The root project is not itself a Kotlin or Android module — it has no source. Declaring the plugins here puts them on the classpath once, at a single version, so the module build files can alias(...) them without repeating a version. Applying them at the root would try to make the root an Android module, which it is not.

shared/build.gradle.kts
plugins {
    alias(libs.plugins.kotlin.multiplatform)
    alias(libs.plugins.android.library)
}

kotlin {
    jvmToolchain(17)
    androidTarget()
}

android {
    namespace = "dev.example.callblocker.shared"
    compileSdk = 36
    defaultConfig { minSdk = 26 }
}

SCOPE One target, androidTarget(). A real KMP project adds iosArm64() and friends here, and Corta Spam does. Adding iOS now buys you nothing and costs you a Kotlin/Native toolchain download plus a second class of build error to debug while you are still trying to get one platform running.

shared/src/commonMain/kotlin/dev/example/callblocker/Greeting.kt
package dev.example.callblocker

fun greeting(): String = "Call blocker, shared module"

Step 02ran from zero

A Composable you can actually see

One line of text on a device. Three traps, all of which looked fine.

One line of text, on a device. This step has three traps in it and every one of them produced a build or a screen that looked fine.

basics Adding Compose to the catalog

Add to [versions]: compose-multiplatform = "1.11.1" and androidx-activityCompose = "1.9.3". Add the plugins compose-multiplatform (org.jetbrains.compose) and compose-compiler (org.jetbrains.kotlin.plugin.compose, version.ref kotlin), plus the library androidx-activity-compose. Declare both new plugins apply false in the root build file.

WHY two Compose plugins. org.jetbrains.compose supplies the multiplatform artifacts — the compose.runtime, compose.material3 accessors. kotlin.plugin.compose is the compiler plugin that rewrites your @Composable functions. Since Kotlin 2.0 the second is versioned with Kotlin, not with Compose, which is why its version.ref is kotlin. Getting this pair wrong produces "This version of the Compose Compiler requires Kotlin version ...", which names versions that both look correct.

shared/build.gradle.kts — add to the existing file
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(compose.runtime)
            implementation(compose.foundation)
            implementation(compose.material3)
            implementation(compose.ui)
        }
    }
}
shared/src/commonMain/kotlin/dev/example/callblocker/App.kt
package dev.example.callblocker

import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable

@Composable
fun App() {
    MaterialTheme {
        Surface {
            Text("Call blocker — nothing blocked yet")
        }
    }
}

going deeper The app module

androidApp/build.gradle.kts
plugins {
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.android.application)
    alias(libs.plugins.compose.multiplatform)
    alias(libs.plugins.compose.compiler)
}

android {
    namespace = "dev.example.callblocker"
    compileSdk = 36
    defaultConfig {
        applicationId = "dev.example.callblocker"
        minSdk = 26
        targetSdk = 36
        versionCode = 1
        versionName = "0.1.0"
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
    sourceSets["main"].java.srcDirs("src/main/kotlin")
}

kotlin { jvmToolchain(17) }

dependencies {
    implementation(project(":shared"))
    implementation(compose.runtime)
    implementation(libs.androidx.activity.compose)
}
androidApp/src/main/kotlin/dev/example/callblocker/MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent { App() }
    }
}
androidApp/src/main/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application
        android:label="Call Blocker"
        android:theme="@android:style/Theme.Material.Light.NoActionBar">
        <activity android:name=".MainActivity" android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>
gradle.properties
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
kotlin.code.style=official
android.useAndroidX=true
android.nonTransitiveRClass=true

Step 03ran from zero

Becoming the phone app

Take the dialer role now, while there is nothing to lose.

This is the step that decides whether the project is possible. Android does not let an ordinary app watch incoming calls. To see a call before it rings you must be the dialer, and taking that role costs you the whole in-call experience — you now owe the user an answer screen, a ringtone, and a call log. Do it now, while there is nothing to lose.

going deeper Eligibility is decided by your manifest

The role is not something you ask for and receive. AOSP's roles.xml lists required components, and an app missing any of them is not offered in the "Phone app" picker at all — with no error explaining why. You need both ACTION_DIAL activity filters (with and without the tel scheme) and an InCallService.

androidApp/src/main/AndroidManifest.xml — inside <application>
<activity android:name=".MainActivity" android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
    <intent-filter>
        <action android:name="android.intent.action.DIAL" />
        <category android:name="android.intent.category.DEFAULT" />
    </intent-filter>
    <intent-filter>
        <action android:name="android.intent.action.DIAL" />
        <category android:name="android.intent.category.DEFAULT" />
        <data android:scheme="tel" />
    </intent-filter>
</activity>

<service
    android:name=".ScreeningInCallService"
    android:permission="android.permission.BIND_INCALL_SERVICE"
    android:exported="true">
    <meta-data android:name="android.telecom.IN_CALL_SERVICE_UI" android:value="true" />
    <intent-filter>
        <action android:name="android.telecom.InCallService" />
    </intent-filter>
</service>

WHY exported="true" on a service you never want another app to call: Telecom is another process and must be able to bind it. The android:permission line is what makes that safe — only a caller holding BIND_INCALL_SERVICE, which is system-only, can connect.

androidApp/src/main/kotlin/dev/example/callblocker/ScreeningInCallService.kt
class ScreeningInCallService : InCallService() {
    override fun onCallAdded(call: Call) {
        super.onCallAdded(call)
        val number = call.details?.handle?.schemeSpecificPart.orEmpty()
        Log.i(TAG, "onCallAdded number=$number state=${call.state}")
    }

    override fun onCallRemoved(call: Call) {
        super.onCallRemoved(call)
        Log.i(TAG, "onCallRemoved")
    }

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

Also declare the hardware you now depend on: <uses-feature android:name="android.hardware.telephony" android:required="true" />. A device with no telephony has no calls to screen.

the hard part Taking the role without touching the screen

In the finished app you request the role properly, through RoleManager.createRequestRoleIntent(ROLE_DIALER), because that is the only way a real user can grant it. While developing, do it from adb:

adb shell cmd role add-role-holder android.app.role.DIALER dev.example.callblocker
adb shell cmd role get-role-holders android.app.role.DIALER

Step 04ran from zero

The number, and what it is not

A string chosen by the network, in whatever shape it felt like.

You have a number in a log line. Before building anything on it, be clear about what it is: a string chosen by the network, in whatever format the network felt like, which may be empty.

basics Reading the handle

private fun Call.handleNumber(): String =
    details?.handle?.schemeSpecificPart.orEmpty()

Both details and handle are genuinely nullable. A withheld or private caller arrives with no handle at all, and orEmpty() turning that into "" is a decision you are making — that an unknown caller is a caller with an empty number, not a crash and not a null to thread through everything downstream.

going deeper Three shapes, one subscriber

The same person can reach you as any of these:

+34611998877     international, states its country
0034611998877    international via the European access code
611998877        national — the network dropped the country entirely

CONTRAST The obvious move is to strip everything that is not a digit and compare. It is wrong, and the wrongness is not academic: +34611998877 becomes 34611998877, the national form stays 611998877, and they are not equal — so a person in your contacts is treated as a stranger. Corta Spam shipped that bug, fixed it in the comparison function, and then shipped it again three days later because one caller normalised its input before handing it over.

The whole of step 8 is this problem. For now, only take away the rule: never compare phone numbers with ==.

Step 05ran from zero

A blocklist that survives a restart

Two tables, and why precedence is not expressed in SQL.

Two tables and a generated API. The interesting decision is not SQLDelight; it is that rule precedence is not expressed in SQL.

basics Catalog and plugin

Add to [versions]: sqldelight = "2.3.2" and kotlinx-coroutines = "1.9.0". Add libraries sqldelight-runtime, sqldelight-android-driver and kotlinx-coroutines-core, and the plugin sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }. Declare it apply false at the root.

shared/build.gradle.kts — complete file at this point
plugins {
    alias(libs.plugins.kotlin.multiplatform)
    alias(libs.plugins.android.library)
    alias(libs.plugins.compose.multiplatform)
    alias(libs.plugins.compose.compiler)
    alias(libs.plugins.sqldelight)
}

kotlin {
    jvmToolchain(17)
    androidTarget()

    sourceSets {
        commonMain.dependencies {
            implementation(compose.runtime)
            implementation(compose.foundation)
            implementation(compose.material3)
            implementation(compose.ui)
            implementation(libs.sqldelight.runtime)
            implementation(libs.kotlinx.coroutines.core)
        }
        androidMain.dependencies {
            implementation(libs.sqldelight.android.driver)
        }
    }
}

android {
    namespace = "dev.example.callblocker.shared"
    compileSdk = 36
    defaultConfig { minSdk = 26 }
}

sqldelight {
    databases {
        create("AppDatabase") {
            packageName.set("dev.example.callblocker.db")
        }
    }
}

WHY packageName in the sqldelight {} block has to match where you want the generated AppDatabase to appear. Get it wrong and the class exists but your import does not resolve, which reads like a build failure and is a typo.

going deeper The schema

shared/src/commonMain/sqldelight/dev/example/callblocker/db/AppDatabase.sq
CREATE TABLE BlockedNumber (
    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    number TEXT NOT NULL,
    label TEXT,
    created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', 'now') AS INTEGER)),
    UNIQUE(number)
);

CREATE TABLE CallLogEntry (
    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    number TEXT NOT NULL,
    timestamp INTEGER NOT NULL,
    action TEXT NOT NULL CHECK (action IN ('ALLOWED', 'BLOCKED'))
);

selectAllBlockedNumbers:
SELECT * FROM BlockedNumber ORDER BY created_at DESC;

insertBlockedNumber:
INSERT OR IGNORE INTO BlockedNumber(number, label) VALUES (?, ?);

deleteBlockedNumber:
DELETE FROM BlockedNumber WHERE id = ?;

insertCallLogEntry:
INSERT INTO CallLogEntry(number, timestamp, action) VALUES (?, ?, ?);

selectAllCallLogEntries:
SELECT * FROM CallLogEntry ORDER BY timestamp DESC LIMIT 100;

WHY no join across a blocklist and an allowlist here. A single clever query would be untestable without a database and unreadable the moment a third rule type arrives. In code it is a pure function over data a test can construct in two lines — which is step 6.

SCOPE The CHECK on action is worth having from the first day. It is the difference between a typo failing at insert time and a call log quietly containing three spellings of "blocked".

the hard part One driver per platform

shared/src/commonMain/kotlin/dev/example/callblocker/db/DriverFactory.kt
package dev.example.callblocker.db

import app.cash.sqldelight.db.SqlDriver

expect class DriverFactory {
    fun createDriver(): SqlDriver
}
shared/src/androidMain/kotlin/dev/example/callblocker/db/DriverFactory.android.kt
package dev.example.callblocker.db

import android.content.Context
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver

actual class DriverFactory(
    private val context: Context,
) {
    actual fun createDriver(): SqlDriver =
        AndroidSqliteDriver(AppDatabase.Schema, context, "callblocker.db")
}

In a JVM unit test you swap this for the JDBC driver and get a real SQLite engine with no emulator — which is how the persistence layer gets tested at all.

Step 06ran from zero

Your first blocked call

The app stops observing and starts acting. The cost of error changes.

Everything so far has been observation. Now the app acts, and the cost of being wrong changes: a missed spam call is an annoyance, a missed real one can be an emergency.

basics The engine

shared/src/commonMain/kotlin/dev/example/callblocker/rules/RuleEngine.kt
package dev.example.callblocker.rules

/** What the engine decided, and why — the reason is what the call log stores. */
sealed interface Decision {
    val isBlocked: Boolean

    data object Allowed : Decision {
        override val isBlocked = false
    }

    data class Blocked(val reason: String) : Decision {
        override val isBlocked = true
    }
}

/**
 * Pure: everything it needs is passed in, so it is testable without a database or a phone.
 *
 * Precedence is expressed here rather than in SQL for that reason — see step 5.
 */
object RuleEngine {
    fun evaluate(
        number: String,
        blockedNumbers: List<String>,
        contactNumbers: Set<String> = emptySet(),
    ): Decision {
        if (normalizedIsEmpty(number)) return Decision.Allowed

        // 1. Contacts bypass everything below. Compared with sameNumber, never with ==, and
        //    against the numbers AS SAVED — normalising them first is what broke this in the
        //    real app, because it strips the '+' sameNumber reads.
        if (contactNumbers.any { PhoneNumberParser.sameNumber(it, number) }) return Decision.Allowed

        // 2. Manual block list.
        if (blockedNumbers.any { PhoneNumberParser.sameNumber(it, number) }) {
            return Decision.Blocked("MANUAL")
        }

        return Decision.Allowed
    }

    private fun normalizedIsEmpty(number: String) = PhoneNumberParser.normalizeForComparison(number).isEmpty()
}

Nothing here knows about Android, SQLite or Telecom, so a test constructs its inputs directly. Precedence is the order of the ifs and nothing else — contacts before the blocklist, deliberately, so that adding your mother to a blocklist by accident is recoverable and adding her to your contacts is authoritative.

going deeper Wiring it to the call

androidApp/src/main/kotlin/dev/example/callblocker/ScreeningInCallService.kt — complete file
package dev.example.callblocker

import android.telecom.Call
import android.telecom.InCallService
import android.util.Log
import dev.example.callblocker.db.AppDatabase
import dev.example.callblocker.db.DriverFactory
import dev.example.callblocker.rules.RuleEngine
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

class ScreeningInCallService : InCallService() {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

    private val ringer by lazy { CallRinger(this) }

    private val database by lazy { AppDatabase(DriverFactory(applicationContext).createDriver()) }

    override fun onCallAdded(call: Call) {
        super.onCallAdded(call)
        val number = call.handleNumber()
        Log.i(TAG, "onCallAdded number=$number state=${call.state}")
        // Ring first, decide second: evaluation touches a database, and a caller must
        // never be dropped into silence because that was slow.
        if (call.state == Call.STATE_RINGING) ringer.start()

        scope.launch {
            try {
                val decision =
                    withContext(Dispatchers.IO) {
                        val blocked =
                            database.appDatabaseQueries
                                .selectAllBlockedNumbers()
                                .executeAsList()
                                .map { it.number }
                        val d = RuleEngine.evaluate(number, blocked)
                        database.appDatabaseQueries.insertCallLogEntry(
                            number = number,
                            timestamp = System.currentTimeMillis(),
                            action = if (d.isBlocked) "BLOCKED" else "ALLOWED",
                        )
                        d
                    }
                Log.i(TAG, "decision=$decision for $number")
                if (decision.isBlocked) {
                    ringer.stop()
                    call.reject(false, null)
                }
            } catch (e: CancellationException) {
                throw e
            } catch (e: Exception) {
                // Fail open: an unblocked nuisance beats a silently dropped emergency.
                Log.e(TAG, "evaluation failed, failing open", e)
            }
        }
    }

    override fun onCallRemoved(call: Call) {
        super.onCallRemoved(call)
        ringer.stop()
        Log.i(TAG, "onCallRemoved")
    }

    override fun onDestroy() {
        super.onDestroy()
        scope.cancel()
    }

    private fun Call.handleNumber(): String = details?.handle?.schemeSpecificPart.orEmpty()

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

WHY ring first, decide second. Evaluation touches a database. If you wait for it before ringing, a slow query drops a legitimate caller into silence; ringing immediately and stopping a few hundred milliseconds later costs a blocked spammer a quarter-second of ringtone.

WHY CancellationException is rethrown before the general catch. Swallowing it breaks structured concurrency — the coroutine keeps running after its scope is gone. Everything else fails open.

Step 07ran from zero

Ringing, because now you must

Step 3's debt comes due: Telecom stops ringing and trusts you.

The debt from step 3 comes due. Declare IN_CALL_SERVICE_RINGING and Telecom stops ringing entirely, trusting you to do it.

Add the meta-data beside the one from step 3:

<meta-data android:name="android.telecom.IN_CALL_SERVICE_RINGING" android:value="true" />
androidApp/src/main/kotlin/dev/example/callblocker/CallRinger.kt
package dev.example.callblocker

import android.content.Context
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.MediaPlayer
import android.media.RingtoneManager
import android.util.Log

/**
 * Plays the ringtone, because declaring IN_CALL_SERVICE_RINGING means Telecom will not.
 *
 * Honours the system ringer mode exactly as the platform dialer does: silent rings not at all.
 * Getting that wrong means ringing in a meeting after the user silenced their phone.
 */
class CallRinger(
    private val context: Context,
) {
    private var player: MediaPlayer? = null

    fun start() {
        val audio = context.getSystemService(AudioManager::class.java)
        if (audio?.ringerMode != AudioManager.RINGER_MODE_NORMAL) {
            Log.i(TAG, "ringer mode ${audio?.ringerMode}; not playing a ringtone")
            return
        }
        if (player != null) return

        val uri = RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_RINGTONE) ?: return
        player =
            MediaPlayer().apply {
                setAudioAttributes(
                    AudioAttributes
                        .Builder()
                        .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
                        .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
                        .build(),
                )
                setDataSource(context, uri)
                isLooping = true
                setOnPreparedListener { start() }
                prepareAsync()
            }
    }

    fun stop() {
        player?.runCatching {
            stop()
            release()
        }
        player = null
    }

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

Then hold one in the service, start it while the call is ringing, and stop it the moment a block decision lands or the call goes away — both edits are already in the step 6 file above.

the hard part Asserting it

# 1. Telecom confirms it handed ringing over
adb logcat -d | grep -c "letDialerHandleRinging=true"

# 2. a player owned by YOUR pid, started, with the ringtone usage
adb shell dumpsys audio | grep AudioPlaybackConfiguration | grep USAGE_NOTIFICATION_RINGTONE

# 3. the vibration running is attributed to your package
adb shell dumpsys vibrator_manager

Signal 2 is the one that cannot be faked. During a ringing call it reads:

AudioPlaybackConfiguration piid:143 type:android.media.MediaPlayer u/pid:10218/4420
  state:started attr:AudioAttributes: usage=USAGE_NOTIFICATION_RINGTONE
  content=CONTENT_TYPE_SONIFICATION ... sampleRate=44100

…where 4420 is your app's pid, the same one printing CallBlocker lines in logcat. An emulator usually reports no vibration hardware, so signal 3 is a warning there rather than a failure.

Step 08ran from zero

Phone numbers are not strings

The step that decides whether the app is usable.

The step that decides whether the app is usable. Everything else can be wrong and merely annoying; this one blocks people the user knows.

basics The problem, concretely

rule saved:  +34611998877     call arrives:  611998877        must match
rule saved:  611 99 88 77     call arrives:  +34611998877     must match
rule saved:  +34611998877     call arrives:  +51611998877     must NOT match

The third line is what makes this hard. Spain's 611998877 and Peru's 611998877 are different people who share a national number.

going deeper Two approaches that look right

CONTRAST 1 — compare the last 7–9 digits. What PhoneNumberUtils.compare does, and it makes all three lines pass. It also lets a stranger who shares a tail match your allowlist. This function guards blocking; trading a correctness bug for a security-shaped one is a bad trade.

CONTRAST 2 — canonicalise to E.164 with a default region. Now you need the SIM or locale region, an ISO-to-dialling-code table, and a trunk-prefix rule that differs per country: dropping a leading zero is right for the UK and wrong for Italy, whose E.164 keeps it. Three new failure modes for a guess you did not have to make.

the hard part Let the number state its own country

shared/src/commonMain/kotlin/dev/example/callblocker/rules/PhoneNumberParser.kt
package dev.example.callblocker.rules

/**
 * Comparing phone numbers without guessing a region.
 *
 * The country-code list is deliberately short here; a real app needs the full ITU list. What
 * matters is the shape: a code is only ever read off a number that actually wrote itself in
 * international form.
 */
object PhoneNumberParser {
    private val knownCodes = listOf("1", "34", "39", "44", "51", "52", "55").sortedByDescending { it.length }

    private const val MAX_E164_DIGITS = 15

    fun normalizeForComparison(number: String): String = number.filter { it.isDigit() }

    /** Canonical `+<digits>`, or null when [number] was not written in international form. */
    fun toE164OrNull(number: String): String? {
        val trimmed = number.trim()
        val digits =
            when {
                trimmed.startsWith("+") -> normalizeForComparison(trimmed)
                trimmed.startsWith("00") -> normalizeForComparison(trimmed).drop(2)
                else -> return null
            }
        if (digits.isEmpty() || digits.length > MAX_E164_DIGITS) return null
        return "+$digits"
    }

    /** Every string this number may legitimately be recognised by. */
    fun comparisonKeys(number: String): Set<String> {
        val digits = normalizeForComparison(number)
        if (digits.isEmpty()) return emptySet()
        val keys = mutableSetOf(digits)
        nationalSignificantOrNull(number)?.let { keys += it }
        return keys
    }

    private fun nationalSignificantOrNull(number: String): String? {
        val e164 = toE164OrNull(number)?.removePrefix("+")
        if (e164 != null) {
            val code = knownCodes.firstOrNull { e164.startsWith(it) && e164.length >= it.length + 4 }
            return code?.let { e164.drop(it.length) }
        }
        val digits = normalizeForComparison(number)
        return if (digits.length > 1 && digits.startsWith("0")) digits.drop(1) else null
    }

    /**
     * True when [a] and [b] can be the same subscriber.
     *
     * The national form may bridge them only when at least one side does not state its country.
     * When both are international those codes are facts, and +34611998877 is not +51611998877.
     */
    fun sameNumber(a: String, b: String): Boolean {
        val da = normalizeForComparison(a)
        val db = normalizeForComparison(b)
        if (da.isEmpty() || db.isEmpty()) return false
        if (da == db) return true

        val aIntl = toE164OrNull(a) != null
        val bIntl = toE164OrNull(b) != null
        if (aIntl && bIntl) return toE164OrNull(a) == toE164OrNull(b)

        val keysA = comparisonKeys(a)
        return comparisonKeys(b).any { it in keysA }
    }
}

WHY candidate sets rather than one canonical string per side: the trunk zero must be available both stripped and intact, because the UK drops it in E.164 and Italy keeps it. Reducing each side to a single string satisfies one country and breaks the other.

Step 09checks ran, script did not

Proving it, on calls a machine places

A harness, and the two ways a green suite lies.

You have a call blocker. What you do not have is any reason to believe it works, because every check so far was you watching one call at a time.

going deeper The harness

The emulator's fake modem is the whole reason this is automatable. Save as scripts/matrix.sh:

#!/usr/bin/env bash
set -euo pipefail
PKG=dev.example.callblocker
DB=callblocker.db
ADB="adb -s ${DEVICE:-emulator-5554}"

pull_db() { $ADB exec-out run-as "$PKG" cat "databases/$DB" > /tmp/m.db; }

max_log_id() { pull_db; sqlite3 /tmp/m.db "SELECT COALESCE(MAX(id),0) FROM CallLogEntry;"; }

place_call() {            # $1 = number -> echoes the action of the NEW row
  local baseline; baseline=$(max_log_id)
  $ADB emu gsm call "$1" >/dev/null; sleep 5
  $ADB emu gsm cancel "$1" >/dev/null 2>&1 || true
  for _ in $(seq 1 15); do
    sleep 1; pull_db
    got=$(sqlite3 /tmp/m.db "SELECT action FROM CallLogEntry WHERE id > $baseline ORDER BY id DESC LIMIT 1;")
    [ -n "$got" ] && { echo "$got"; return; }
  done
  echo "NO_NEW_ROW"       # a real finding, not a wrong verdict
}

fail=0
check() {                 # $1 = number, $2 = expected, $3 = scenario
  got=$(place_call "$1")
  if [ "$got" = "$2" ]; then printf "  PASS %-16s %s\n" "$1" "$3"
  else printf "  FAIL %-16s expected %s, got %s\n" "$1" "$2" "$got"; fail=1; fi
}

check 611998877     BLOCKED "national form of a blocked +34 number"
check +34611998877  BLOCKED "the blocked number exactly"
check +51611998877  ALLOWED "same national digits, different country"
check 5559998888    ALLOWED "no rule matches"

exit $fail

WHY poll for a row with id > baseline rather than reading the newest row. "This call was never logged" is a real finding, and it must not be able to masquerade as an old row carrying the wrong verdict — which is exactly what reading ORDER BY id DESC LIMIT 1 alone would do.

WHY more than half the rows assert ALLOWED. A blocker that blocks everything passes every BLOCKED row.

the hard part Two ways a green suite lies

1. It asserts absence. A test that a blocked call produces no ringtone passes against an app with no ringtone code whatsoever. The real app had six such tests and a ringer that never rang. Prefer positive assertions — assert the player started, not that nothing happened.

2. It never reaches the state. Every call test in that project covered calls that ring, get blocked, or get missed — never one that was answered. An Android 14 restriction crashed the app the instant any call was answered, and the suite stayed green because nothing had ever entered that state.

Both are the same defect: not in the assertions, but in the state space they cover. When a suite is green and a user is unhappy, ask which state the suite has never entered.

You have an app that is your phone's dialer, sees calls before they ring, blocks by rule, rings when it should and stays silent when it should not. What it does not have: an in-call screen worth using, a call log, notifications, quiet hours, contacts integration, more than one language, or an iOS target.

All of those exist, built on exactly this skeleton, in the Corta Spam course — 28 chapters of what happened after this point, including the bugs. Read chapter 20 ("Code That Ships But Never Runs") before you write your next feature, and chapter 28 before you write your next fix.