Skip to content

About

Open-source call blocker for Android. Screens every incoming call by your own rules, before the phone rings. No ads, no tracking, no network code. Kotlin Multiplatform.

Topics

Resources

Stars

6 stars

Watchers

0 watching

Forks

Latest commit

 

History

302 Commits

Folders and files

Repository files navigation

Corta Spam

Open-source call blocking app. No ads. No tracking. No data leaves your device.

Screens incoming calls before your phone rings. Checks every number against your rules — manual blocklist, pattern matching, country blocking, quiet hours — plus an optional spam list bundled inside the app (no network, ever). Blocks, allows, or answers with a custom greeting.

i18n: English, Spanish (LATAM), Portuguese (Brazil), Hindi.

Free, and staying free — nothing is asked for inside the app. If it has been useful: 💜 GitHub Sponsors · ☕ Ko-fi · PayPal · Yape / Plin · or just star the repo, which helps more. Everything, and why these and not others: DONATE.md.

Status

M0–M13 complete, including M12's adaptive layout (both tablet list-detail panes now in). 4-language i18n. Open source under MIT License. 762 automated tests pass. Android APK builds. iOS shell builds and runs, but call blocking there is still pending the CallDirectory extension.

Features

  • Manual blocking — block or allow specific numbers, with an optional label shown in the list and in matching Call Log rows
  • Pattern rules — block by prefix, suffix, or wildcard (+34900*, *1234)
  • Country blocking — block all numbers from a country code, matched only against numbers actually written in international form (+34… or 0034…), so blocking Morocco never blocks a Manhattan 212 number
  • Repeat-caller rules — block a number after it tries N times inside a window, optionally scoped to a pattern
  • Quiet hours — silence all calls on a schedule (TimePicker with presets: Night, Siesta, Work)
  • Auto-responder (Experimental, off by default) — answer blocked calls with TTS greeting or custom audio. Switching it on is confirmed through a dialog that says what the user will see — the phone answers, the loudspeaker comes on, and with recording the call stays connected for up to a minute — because a phone picking up by itself is otherwise indistinguishable from a bug; "Test greeting" button previews it locally, no real call needed. The default greeting and the recording-consent phrase are localized, and playback forces the speaker so the caller can actually hear it. A "How this works" card states the real limits — only blocked calls are answered, and the greeting reaches the caller acoustically — and a custom audio file keeps a persisted read grant so it still plays days later, falling back to the spoken script if it ever cannot be opened
  • Caller message recording (Experimental, off by default) — records what a blocked caller says after the greeting, capped at 60s, playable and deletable from its call-log entry. Gated on both a consent phrase in your own greeting and the Android microphone permission. Records through the microphone, because Android reserves the actual call-audio sources for privileged apps — so on phones whose manufacturer locks the mic during a call it captures nothing
  • A blocked call always ends — rejected while it is ringing, hung up if anything answered it first, and hung up after the auto-responder greeting whether or not the greeting ever played. A call the app has told you it blocked is never one you are still connected to
  • Repeated-caller bypass — opt-in: an unknown number that would otherwise be silently blocked gets let through once it retries enough times, with a heads-up on the ringing screen and a notification. Never applies to numbers matched by a manual block, pattern, country, spam, or schedule rule
  • Keypad — a dial pad tab, because taking the default-dialer role replaces the phone app. Also where an ACTION_DIAL intent from any other app lands, pre-filled. "Create new contact" hands what you typed to the platform's own contact editor, so a number can be kept as well as dialled
  • Contact search — the keypad's one field is both the number being dialled and a search box: type a name and matching contacts appear, type digits and they are matched against contact numbers as digits, so 611 finds +34 611 99 88 77. Results float over the screen in a window of their own rather than occupying a band of it, so nothing is reserved when there is nothing to show and the pad below cannot be pushed anywhere. Tapping a result fills the number in, leaving the call itself one deliberate press away
  • Agenda — a contacts tab: the whole address book, with the phone's own starred contacts across the top, filter chips for All / Starred / Blocked / Allowed, a search box, and pull-to-refresh. Tapping anyone opens Call / Block / Allow / Copy. The Blocked chip is the question no other app on the phone can answer — the block list holds numbers, so it cannot say which of the people you know you have silenced
  • Call-log filters — search by name or number, filter by direction and outcome (All / Incoming / Outgoing / Blocked), and by date (Today / This week / This month). Pull down to reload the contact names beside the calls: the calls themselves arrive as a database flow, the names come from a five-minute cache the gesture goes past
  • Call log — a recents list: every call in both directions, with local timestamp, outcome, rule detail, and the contact's name when it matches one (list-detail two-pane on tablet). Outgoing calls are labelled as outgoing rather than "allowed", because they are never screened and no decision was made about them
  • Dark mode — the whole app follows the system setting, on the palette the UI was designed with. It matters most on the screen the app draws without being asked: the full-screen incoming-call screen, which arrives at whatever hour someone rings
  • Emergency callback exemption — for 30 minutes after you call the emergency services, every incoming call is let through whatever your rules say. On by default, and checked before every rule including a manual block. Uses the platform's own emergency-callback signal plus the numbers this app saw you dial; it needs no extra permission, and the 30 minutes is stated rather than implied
  • In-call controls — mute, speaker and a call timer, plus a DTMF pad. The role replaces the phone app, so anything missing here is missing from the phone. The DTMF pad is there because taking the default-dialer role means this is the only call screen the user has: without it "press 1 for accounts" is unreachable and every automated menu ends at the first prompt. The digits already sent stay on screen, since nothing on the line echoes them back
  • Version in Settings — the last row of Settings names the installed build, 1.4.0 (6), read from the package itself rather than from a constant that could disagree with it. Both numbers, because a version name does not say which upload you are on
  • Credits — the maintainer, the AI pair used to write the code, and the open-source libraries the app ships with, each with its SPDX licence and project home. People and licences are kept in separate sections: one is a courtesy, the other is an obligation
  • Caller identity on the call screen — a ringing or dialling call shows the contact's name, or failing that the label you gave that number in your own block/allowlist, with the number kept underneath
  • Block state in the call log — a row says whether that number is on your block list or allowlist right now, and tapping it offers Unblock / Remove from allowlist instead of the action you already took. Separate from the call's own outcome: a call blocked by a rule you have since deleted still reads "Blocked call" and carries no badge
  • Call back — tap any number in the call log to return the call, or the Call back button on a missed-call notification
  • Actionable notifications — a blocked, missed or repeat-caller notification carries the buttons that outcome allows (Call back, Block, Always allow), and tapping the notification itself opens the call log with that caller's actions already open
  • Copy number — copy phone numbers to clipboard from the call log
  • Stats — blocked-call counts by day/week/month, bucketed on local midnight and DST-aware
  • Backup/restore — export/import all rules as JSON, with labels preserved; an in-app "View example format" dialog shows the JSON shape
  • Adaptive layout — bottom bar on phone, nav rail on tablet/landscape, content capped at 600dp
  • Duplicate warnings — warns when adding a number already present in the other list
  • Precedence engine — manual block overrides contacts and allowlist
  • Contact matching — a contact is recognised whichever way each side is written: saved 611 99 88 77 and called from +34611998877, or saved +34611998877 and called from 900123456. The country code is derived from the number itself rather than guessed from a region, so two numbers that both state a country are still told apart
  • Permission checklist on first run — after the default-dialer explainer, one screen lists every permission the app asks for and the single thing each is used for, with its system dialog behind an explicit Allow. Nothing on it is mandatory, and the microphone is named but only ever requested when call recording is switched on
  • Permission warnings on Home — if the app loses the dialer role, notifications, full-screen intent, or the call permission, a card says so above the blocked-call counters, with a button that fixes that specific thing. The same warnings appear in Settings
  • Privacy & Terms — in-app privacy policy and MIT license terms
  • Ringing — the app plays the ringtone and vibration itself (as the default dialer contract requires), honouring the system ringer mode, and silences it the moment a block decision lands
  • Do Not Disturb is honoured — taking over ringing takes over zen filtering with it, since Telecom stops applying it once an app declares it rings for itself. A call is silenced when Do Not Disturb says so and still rings when it is one of the exceptions the user set up: calls from anyone, from contacts, from starred contacts, or a repeat caller inside fifteen minutes. Total silence beats all of them. The ringing notification is still posted either way — Do Not Disturb silences a call, it does not hide one, and that notification is where Answer and Decline live
  • Notification control — a single "Show notifications" switch mutes everything the app posts, including the incoming-call alert. Repeat calls from one number update that caller's notification with an attempt count instead of stacking a new one
  • i18n — English (default), Spanish (es), Portuguese (pt), Hindi (hi)

Project layout

shared/       Kotlin Multiplatform module — commonMain domain logic, Compose UI, SQLDelight
androidApp/   Android application shell (MainActivity, InCallService, InCallActivity)
iosApp/       iOS application shell — project.yml (xcodegen) + Swift entry point
docs/         Spec, milestones, adaptive plan, store compliance, QA scripts
design/       Iconography — SVG masters, Sharp renderer, brand assets

Prerequisites

  • JDK 17
  • Android SDK (ANDROID_HOME set), platform 36 + build-tools
  • Xcode 16+ and xcodegen (brew install xcodegen) — iOS only
  • Node.js (for icon regeneration)

Build & run — Android

./gradlew :androidApp:assembleDebug
# with device/emulator connected:
adb install -r androidApp/build/outputs/apk/debug/androidApp-debug.apk
adb shell am start -n org.carlospinan.bloqueador.app/.MainActivity

# or use the helper script:
./install_android.sh

Build & run — iOS

cd iosApp && xcodegen generate && cd ..
open iosApp/iosApp.xcodeproj
# or headless:
xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp \
  -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -configuration Debug build

Tests

./gradlew :shared:testDebugUnitTest          # 649 tests, commonTest + androidUnitTest (Robolectric)
./gradlew :androidApp:testDebugUnitTest      # 113 tests, Android-only classes (Robolectric)
./gradlew :shared:iosSimulatorArm64Test      # commonTest on Kotlin/Native (deferred)
./scripts/verify.sh                          # everything above + ktlint, lint, migrations, iOS compile

On a device

Some of this app's behaviour has no unit test that can reach it. A call only exists while Telecom is holding one, and the rule engine can be perfectly correct while nothing arrives at it — which is how six features once shipped, passed their tests and never executed. These scripts assert the parts a JVM test cannot see. All of them take --device <serial>.

./scripts/rule_matrix_test.sh    # 13 block/allow scenarios, each driven by a real call (emulator)
./scripts/ring_test.sh auto      # the phone actually rings, and stays silent when it should
./scripts/ring_test.sh dnd       # Do Not Disturb: stranger silenced, priority caller still rings
./scripts/ring_test.sh watch     # the same assertions on real hardware, while a person calls
./scripts/call_test.sh preflight # is this device set up for a live auto-responder recording test?
./scripts/device_check.sh        # install, no fatals, schema version and indexes on the phone

rule_matrix_test.sh needs an emulator (it places the calls itself with adb emu gsm call), a debug build (it seeds the app database through run-as) and the dialer role. It replaces the device's rules, settings and call log. Ringing on real OEM hardware is the one thing an emulator cannot settle — ring_test.sh watch is for that, and someone has to phone the handset.

Icon regeneration

If SVG sources change, regenerate all 22 PNG icons:

npm install --no-save sharp
node design/iconography/render_ui_icons.mjs
# Expected output: "Rendered and validated 21 interface icons."

Localization

String resources are in shared/src/commonMain/composeResources/:

Directory Language
values/ English (default)
values-es/ Spanish (LATAM)
values-pt/ Portuguese (Brazil)
values-hi/ Hindi

Add new locales by creating a values-<code>/strings.xml file following the same key structure.

Learning

A complete 36-module HTML course walks through every layer of the app — Gradle, KMM, Compose, Navigation, adaptive layouts, SQLDelight, Koin DI, permissions, Telecom/InCallService, rule engine, i18n, testing, CI, iOS debugging, call notifications/permission UX, extending the precedence engine safely, state management (MVVM + MVI done right, including when not to force the pattern), test doubles at scale (consolidating duplicated fakes across KMP test source sets), testing the persistence layer against a real SQLite engine, auditing for code that ships but never runs, platform policy and the claims your app makes, permission UX as a design problem — asking, explaining, and noticing when a permission is taken back — one behaviour written twice and fixed once, plus the two features whose obvious implementation would have broken the product, the difference between reading code, watching a failing device, and actually proving a screen scrolls, a default dialer that could not dial, a call log that had the data all along in the wrong shape, a notification that killed the app the moment a call was answered, a fix that was correct in isolation and inert where it was called, the call screen a default dialer owes its user, and (newest) what it costs to own a platform role — a missing dark mode, a call-waiting bug that stranded the caller, states that rendered no buttons at all, and defaults that could silence an ambulance.

Want to build one instead of reading about one? Read it online (source: docs/build-from-scratch.html) — a build-along tutorial: nine steps from an empty directory to an app that holds the dialer role, sees incoming calls and blocks them, each step ending in a command to run and a specific thing you must see. Steps 1–4 were executed from zero on an emulator while it was written, and it says outright which later steps were not.

Open course/corta_spam_course.html in any browser. Dark mode, progress tracking, code snippets from real project files, SVG diagrams, and 194 quiz questions included.

Recent Fixes

2026-09-02: four user reports in one pass — ringing through Do Not Disturb, a literal %d on screen, the caller hearing the room, and blocking that "let a stranger through". Three were real and are fixed; the fourth turned out to be a question about settings with one genuine hole underneath it.

  • The app rang through Do Not Disturb, and the reason was structural. The manifest declares IN_CALL_SERVICE_RINGING, which tells Telecom this app rings for itself — so Telecom stops ringing and stops applying zen filtering on the app's behalf. The app took the whole job and did half of it: CallRinger decided everything from AudioManager.ringerMode, and nothing in the codebase read getCurrentInterruptionFilter() at all. The decision now lives in RingerPolicy as a pure function split in two, so the ringing path stays cheap: the interruption filter and the policy answer on their own, and only "priority callers only" — the one case that needs to know who is calling — pays for an address-book read. That branch starts silent rather than ringing first, which is the one place in this service where ring-first-decide-second is wrong: the user has asked for silence, so the failure to avoid is noise.
  • The inverse bug was worse, and only a device found it. AudioManager.getRingerMode() returns RINGER_MODE_SILENT while Do Not Disturb is on even though Settings.Global.MODE_RINGER still reads 2. So the gate would correctly decide "this starred contact may ring" and plan() would then silence it — every exception the user configured was unreachable. Diagnostics on an API 36 emulator showed the whole chain agreeing (gate=ASK_CALLER, isStarred=true, allows=true, ring() reached) and no audio player appearing. Fixed by ringing on the user's own setting while zen is filtering, since the gate has already accounted for it and applying it twice is what silenced the exceptions.
  • The notification channel carried its own bypass and its own sound. incoming_calls asked to bypass Do Not Disturb and left sound and vibration at the channel defaults, so every incoming call also fired the default notification sound underneath the real ringtone. A channel's sound, vibration and bypass are frozen at creation — passing new values for an existing id is silently ignored — so the fix needs a new id (incoming_calls_v2) and deletes the old one. On the device the bypass request had in fact been inert without ACCESS_NOTIFICATION_POLICY; the doubled sound was real.
  • %d was printed to the user, literally, in all four locales. Compose Multiplatform does not format these strings, it substitutes them, with one regex — %(\d+)\$[ds], confirmed by grepping the shipped classes.dex of 1.6.1. A bare %d matches nothing, so it is not an argument at all. call_repeated_caller_hint was the only live caller of the mechanism written that way. Nothing existing could catch it: the key is present in every locale, the build succeeds, and the specifier-parity test accepts both spellings by design because it compares a translation against its source and both sides carried the same wrong one.
  • The auto-responder left the caller listening to the room for up to two minutes. The greeting reaches the caller only through the handset's own microphone — Android gives third-party apps no uplink injection, CAPTURE_AUDIO_OUTPUT being signature|privileged — so an open mic during the greeting is inherent and cannot be fixed. The minute after it can: setMuted() gates only what Telecom sends to the far end, while MediaRecorder's MIC capture is untouched and, with the loudspeaker still on, keeps picking the caller up through it. Muting when the greeting completes therefore costs nothing and closes the larger half. Two more defects in the same path: setAudioRoute/setMuted are set on the service, so greeting a blocked caller mid-conversation moved the user's real call onto the loudspeaker, and neither was ever handed back.
  • "It was not in my agenda and it still got through" is mostly a setting, with one real hole. The engine is correct — unknown numbers are blocked only when Default action is Block, which is not the shipped default — but the matcher is now stressed rather than spot-checked: ten contacts saved the inconsistent way real address books hold them, against 100 strangers built by mutating the last two digits of each contact's national number. All 100 are correctly blocked. The hole that remains is now an executable test: a contact saved without a country code is matched by the same national number under any known foreign dialling code, so 611998877 is met by +51611998877. It matters more than its size suggests — contacts merge into the allowlist at step 2, which beats patterns, country rules, spam and the default action, so one false match defeats every rule below it.
  • 740 → 762 tests (:shared 642 → 649, :androidApp 98 → 113). Both new suites were watched failing first: the %d test against the reintroduced bug, and the matcher against two mutations — a prefix match trips the 100-stranger test, digit equality trips the contacts test. Verified on an API 36 emulator with a new ring_test.sh dnd mode, plus the existing ring and blocked-call suites. Two false failures on the way, both worth knowing: the first run expected an ordinary contact to ring under a policy whose priorityCallSenders is PRIORITY_SENDERS_STARRED (the AOSP default) — the app was right and the test was wrong, and it now reads the device's policy instead of assuming — and a later run's "still silent" was the AVD modem wedging, with no call reaching the app at all.

2026-08-28: the Play Console's Recommended actions panel for release 9 (1.6.1) — four advisories, triaged, no code changed. Written up in docs/PLAY_ADVISORIES.md so release 10 does not re-derive them.

  • The deprecated-API advisory was pointing at the call the other advisory asks for. Play named setStatusBarColor, setNavigationBarColor and LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES starting in c.w.b, c.y.b and a4.b.t, which read like three places in this app. Resolved against the uploaded bundle's mapping.txt, all three are androidx.activity.EdgeToEdgeApi26.setUp, EdgeToEdgeApi29.setUp and an outline called from EdgeToEdgeApi28.adjustLayoutInDisplayCutoutMode — the inside of enableEdgeToEdge(), which is what the first advisory recommends calling. Nothing in androidApp/ or shared/ touches those APIs, in Kotlin or in either themes.xml. Unfixable here without trading a cosmetic advisory for a real one.
  • An obfuscated trace is worth a minute with mapping.txt before it is worth an edit. Two traps make the lookup lie confidently: R8 names are not stable across builds, so a mapping from a later local rebuild resolves c.w to some unrelated class and answers about a different binary — check its timestamp against the upload date — and a synthetic outline carries the name of whichever class it was first outlined from, which is why a4.b says androidx.emoji2.text.ConcurrencyHelpers$… while the call site is in androidx.activity. The recipe is in the doc.
  • Edge-to-edge was already done, and picture-in-picture never will be. Both activities the manifest declares call enableEdgeToEdge (MainActivity.kt:250, InCallActivity.kt:49) and the insets are consumed rather than assumed — safeDrawingPadding() in three screens, WindowInsets.safeDrawing.only(…) in AdaptiveScaffold, asPaddingValues() in the keypad. That advisory fires on targetSdk alone. PiP is offered to every app; this one has no video surface to put in a corner.
  • AGP 9 is the one real item, and it is deferred on purpose. R8 is already on. The advisory's payoff is memory and startup, and AGP 9 is a migration — built-in Kotlin removed, KMP plugin handling changed — against Kotlin 2.2.20, Compose Multiplatform 1.11.1 and SQLDelight 2.3.2. It gets its own commit, its own ./scripts/verify.sh, and a release build on hardware, because R8 changes behaviour around reflection, serialization and Telecom callbacks. Not the day before an upload.

2026-08-27 (later): a second report — "if I type XXXX and want to insert YY at the beginning, it doesn't let me".

  • The dial pad could only type at the end of the number. The keypad's field held a plain String, and a String carries no caret: every control that typed into it — a pad key, the + button, delete — could only append. Put the caret in front of a number already typed and a tapped key still landed at the back, so the commonest correction a dialer is asked for, adding a country or area code to a number already on screen, was impossible without deleting it and starting again. Delete had the same shape: dropLast(1) removed the last character wherever the caret was. The field is now a TextFieldValue, so the caret is state the screen can read, and every control acts on it — a key inserts where the caret is and moves it along, delete removes what is before it or the whole selection.
  • The caret arithmetic is a pure function, not a screen detail. NumberEntry.kt holds typeInto and deleteBackwards over plain text and offsets, which is what makes nine assertions possible without a composition. A selection reported by the field is coerced rather than trusted — a stale one that outran its text would otherwise crash the screen inside a keystroke.
  • A/B'd on a Pixel 8 Pro API 36 emulator, both binaries. Against the pre-fix build the report reproduces exactly: 5551 typed on the pad, caret moved to the front, 9 tapped, field reads 55519. Against the fixed build the same taps read 9|5551, then 91|5551, delete takes the 1 before the caret rather than the trailing one, and + at the front gives +95551. Long-press-to-clear still empties the field.
  • Left unchanged: rotating the phone still empties the keypad field, and that is not this bug — the pre-fix build loses it identically. AdaptiveScaffold calls content() from three branches of one when (windowSizeClass), so a size-class change moves every screen to a different composition slot and discards its remember/rememberSaveable state. It needs movableContentOf and a pass over every screen, which is its own change with its own verification.
  • 731 → 740 tests (:shared 633 → 642, :androidApp 98 unchanged). Six of the nine were watched failing against the old append-and-drop-last behaviour before being trusted. ./scripts/verify.sh green, including the iOS compile — the new test names carry no comma, which Kotlin/Native rejects in a backticked name.

2026-08-27: a bug report — "default action is Block, the phone says Blocked call, and it answers itself; I only notice when I hear the call in progress" (Redmi Note 13 Pro, Android 16, 1.6.0).

  • A blocked call is now guaranteed to end. Call.reject() was the whole of it, and Telecom applies that only to a call that is still ringing. Anything that answered first — the ringing screen's own Answer button, reachable by a cheek or a pocket because ProximityPolicy deliberately does not blank a ringing screen, a headset button, the system UI — turned the rejection into a silent no-op, and nothing looked again. The "Blocked call" notification was posted regardless, so the app claimed to have blocked a call the user was connected to, on the loudspeaker. BlockedCallPolicy now chooses by state — reject while ringing, hang up otherwise, leave a call already disconnecting alone — and a 3-second watchdog re-checks and disconnects if the call is somehow still up. An unknown state hangs up rather than hoping.
  • A greeting that never plays no longer holds the call open. With the auto-responder on, a blocked call is answered on purpose so the caller hears the greeting, and the call ends when playback reports completion. Text-to-speech has three ways to report nothing at all: an engine that fails to initialise (the old code then left it non-null, so prepare() never rebuilt it and the queued greeting was dropped), a missing voice for the device language — Spanish, Portuguese and Hindi are all downloads on phones that ship with English — and speak() returning ERROR, which never calls the listener. Each of those left the call answered until the caller gave up: exactly what the report describes. All three now report completion, and two deadlines back them up — 10 s if the greeting never makes a sound, 60 s if it started and never finished.
  • Switching the auto-responder on now asks first. It is the one setting that makes this app pick up a call, and the switch's description ("answered and greeted") reads as a feature rather than as your phone will answer, on speaker, by itself. A dialog names the three things the user will actually see and the limit that matters — calls that are not blocked are never answered — and nothing is written until they confirm. Turning it off asks nothing.
  • Proved on an emulator, not just in tests. scripts/blocked_call_test.sh drives three scenarios on an AVD and asserts from Telecom's own per-call history rather than by polling. The bug reproduces and the fix holds: SET_RINGING → SET_ANSWERED 2.9s → SET_ACTIVE 3.4s → REQUEST_DISCONNECT 4.2s → SET_DISCONNECTED cause=LOCAL, with the app's own log row reading BLOCKED {"type":"default_block"}. Two traps are handled in the script because they produce confident wrong answers: polling dumpsys telecom costs most of a second per sample and reported a call rejected at 0.9 s as "still ringing" for fifteen, and the AVD's virtual modem stops delivering calls entirely after one that was answered — gsm call still returns OK — so the suite reboots the device when that happens.
  • Play Console advisories for release 8, acted on where acting helps. Optimised resource shrinking is now on (android.r8.optimizedResourceShrinking=true) — isShrinkResources was already set, and this makes R8 shrink resources with the same reachability information it uses for code rather than the older shrinker's approximation; the release APK still builds at ~2.0 MB. androidx.activity went 1.9.3 → 1.13.0. The edge-to-edge advisory needed no code: both activities have called enableEdgeToEdge() for a while and every screen pads with safeDrawing (verified again on an API 36 emulator, where the system enforces it). The deprecated window APIs advisory does not clear, and cannot: setStatusBarColor, setNavigationBarColor and LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES are called by androidx.activity's own EdgeToEdgeApi35.setUp — disassembled at 1.13.0 to check — which is the API Google's own advisory tells you to call. Picture-in-picture is declined: this app has no video and no content to keep watching, and its one full-screen surface is a call screen that must not shrink into a corner.
  • 731 tests (./scripts/verify.sh green), including six for the termination policy and four for the confirmation dialog, each watched failing against the unfixed code first.

2026-08-20 (later): driven on an emulator, which found that the first fix had moved the blank space rather than removed it.

  • Pinning the pad to the bottom left a third of the screen empty between the field and the pad — bigger than the 132 dp band it replaced, and in the same place: under the search box. The field now travels with the pad, where a dialer shows the number it is dialling, and the empty space ends up above it under the title, which is the ordinary shape of a phone dialer and is where the results open. That flipped the popup's side, so the screen decides it rather than the position provider: it measures both gaps — title to field, field to pad — opens on the larger, and caps the popup at it. A provider cannot make that call, because below the field is the dial pad and a popup only knows that it fits.
  • Two defects only the device showed. Flush against the anchor, the popup covered the OutlinedTextField's floating label, which is drawn on the field's own top border — the results hid what the field was for; there is now an 8 dp gap, subtracted from the height cap. And at 280 dp the popup clipped its last row and pushed "Create new contact" below a scroll in exactly the state that needs it; 340 dp shows five matches and the footer.
  • Settings needed a way in. It left the navigation bar when Agenda took the fifth slot, and Home's link to it is the last of four text buttons at the bottom of a scrolling screen — the suite says as much: "last quick link is off screen but reachable by scrolling". Home's header now carries a gear beside the blocking switch; the text link stays, because the other three quick links live in that group.
  • Verified on a Pixel 8 Pro API 36 emulator, debug build. The 1 key reads [246,1618][283,1702] before and after a digit is typed, so the pad does not move. Picking a row fills the field and closes the results. With the field focused the soft keyboard stays up while typing (mInputShown=true before and after) — the point of the non-focusable popup. On the Agenda: the favourites strip picked up a contact starred through ContactsContract; blocking from a row wrote the rule and the Blocked chip then listed that contact with its badge; unblocking from the same row offered Unblock rather than Block and left BlockedNumber empty. Pull-to-refresh: a contact inserted into the provider while the app was open was absent before the pull and present after it, which is the five-minute cache being dropped rather than waited out. The call log's indicator appears on the same gesture. 694 tests, ./scripts/verify.sh green.
  • A switch for the recent callers, and the pad now sizes itself by measuring. The strip is call history on a screen anyone holding the unlocked phone can open, so showRecentCallersOnKeypad is in Settings, on by default — starred contacts are not covered by it, since those are a choice the user made and every dialer shows them. Off, the recents leave the ViewModel state rather than being hidden by the screen, and the empty-band hint stops promising them. Separately, KEYPAD_FIXED_HEIGHT was an estimate and measurement said it was ~90 dp out: the title, field, action row and spacers report their own bounds now, and on the razr the keys go from 87 dp to the 96 dp ceiling — a whole row of key height the estimate was giving away. The constant survives as the first-frame fallback. And the strip has placed a call: on the emulator, picking a recent caller fills the number, Call reaches Telecom, InCallActivity binds, and the row lands in the log as OUTGOING/ALLOWED.
  • The band above the number field now holds something. Growing the pad only shrinks that band so far — a 1080x2640 phone is about 1000 dp tall and twelve keys cannot fill it at any size that still reads as a dial key — so the space is used instead of stretched: the phone's starred contacts, or the four most recent callers when nothing is starred, or one line saying what will appear there when there is neither. The strip is the same component the Agenda tab draws, moved into the contacts package so both read the same platform flag. A tap fills the number rather than dialling, like a search result. The title moved down beside the field and the column packs from the bottom, so the screen is one block with a margin above it rather than a heading with a hand's width of nothing under it. Two defects came out of the tests: recents deduplicated by digits kept one caller twice (+51987654321 and 987654321 are one person and two strings — it dedupes by comparisonKeys now), and with two coroutines writing the keypad's state a read-modify-write lost the permission flag, so every write uses update().
  • Bottom-anchoring the pad left a void doing it, and the fix was to grow the keys. On the razr the content is roughly 500 dp of an 820 dp screen, so about a third of the dialer was empty — the same complaint as the reserved band, one screen further on. The pad is now sized to the window: the screen measures what is left after the title, field and action row and hands DialPad a key height and row spacing computed from it, clamped to 56–80 dp and 8–20 dp, so the keys grow until the pad fills a tall phone and the clamps hold the minimum touch target on a short one. The in-call DTMF pad keeps the compact defaults, since it shares its screen with the call's own controls. Settled on the device: at 92 dp keys the pad looked right and the results did not — with ~115 dp left above the field the popup hit its 140 dp floor, was pinned to the top of the window, covered the title and clipped a row through the middle of its text. Keys at 80 dp leave the room the results need, and the floor came down to 88 dp so a short window gives a short popup rather than a cut-off one.
  • Two of the new ViewModel tests hung for a minute on the iOS simulator and passed on the JVM. viewModelScope dispatches on Dispatchers.Main, and on Kotlin/Native the real one needs the platform run loop that no unit test spins, so the coroutine that loads the address book never ran and the collector waited forever — UncompletedCoroutinesError. The trap is already documented in CallLogViewModelTest; the new class did not follow it. Every test in it now installs a StandardTestDispatcher, and that exposed a second, real defect: the cache test waited with state.first { !it.refreshing }, a predicate already true of the flow's initial value, so it returned before the refresh had started and asserted against a call count of zero. It passed only because the eager dispatcher happened to run the coroutine first. Both cache tests now wait on the scheduler. ./scripts/verify.sh compiles commonTest for Native but does not run it — :shared:iosSimulatorArm64Test is what catches this, and it is not in that script.
  • Still unverified on hardware. The razr has not seen any of this.

2026-08-20: the blank band under the keypad's search box, and the address book the app never had. Both from a user reading the dialer next to the phone app it replaced.

  • The keypad reserved 132 dp of empty space under the search field, and it read as a rendering fault. It was a real fix wearing the wrong shape: the contact matches used to size themselves to their contents, which moved the dial pad up to 882 px per keystroke (see 2026-08-19), and a region of constant height stopped the pad moving by making the gap permanent. On an empty query nothing is ever drawn in it, so what the user sees is a dialer with a hole in it. The matches now live in a Popup — a window of its own, outside the screen's layout, so it can be exactly as tall as it has content for and still move nothing. The pad and the Call button are pinned to the bottom of the window with Arrangement.SpaceBetween, which is what keeps the keys still now that no gap is reserved, and the popup is capped at the free space between the field and the pad rather than allowed to cover the keys: a popup is a separate window, so results drawn across the pad would not merely hide the next key, they would swallow the tap on it. It is deliberately not focusable — a focusable popup takes the focus off the field it is a search for, closing the soft keyboard after one character. "Create new contact" moved into the results as their last row, which is where the user is already looking when the row above says nothing matched; the argument that used to keep it below the pad — anything above the pad pushes Call down — stopped being true when the pad was pinned.
  • There was no screen that listed the people in the phone. Taking ROLE_DIALER replaces the phone app, and a phone app has an address book; this one could only reach a contact through the keypad's search field, which needs the name typed before it shows anything. The new Agenda tab lists the whole book, with the platform's starred contacts in a favourites strip across the top — the platform's flag, ContactsContract's STARRED, not a favourites list this app keeps, because a dialer showing a different set of favourites from every other app on the phone is showing its own opinion instead of the user's. A star on any duplicate row wins: one card synced from two accounts arrives twice and often only the Google copy carries the star.
  • The Blocked and Allowed chips answer a question the block list cannot. The block list is a list of numbers, so "which of my contacts have I blocked" had no screen — the two rule filters cross the address book with the rule set, matched with sameNumber rather than string equality, so a rule saved from a call in national form still matches a contact card saved internationally. Rows carry the same block/allow badge the call log does, and tapping one opens the same Call / Block / Allow / Copy actions, with one button per meaning: a contact already blocked is offered Unblock, never a Block that does nothing visible.
  • Settings left the navigation bar to make room, and no tab lights up while it is open. Material's bar tops out at five items and the address book of an app that replaced the phone earns the slot; Settings is reached from the card Home already had for it. routeSection returns NO_SECTION for the settings routes rather than falling back to 0, which would have highlighted Home — or, after the reshuffle, Lists — while the user reads the privacy policy.
  • Pull-to-refresh on the Agenda and the call log, and it goes past the cache. AndroidContactsGateway caches the address book for five minutes because the allowlist reads it while the phone is ringing. A refresh gesture served from that cache answers a deliberate "look again" with the same stale list, silently and convincingly — so ContactsGateway.refresh() drops it first. On the call log the calls need no refreshing at all (they arrive as a database flow); the names beside them do, which is the whole point of pulling that screen. Both lists stay a LazyColumn even when all they hold is one line of text, because a message rendered outside the scrollable is a state the user cannot pull to leave — and "no contacts", right after granting the permission, is the state the gesture exists for.
  • 652 → 692 tests (:shared 560 → 600, :androidApp 92 unchanged). The three new keypad assertions were watched failing against the old layout — the bottom-anchored pad, the results closing when a contact is picked, and their coming back on the next keystroke. ./scripts/verify.sh green, including the iOS compile and TranslationCompletenessTest over the eleven new keys in all four locales.
  • Not verified on hardware or an emulator. Everything above is Robolectric and unit tests; the razr has not seen this build. The pull gesture in particular is the kind of thing a component test can only prove is wired.

2026-08-19: the phone app that could not dial 112, a dial pad that moved while it was typed on, and a navigation bar with no dark mode. All three found by installing the release build and using it.

  • Country names read in English in every language, and no language change could fix it. The Spanish call log said "País: Morocco / Western Sahara". Countries.kt is 223 hardcoded English strings, and the picker wrote the chosen name into the CountryRule row — so the name was captured once, in English, and was already in the database by the time anyone switched language. The same string is copied into rule_detail on a blocked call. Two changes: Countries.kt now carries the ISO 3166-1 alpha-2 regions each dialling code covers — a region code is a key the platform turns into a name in any language, where an English string is just an English string — and the name is resolved at render time from the code at all three places that show one, with the stored name demoted to a fallback for a region the platform cannot name. Existing rows therefore localize with no migration. The four codes covering several territories (1, 7, 212, 590) keep all of them, so the joined name has each part translated rather than being one fixed string with a slash in it. The code→ISO mapping was derived from the JDK's own CLDR data by matching the existing names rather than hand-typed; the 23 that did not match — mostly CLDR's "&" against this list's "and" — were resolved individually. Not done with resource strings: 223 names × 4 locales is a large surface that goes stale, and it would still leave every locale the app does not ship reading English. Verified on the emulator in Spanish: País: Marruecos / Sáhara Occidental (+212). The picker still matches the English name too, because someone reading in Spanish may well type "Germany".
  • All ten store screenshots retaken, and there is now a script to reproduce the state. Every shipped screenshot was from 2026-08-14 and showed a purple app: they predated the theme, when every screen still opened a bare MaterialTheme, so the listing advertised a brand colour the app does not have. They also predated the keypad's fixed results area, the call log's block-state badges, and Settings' version and emergency rows. scripts/seed_screenshots.sh seeds rules, settings and a call log with timestamps relative to now, so Home reads the same counters on any day it runs, and switches language per-app with cmd locale set-app-locales rather than a device language change and a reboot.
  • The emergency-callback exemption had no time limit on the platform's signal, so a stuck flag switched call blocking off permanently. Two signals feed the exemption: this app's own record of the user dialling an emergency number, which expired after thirty minutes, and Android's Call.Details.PROPERTY_EMERGENCY_CALLBACK_MODE, which was trusted outright for as long as the platform kept reporting it. A platform that never clears it therefore short-circuited every rule — manual blocks included — before any of them were consulted, and the only trace was a rule_detail in the call log. Found the hard way: a Pixel 8 Pro API 36 emulator that dialled 112 once reported callback mode on every incoming call for at least a day, while dumpsys telephony.registry reported mECBMReason=0. rule_matrix_test.sh said 13 of 14 scenarios failed, all ALLOWED/-, and the rule engine was fine — 14/14 with one setting changed. Both paths are now bounded by the same thirty minutes, measured from the first moment the app had reason to believe an emergency was in progress; the marker is persisted, cleared when the platform stops claiming callback mode, so a real second emergency restarts the window and a stuck flag cannot hold it open. Verified on the stuck emulator itself: first call ALLOWED with {"type":"emergency_callback"}, then the marker wound back 31 minutes and the same call from the same still-claiming platform is BLOCKED/MANUAL.
  • Two device scripts were reporting the wrong thing about themselves. device_check.sh waited a fixed three seconds for the schema — enough on a warm install, not on a cold one, where a fresh install reported user_version 0 and the identical re-run reported 4. It now polls. rule_matrix_test.sh printed "expected BLOCKED/MANUAL, got ALLOWED/-" while the row it had already pulled said {"type":"emergency_callback"}; it selected only action and rule_type, and rule_type is NULL for an exemption. It now carries the detail through, names that case explicitly, and pins the exemption off for the run so the suite does not depend on the device's telephony state.
  • The Credits screen names its testers. Sig Mandel, Faride Altamirano, Jose Arellano and Augusto Piñán, for early feedback, bug reporting and testing — in the order the maintainer named them, because a credit is an acknowledgement and re-sorting someone's thanks is not an improvement. Their role text stays English, like the two entries above it: Contributors.kt sits deliberately outside the localized resources, because a resource key per person means a build failure every time someone is added without all four locales. With six entries now sharing three role labels that trade is worth revisiting — a key per role has none of that problem — but it changes a documented decision and the existing entries, so it is listed rather than done quietly.
  • Every locale is now asserted to take the same format arguments, not just the same keys. Key parity said a translator answered; nothing said they answered the same question. A specifier a translation invents throws MissingFormatArgumentException at format time and one it drops loses the value silently, and neither was visible to anything here: the build succeeds, the key is present, and Lint's MissingTranslation is disabled because it only sees one of the two resource systems. Plain strings are compared as sorted multisets, so reordering %1$s (%2$s) to %2$s de %1$s passes and a dropped argument does not. Plurals are compared as distinct sets across all quantity forms, and that looseness is the finding: which forms carry the count is a property of the language — English says "Called once" for one and uses %1$d only in other, while Portuguese ("Ligou %1$d vez") and Hindi use it in every form. The first version compared per-form and failed all three of those correct translations. No live defect was found; 303 keys × 3 locales × 3 resource trees passed unchanged.
  • Long-pressing the keypad's delete key removed one digit, however long it was held. Every phone's dialer clears the field on a long press; correcting a mistyped international number here meant tapping delete once per digit — thirteen times for a +34 number. The gesture was not missing from the handler: the control was a Material TextButton, which exposes no onLongClick, so there was nowhere to write it. It is now a Box with combinedClickable, which has to re-supply what the button gave for free — the 56 dp touch target, the disabled colour of the glyph, and an onLongClickLabel, without which a long press is invisible to a screen reader. No press-and-hold repeat-delete: clear-all is what AOSP's dialer does with a long press, and the two gestures would compete. Verified on the release build on the emulator (short tap 1234567 → 123456, long press → empty); the razr dropped off USB before this build reached it, so on hardware only the pre-change behaviour was measured.
  • Tapping Call on 112 did not ring anyone. This app holds ROLE_DIALER, so it is the phone app — and Telecom cancelled the call and launched the stock dialer with the number pre-filled, asking the user to press Call a second time. The permission was never the problem: CALL_PHONE is GRANTED_BY_ROLE, which dumpsys package states outright. startActivity(ACTION_CALL) is routed through Telecom's own UserCallActivity trampoline, and Telecom decides whether an emergency number may be dialled by asking which package started the intent — and getCallingPackage() is null for a plain startActivity, so holding the dialer role is invisible at exactly the moment it is checked. Read off the device: W Telecom: NewOutgoingCallIntentBroadcaster: Cannot call potential emergency number 112 with CALL Intent ... unless caller is system or default dialer. Both call sites now use TelecomManager.placeCall, a direct binder call where the caller's package is authenticated rather than guessed — the keypad, and the call-back button on a missed-call notification, which is the likelier emergency of the two. ACTION_CALL survives only as a fallback for a device with no Telecom service.
  • The dial pad moved 882 px — about a third of the screen — from one keystroke. The contact-match list sat between the number field and the pad and sized itself to its contents. With six contacts seeded, typing a single 1 matched five of them and moved the 1 key from y=702 to y=1584, so the second digit of a number landed wherever the first keystroke had just moved the pad to — and what occupies the old position is the match list, whose rows replace the entire typed number with that contact's. The Call button sits in the same reflowing column, so a mis-tap could place a call to the wrong person. The results now live in a region of constant height, reserved whether or not there is anything to show, and scroll inside it. Reserving it only once something is typed would not do: the move would then happen on the first keystroke, which is the one that misplaces the second digit. This was half-known — ContactRow's own note says the list "reshuffles under the finger on every keystroke", and the file's history already contains one fix for the results pushing Call off the bottom of the screen. Both treated the consequence; the pad kept moving.
  • In dark mode the content went dark and the navigation bar stayed white. AdaptiveScaffold draws the navigation bar and the rail and opened no theme at all, so it inherited Material 3's baseline light palette from the composition root. In light mode it was wrong too, just less visibly: the bar was Material's default purple rather than the app's own palette. CortaSpamThemeTest enforces dark mode by hunting for MaterialTheme { } — a rule that can catch chrome using the wrong theme and never chrome using none, so this file was never an offender and was never themed either. The replacement is a runtime assertion: the scaffold composes content() inside whatever theme it opened, so probing the colour scheme from the content slot reports what the bar beside it is drawn with.
  • The theme check reported the sentence describing the bug as the bug. Its source scan skipped KDoc but not // comments, and the comment added to AdaptiveScaffold explaining the gap names MaterialTheme { }. It now skips both.
  • Seven existing keypad tests broke, and the cause was the test window rather than the layout. Setting the reserved region to 0.dp and re-running passed five of the seven, which is what tells a viewport-shaped failure from a code-shaped one. They had been passing only because everything happened to fit Robolectric's small default window; they now run at w411dp-h891dp. Shrinking the region until the suite went green would have fitted the product to a window nobody owns.
  • 632 → 652 tests (:shared 541 → 560, :androidApp 91 → 92). All four new or changed assertions were watched failing against the unfixed code — the theme one re-proved after the viewport change, because a harness edit can quietly make an assertion vacuous. ./scripts/verify.sh green, including the iOS compile.
  • Verified on the Pixel_8_Pro_API_36 emulator, release build, dialer role held. 112 connects on one tap and the refusal line is gone from logcat; the connected call is drawn by the platform's emergency screen, which carries the caller's location and is Android's behaviour rather than a symptom of the fix overreaching. An ordinary number (5550000) still lands on this app's own InCallActivity with the timer running and Mute/Speaker/Keypad present, which is the check that it did not overreach. The pad's 1 key reads [246,1057][283,1141] both before and after a digit is typed, and the same blind tap sequence that previously produced +34900123999 now types 112. Dark mode was set with cmd uimode night yes and the navigation bar is dark, with the app's green on the selected item. Unverified on hardware: none of this has been on a real handset, and no real emergency number has been dialled on a live network.

2026-08-18 (fourth): a cheek on the hang-up button, and the wake lock that came back without the screen.

  • The in-call screen never turned off, so a call was conducted with a face resting on it. Every phone app blanks the display while the handset is against an ear, and this one did not — it had no WAKE_LOCK at all. That was survivable while the screen carried one button; the batch that added Mute and Speaker beside Hang up made it three things a cheek can reach. PROXIMITY_SCREEN_OFF_WAKE_LOCK is held while a call is connected or dialling and the audio is not on the loudspeaker. It is a screen-off lock — it can only blank the display, never keep it awake — and it costs no device support: WAKE_LOCK implies no uses-feature, confirmed with aapt2 dump badging on the built APK rather than by reading the manifest, and isWakeLockLevelSupported means a phone without the sensor does nothing at all.
  • The decision is a pure function, for the reason every other decision in this package is. ProximityPolicy joins RingerPolicy, NotificationPolicy and CallDirectionPolicy: the activity cannot be constructed in a unit test, and blanking at the wrong moment is not cosmetic. Both directions are asserted, and RINGING explicitly — Answer and Decline are the screen for an incoming call, and a phone that rang in a pocket must not reach the ear already unanswerable. OTHER resolves to leaving the screen on, the same way CallDirectionPolicy resolves an unknown state to the smaller mistake.
  • Leaving the call screen and coming back left the lock off, and only a device showed it. The acquire/release ran from a LaunchedEffect keyed on the policy's answer. Pressing Home released the lock through onPause — correctly — and returning to the still-connected call re-ran nothing, because the key had not changed while the screen was away. The condition has two halves that move independently: what the call is doing, and whether this screen is what the user is looking at. The answer is now remembered and re-applied in onResume.
  • The release passes RELEASE_FLAG_WAIT_FOR_NO_PROXIMITY, so hanging up with the phone still at the ear does not flash the screen on against the user's face; it comes back when the handset does.
  • 626 → 632 tests (:androidApp 85 → 91). ./scripts/verify.sh green, including the iOS compile.
  • Verified on the Pixel_8_Pro_API_36 emulator, by A/B against a build with the speaker guard removed — the AVD has a proximity sensor but no earpiece, so Telecom refuses to leave the loudspeaker and the real build can never take the lock there. With the guard out: dumpsys power shows PROXIMITY_SCREEN_OFF_WAKE_LOCK 'CortaSpam:InCallProximity' held by the app's uid on a connected call, released on Home, and — only after the onResume fix — re-acquired on returning. The same A/B run with onResume deleted again left it released on return, which is the bug reproduced rather than reasoned about. With the guard in and the emulator on its loudspeaker, the lock is correctly never taken. Unverified on hardware: nothing here has been near a real proximity sensor, so that the screen actually blanks against a face is still an untested claim.

2026-08-18 (third): hanging up left the screen behind, an outgoing call had no name on it, and a number typed on the keypad could be dialled but never saved.

  • A call that had ended left its screen up, with nothing to press and no way off it. InCallState only cleared when Telecom called onCallRemoved, and Telecom holds a call in STATE_DISCONNECTED for as long as it likes before it does — seconds, on a real network. For that whole window the screen rendered DISCONNECTING, which by design shows no buttons at all, while Back deliberately backgrounds the task rather than leaving. The call was over, there was nothing to press, and there was no way out. STATE_DISCONNECTED now detaches the call the moment it arrives, so the screen goes when the call goes; DISCONNECTING gained a Close button, suppressed while a second call is still live because leaving then would take away its only UI; and Back finishes rather than backgrounds once the call is ending.
  • Two more routes to the same stranded screen, closed with it. InCallState.detach was the last line of onCallRemoved, below a recorder stop, two notification cancels and a ringer stop — anything that threw up there left the screen behind for a call that no longer existed. It is now the first thing that happens. And PassthroughInCallService.onDestroy never cleared InCallState at all: the Call objects only work through the service's binding to Telecom, so a service torn down mid-call left a screen whose Hang up button reached a dead adapter and did nothing — and left that dead call in the stack for the next call to promote back onto the screen when it ended.
  • The call screen had a hang-up button and nothing else. No mute, no speaker, no duration. This app holds ROLE_DIALER, so that is not a minimal UI, it is the whole phone: a call could not be put on speaker to write something down, could not be muted, and a connected call was indistinguishable from one still silently trying. Mute, Speaker and the existing Keypad toggle now sit in one row above Hang up, and the caller's name gained an mm:ss timer under it. Both toggles render what Telecom reports rather than what was last tapped — the auto-responder forces the loudspeaker on its own and a headset moves it back, and a button drawn from a local guess shows the opposite of the truth in both cases.
  • An outgoing call showed a number where the phone app it replaced showed a name. resolveCallerName asked ContactsContract.PhoneLookup, which matches through the provider's region-derived data4 column: a contact saved nationally does not match a call in E.164 unless the phone's region agrees, and with no SIM data4 is null and every contact reads as a stranger. This app already knew that — it is written on isKnownContact, and it is why the call log gets names right where the call screen did not. The lookup now falls back to ContactsGateway and comparisonKeys, the same probe the call log and the block lists use.
  • A number typed on the keypad could be dialled and never kept. Taking ROLE_DIALER took away the app the user used to save a number from, and this screen shipped with no replacement. "Create new contact" now hands the typed number to the platform's own editor through ContactsContract.Intents.Insert — no WRITE_CONTACTS, which a call blocker asking for it would have to justify to Play and to the user, and the editor they already know does the saving. It sits below the Call button deliberately: the first attempt put it among the search results, which pushed Call towards the bottom of the screen — the same regression this file's history already contains once — and it was caught by five existing tests that could no longer reach the button.
  • 612 → 626 tests (:shared 528 → 541, :androidApp 84 → 85). ./scripts/verify.sh green, including the iOS compile.
  • Verified on the Pixel_8_Pro_API_36 emulator. An outgoing call to a saved contact shows the name over the number with the timer running (00:02 → 00:22); Mute reaches Telecom, not just the UI — the system status bar shows the muted-microphone icon while the chip is selected; Speaker stays selected when asked to move to an earpiece the emulator does not have, which is the state reporting working rather than failing. Hang up ends the call and InCallActivity reaches onDestroy with dumpsys telecom showing no calls left. "Create new contact" opens Google Contacts' editor with 5559991111 already in the Phone field. Still unverified on hardware: the disconnecting-state Close button needs a call that lingers in STATE_DISCONNECTED, which the emulator never produces — it answers and tears down instantly. That path is covered by Robolectric only.

2026-08-18 (second): the app had no dark mode, call waiting stranded the caller, four call states had no buttons, and the defaults could silence an ambulance.

  • There was no dark mode at all. Not a partial one, not a wrong one — none. Every screen opened a bare MaterialTheme { }, which is Material 3's baseline palette: light, and purple, whatever the system is set to. Both activities also named Theme.Material.Light.NoActionBar directly, so the window Android paints before the first Compose frame was white too. The colours were not missing — design/mockups.html has carried a full light and dark pair since the UI was designed, and they are transcribed into CortaSpamTheme with the source token named beside each value. Green stays the accent in both schemes because it is doing semantic work as well as brand work: primary is the "allowed" colour in the call log, and the two hard-coded Color(0xFF4CAF50) literals there now use it.
  • Each screen themes itself rather than relying on one wrapper at the root of the nav host. That is not redundancy. InCallActivity draws CallScreen outside the nav host entirely, and every Robolectric screen test renders one screen with no host at all — a root-only theme would have left exactly the screen that most needs a dark mode still on the baseline palette. The test that pins this reads the source, and the comment on it says why: captureToImage() times out under Robolectric because PixelCopy never redraws, and the first attempt — reading a CortaSpamTheme block placed next to CallScreen — passed against a deliberately reverted CallScreen, because it was asking the theme what the theme says.
  • Call waiting stranded the surviving call. InCallState held one Call in one field. A second call overwrote the first; when that second call ended, detach saw the call it held was the one going away and cleared the state — so InCallActivity finished while the first call was still connected. The ongoing-call notification survived, but its Hang up action routed back to a null field and did nothing, leaving the system's own UI as the only way to end a call from the app that had replaced the phone app. Every call is now tracked, one is on screen, and losing that one promotes rather than clears; a ringing call wins the promotion. The screen also says "You are already on another call", because showing one of two calls as though it were the only one is how someone hangs up on the wrong person.
  • Three more defects came out of that same single field. Call.Callback was registered on every call and wrote the phase of whichever one changed, so a background call rewrote the phase of the call the user was looking at. setDisplayName and setRepeatedCallAttempts applied to whatever was on screen rather than to the call they belonged to, so a result could land on the other caller's screen or be discarded and never reappear. And a DTMF tone still held when its call ended had its stop dispatched against whatever call was on screen by then.
  • Four call states rendered no buttons at all. CallUiPhase had four values and OTHER was the drain: a held call, a call being torn down, a dual-SIM call waiting for a phone account to be picked, and Telecom's simulated ringing each produced a screen with a caller's name on it and nothing to press. HOLDING and DISCONNECTING are now real phases, HOLDING gets a Resume button, and OTHER keeps a hang-up button deliberately — whatever the state turns out to be, ending the call is the escape hatch. DISCONNECTING is the one phase that still shows nothing, because the thing a hang-up button would do is already happening.
  • The app's own defaults could silence an ambulance. A callback from the emergency services arrives from a number that is not in the address book, so with the default action set to BLOCK, or inside quiet hours, or under a country rule, it was blocked — and with the auto-responder on it was answered, read a greeting, and hung up on, with nothing to tell the user. Settings gains an "Emergency callback exemption", on by default: for 30 minutes after an emergency call every incoming call is let through, checked before every rule including a manual block. It uses PROPERTY_EMERGENCY_CALLBACK_MODE plus the last emergency number this app saw dialled, so it needs no new permission — TelephonyManager.isEmergencyNumber would need READ_PHONE_STATE, which this app deliberately does not declare. A callback arriving after the window has closed is not covered, and the setting says 30 minutes rather than implying more.
  • RuleDecision.isBlocked is now an exhaustive when. The old chain of !is compiled fine when a variant was added and silently classified it as blocked, which is the dangerous default for a phone app. Adding the emergency decision to that chain is exactly the mistake it now prevents.
  • Back threw away the call screen. It called finish() while the call carried on, leaving the user on a live call with nothing showing it. Back now backgrounds the task, as Home already did. It is not simply swallowed — leaving the call screen during a call is reasonable, and a back button that does nothing is its own bug — so Home gained a "A call is in progress / Return to call" card, above even the permission warnings. The ongoing-call notification was the only route back and is not posted at all when notifications are switched off, which would have made this half a fix for exactly the people most likely to hit it.
  • 580 → 612 tests (:shared 503 → 528, :androidApp 77 → 84). The dark-mode source scan and the call-waiting promotion test were each watched failing against the bug first. ./scripts/verify.sh green, including the iOS compile. Still not run on hardware: none of this has been on a device — no second call has been placed, no call held, no emergency number dialled, and the dark palette has not been looked at on a screen.

2026-08-18: the call screen could not press 1, nothing said which build was installed, and four Home tiles all landed on the same list.

  • There was no keypad during a call. This app holds ROLE_DIALER, so its call screen is the only call screen the user has — and without a pad on it, every bank, airline, clinic and utility menu ends at the first prompt, with no way through except reinstating the phone app this one replaced. The pad now appears behind a toggle on a connected call and sends each key through Call.playDtmfTone. It is offered only on an ACTIVE call, because Telecom drops a tone played on one still ringing or dialling and a button there would look like a keypad that does not work.
  • The tone is held 250 ms rather than stopped in the same frame. Below roughly 40 ms an ITU Q.24 receiver is not obliged to recognise it, and a menu that drops every other digit is indistinguishable from a broken pad. A pending stop is cancelled first, so digits tapped quickly do not cut each other short.
  • The digits sent live in InCallState, not in the composable. DTMF is fire-and-forget — nothing echoes a digit back — so someone part-way through a card number has no other way to check what has already gone, and an activity recreation would wipe the only record of it.
  • The twelve keys are now one DialPad shared with the dialer screen, so they cannot drift apart. + stays on the dialer alone: it can be dialled but has no tone. The pad scrolls inside its own area, because in landscape it is taller than the gap between the caller's name and Hang up, and a pad that pushed Hang up off the screen would be worse than no pad.
  • Four Home tiles navigated to a filter their destination could not read. "Blocked today", "This week", "This month" and "Pending review" each go to call_log/<filter>, and all four showed the unfiltered list. The destination cast NavBackStackEntry.arguments to Map<String, *> — a type it has never been — and the compiler said so on every build ("this cast can never succeed"); the as? turned that impossibility into null and the elvis replaced it with "all". CallLogViewModel.applyFilter had handled all four values the whole time and its unit tests passed, because they call it directly. The dead step was between the route and the ViewModel, which is where a component test does not look.
  • Nothing in the app said which version was running. A bug report that does not establish that costs a round trip before anyone can ask whether the fix is in — and this project has spent six version codes, several differing only in a Console form answer. Settings' last row now reads 1.4.0 (6), read from PackageManager on Android and the bundle's Info.plist on iOS. Not a constant in commonMain: that would be a second number to remember to bump, and a version row that can disagree with its own artifact answers nothing.
  • Credits shipped reachable and empty, and had said "names are on the way" since 2026-08-13. It now names the maintainer and the AI pair, and adds a second section for the libraries the app is built out of — with SPDX identifier and project home, so a reader can check the terms rather than take the screen's word for them. Test-only and build-only dependencies are deliberately absent: nothing of Robolectric or the Gradle plugins reaches a device. The new test pins the default lists rather than only the ones a test passes in; the empty state was the covered one, which is exactly the shape that lets a placeholder hide a list nobody filled.
  • 571 → 580 tests (:shared 494 → 503). ./scripts/verify.sh green, including the iOS compile, Android Lint and the SQLDelight migration check. Not yet run on hardware: the DTMF tones have not been sent into a real automated menu, and the restored date filters have not been tapped on a device.

2026-08-14 (fifth): contacts were blocked anyway, the call log offered the action you had already taken, and the greeting nobody heard.

  • A contact in the address book could still be blocked — the other half of the 08-11 fix. PhoneNumberParser.sameNumber was made right three days ago, and then AndroidContactsGateway handed it numbers it had already run through normalizeForComparison, which strips the +. A contact saved +34611998877 therefore arrived stating no country, so sameNumber could no longer split off its national form, and a call delivered as 611998877 — ordinary for a domestic call — stopped matching. The contact was not allowlisted and any pattern, country, quiet-hours or default-block rule blocked someone the user had in their phone. The call log showed their name the whole time, because the name map was keyed by comparisonKeys and was never wrong, which is what made it look inexplicable. The lesson is bigger than the line: sameNumber only works if every caller hands it numbers as saved, so any normalisation upstream silently disables it. That contract is now written on ContactsGateway.contactNumbers, and the cursor-loop mapping moved into a pure buildContactsSnapshot that unit tests can reach without a ContentResolver.
  • Proven by A/B on a real call path, not by reasoning. rule_matrix_test.sh gained a phase F — a contact saved internationally, called from a domestic line — and the fix was reverted, rebuilt and reinstalled to watch it fail: BLOCKED|-, the user's report reproduced exactly. Phase E still passed against the broken build, which is the point: the existing regression covered the opposite direction and could never have caught this.
  • Phase E's own SKIP branch had never once executed. Under set -e -o pipefail, the grep -v '^+' that looks for a national-format contact exits 1 when it filters everything out — which is precisely the case it exists to detect. The script died there instead, printing no result, no reason and no summary, and exiting 1. Another guard that had never run.
  • The call log now says whether a number is on a list, and offers the action that is left. It only ever showed what happened to each call, and offered Block and Add-to-allowlist regardless — so blocking an already-blocked caller was a tap that did nothing visible. Rows carry an "On your block list" / "On your allowlist" badge, and the tap dialog and tablet detail pane flip to Unblock and Remove from allowlist. The two facts are deliberately kept apart: a call blocked last week by a rule since deleted still reads "Blocked call" and carries no badge.
  • The custom greeting was never played on a real call. The picker used GetContent(), whose read grant is scoped to the picking activity's task and long gone by the time PassthroughInCallService reads the URI days later in another process. setDataSource threw, the blanket catch reported completion, and the blocked caller was answered and hung up on in silence. Now ActionOpenDocument plus takePersistableUriPermission, refusing to save a URI that cannot be persisted — and if the audio still fails to open, the script is spoken rather than nothing.
  • The recorder belonged to the service, not the call. With two calls in progress, whichever ended first stopped the other's recording and filed its audio under its own call-log row. It now lives on the per-call state. The TTS completion callback also mutated that state from a text-to-speech engine thread; it hops back onto the service scope first.
  • "Recording is not working" was usually the app being silent about the truth. Recording only ever runs on a call the auto-responder itself answered, so with the auto-responder off the switch was inert and said nothing. RecordingReadiness names the reason — responder off, greeting invalid, microphone not granted — and a "How this works" card states the limits the feature genuinely has: only blocked calls are answered, the greeting reaches the caller acoustically through the loudspeaker, and recording captures the microphone rather than the call, so some phones capture nothing. A limitation the user can read is not a bug report.
  • Two strings shipped a literal backslash. Compose Multiplatform resources are parsed as plain XML, where Android's \' apostrophe escape means nothing, so the auto-responder card read your phone\'s loudspeaker — and blocklist_duplicate_blocked_body had been showing won\'t override the block in the duplicate-number dialog for as long as it has existed. Nothing could catch it: it compiles, TranslationCompletenessTest only counts keys, and Android Lint cannot see that resource tree at all. Found by screenshotting the screen; now a test.
  • verify.sh never compiled a line of commonTest for Kotlin/Native, while CI runs :shared:iosSimulatorArm64Test. Two iOS-only failures got through as a result: Kotlin/Native rejects a comma inside a backticked test name (three tests, green on the JVM for months), and viewModelScope work never runs in a Native unit test without Dispatchers.setMain, so CallLogViewModelTest waited out runTest's full one-minute timeout. Both fixed, and the compile task added to the script.
  • ring_test.sh cried wolf after the rule matrix. The matrix leaves the device on default_action=BLOCK, under which the unmatched number it rings with is blocked and CallRinger correctly stops — and the script printed "Ringing FAILED. Users of this build would miss calls silently." It now checks that precondition the way it already checked ringer mode, and refuses to run rather than lie.
  • 528 → 571 tests (:shared 406 → 494, :androidApp 66 → 77). The contacts fix, the escaping check and phase F were each watched failing first. ./scripts/verify.sh green; on a Pixel API 36 emulator rule_matrix_test.sh reports 14 passed, 0 failed, 0 skipped, and ring_test.sh auto verifies both the ringing and the silence halves.

2026-08-14 (fourth): dates spoke English to everyone, and the store screenshots were nine days stale.

  • The call log's timestamps were never localized. They were assembled in commonMain from local.month.name — an English enum constant — cut to three letters, then day, year and a 24-hour clock in that fixed order. Every other string on the screen was translated, so es/pt/hi readers got a fully localized call log whose dates read "Aug 14, 2026". Now delegated to each platform's own formatter (DateTimeFormatter.ofLocalizedDateTime, NSDateFormatter), because twelve month names per locale plus field order and clock convention is knowledge both platforms already have. Spanish reads 14 ago 2026, 3:18 p.m.
  • Found by screenshotting the app in Spanish, not by a test — which is the second time that pass has caught a shipping localization bug in this project. The regression test asserts that the locale is honoured rather than pinning exact wording, since CLDR changes month abbreviations between releases; three of its four cases fail against the old formatter.
  • Ringing is confirmed on the razr. The oldest open risk in this project — the app rings for itself because it declares IN_CALL_SERVICE_RINGING, and until today that had only ever been proven on an emulator. A real inbound call on the handset rings. The one measurement worth recording alongside it: the phone's own ring volume was at 1 of 7, so "the ringtone is quiet" was the slider, not the app.
  • Store screenshots retaken on a Pixel API 36 emulator running the release build, five per language, with seeded data that uses only +34 900 service-range numbers and invented contacts. The set now covers the keypad with contact search and the call log with its filters — neither of which existed when the old shots were taken. Raw captures in docs/store/, 9:16 padded copies for upload in docs/store/play/.

2026-08-14 (third): answering a call killed the app, and four bugs turned out to be one.

  • The app crashed the moment a call was answered. Android 14 refuses a CallStyle notification that is not tied to a foreground service or a user-initiated job and carries no full-screen intent — and it refuses it by throwing IllegalArgumentException out of notify(), on the main thread, inside a Call.Callback. The "return to call" notification is posted the instant a call goes ACTIVE and had none of the three:

    java.lang.IllegalArgumentException: 0|org.carlospinan.cortaspam|1002|null|10728 Not posted.
    CallStyle notifications must be for a foreground service or user initated job or use a fullScreenIntent.
        at IncomingCallNotifier.notifyOngoingCall(IncomingCallNotifier.kt:157)
        at PassthroughInCallService$stateCallback$1.onStateChanged(PassthroughInCallService.kt:156)
    Process org.carlospinan.cortaspam (pid 24913) has died: fg TOP
    
  • That one crash was three of the four reported symptoms. Telecom answers a dead default dialer by handing the live call to the preloaded one, so the system phone app "took over" mid-conversation; the app's own call screen (icon and all) vanished with the process that was drawing it; and its missed-call notification never arrived, leaving only the platform's. The ringing notification was never affected — it carries a full-screen intent, which is why every test up to now had been of a call that rings and is never answered.

  • The ongoing notification is now a plain one with a Hang up action and a tap target back into the call. CallStyle bought nothing there. post() also swallows RuntimeException now: a notification is never worth a live call, and this failure mode must not be reachable from any future notification the platform decides to reject.

  • The platform's missed-call notification is suppressed properly, rather than by accident. MissedCallNotifierImpl posts its own unless the default dialer declares a receiver for ACTION_SHOW_MISSED_CALLS_NOTIFICATION — the test is the receiver existing, not what it does. It is declared for both the android.telecom.action and android.telephony.action spellings, and it will usually never fire, because Telecom sends the broadcast with READ_PHONE_STATE and this app deliberately does not hold it.

  • The call log gained filters: a search box (name or number), direction/outcome chips (All / Incoming / Outgoing / Blocked) and date chips (Today / This week / This month). The first two are pure functions over the loaded entries so typing never hits the database; the date chips go to the ViewModel, because a date window is a re-query. An empty filter result gets its own message — "no calls yet, add a number to start blocking" is advice for an empty log and misleading for a search that matched nothing.

  • Verified on a Pixel API 36 emulator, which enforces the rule the razr crashed on: a simulated call answered, the app's own InCallActivity still resumed afterwards, the ongoing notification posted with its Hang up action, the process id unchanged either side of the answer, zero FATAL EXCEPTION lines — and Telecom's missed-call notification not updating after a fresh missed call, i.e. no longer posting. The filters were driven on the razr against a real mixed log.

2026-08-14 (second): the log that remembered half the calls, and the call screen that knew the name and did not say it.

  • Outgoing calls were never logged at all. The call log grew out of the rule engine, so a row was a decision record — and an outgoing call has no decision, because the screener deliberately never evaluates one. The result was a dialer whose history was missing every call its owner had placed. CallLogEntry now carries a direction column and outgoing calls are written to it.
  • 3.sqm rebuilds the table rather than using ALTER TABLE ADD COLUMN, because the new column carries a CHECK constraint and a table built by ADD COLUMN is not guaranteed to compare equal to the CREATE TABLE that verifyMigrations diffs it against. Two traps came with that: DROP TABLE takes the index with it (idx_call_log_action_time has to be recreated, or Home's three window counts silently go back to full scans), and SQLDelight's migration compiler does not model the drop — without an explicit DROP INDEX first, generation fails with Duplicate index name.
  • Outgoing rows are ALLOWED with a NULL rule_type — the only value the action CHECK leaves, and the one that keeps them out of the blocked-call counters — but the log labels them "Outgoing call", not "Allowed call". Reusing the allowed label would report a screening result for a call that was never screened.
  • The call screen showed a bare number for everyone, including people already in the address book: the app knew the name well enough to put it on the notification and dropped it on the one surface where "who is this?" is the whole question. It now shows the contact's name, or failing that the label the user gave that number in their own block/allowlist, with the number kept underneath.
  • The lookup runs off the call-setup path and is applied only if the call it was started for is still the one on screen — otherwise a slow provider query returning after the call ended would put the previous caller's name on the next caller's screen.
  • Verified on hardware and emulator: on the razr, PRAGMA user_version reads 4 after the upgrade, the row that was already in the log survived as INCOMING, and a call placed from the keypad appears as "Llamada saliente" above it. On the emulator, a call from a saved contact shows the contact name over the number, and a call from a labelled allowlist entry that is not a contact shows the label — read out of the view hierarchy, because the app's own ongoing-call heads-up notification covers the headline in a screenshot.

2026-08-14: the keypad learned the address book, and a notification body finally does something.

  • A dialer with no contact search could only call numbers the user knew by heart. ContactsGateway had read the address book since the allowlist needed it, but only ever as a Set<String> of normalized digits and a lookup map — both shapes that have thrown away the ordering and the punctuation a person needs to recognise a contact in a list. It now also returns List<Contact> (name + number as saved, collated by name), and the keypad's single field searches it while it dials.
  • One field, both jobs. A name matches by prefix, then by word start — so "ana" finds Ana and Maria Ana but not Susana, because a syllable inside a word is a coincidence. Digits are compared as digits on both sides, so stored formatting is irrelevant and the national part of an international number matches too: the address book holds +34611998877 and the user types the 611998877 they know. Results are capped at five with the overflow stated, not silently dropped — this screen is one scrolling column, and 500 rows for a query one more character would have narrowed is not a search result.
  • Tapping a finished-call notification used to do nothing. Its buttons worked; the body had no content intent at all, so the notification reporting a blocked or missed call could only be dismissed, and the log it was reporting stayed two navigation steps away. It now opens the call log with that caller's actions already up (Block / Add to allowlist / Call back / Copy number). The per-number data URI matters as much here as on the buttons: PendingIntent equality ignores extras, so without it FLAG_UPDATE_CURRENT would repoint one caller's notification at the next caller's number.
  • placeCall was escaping nothing. MainActivity built tel:$number raw while CallActionReceiver used Uri.encode(number, "+"). The keypad has a # key, so a number containing one was reachable by typing, and everything after the # reads as a URI fragment — a truncated number, dialled. Same encoding on both paths now.
  • Verified on the razr 50 ultra (ZY22JZJDNH), API 36, against its real 1,900-contact address book: searching Ana returns four contacts, including one matched on the second word of the name rather than its start, and searching 0000 matches four by number, and tapping a result fills the field. Hardware found a bug the emulator could not: with the soft keyboard up, the results list pushed the Call button off the bottom of the screen, so picking a contact hid the button that places the call — picking now clears focus with the keyboard.
  • Outgoing calls re-verified on that hardware, which is what the 1.2.0 cut was waiting for: dialling from the keypad binds PassthroughInCallService, shows InCallActivity and reaches the network, and it still does with the number on the manual blocklist and an all-day quiet-hours rule active — the call log stays empty for both, so the rule engine never saw them.
  • Notification flow proven end to end on the emulator, where an inbound call can be simulated: a missed call posts one notification with Call back + Block, tapping the body opens the call log on +34655111222 with its four actions, Block writes the rule and dismisses the notification, Call back places the call. Still unproven on the razr — an inbound call needs a second phone, and ringing on OEM hardware remains the oldest open risk.

2026-08-13 (tenth): version 1.2.0 on code 4, cut for internal testing.

  • The artifact is corta-spam-1.2.0-4-release.aab (5.21 MB). Release name for the Console, internal only and under the 50-character cap: 1.2.0 (4) internal — keypad, call back, outgoing fix. Tester notes in both languages live in docs/store/RELEASE_NOTES_1.2.0.md.
  • Version code 4 is reused, not skipped. A code is spent on upload, not on publication, and both previously built code-4 artifacts (0.1.0 and 1.1.4) were superseded before either reached the Console. They are in bundle/release/superseded/; the filename is the only thing separating them from the one to upload. If Play refuses the upload because it has seen code 4, that assumption was wrong and the fix is a rebuild at 5.
  • The name went to 1.2.0 rather than 1.1.5 because this adds a feature rather than fixing one: 1.1.4 could not place a call at all.
  • Audited on the APK, never the source manifest: versionCode='4' versionName='1.2.0', targetSdk 36, android.hardware.microphone as uses-feature-not-required, no READ_PHONE_STATE, faketouch the only uses-implied-feature, jar verified as CN=Carlos Pinan, R8 mapping present, es/hi/pt all packaged. ./scripts/verify.sh green before the cut.
  • The release notes went over the cap on the first pass — 574 characters of Spanish and 522 of English against Play's 500 — which is exactly why RELEASE_TEMPLATE.md says to count them rather than estimate. Trimmed to 494 and 473.
  • What internal testing is for here: no physical device has run the keypad, the call-back button or the outgoing-call fix, and ringing has never been proven off an emulator.

2026-08-13 (ninth): the screener was screening the user. Outgoing calls, and tests for placing one.

  • Calling a number on your own blocklist made your phone hang up on you. An InCallService receives every call, in both directions — that is the price of owning the in-call UI — and onCallAdded gated only the ringing on call state. The evaluation was gated on nothing, so the rule engine ran against numbers the user had dialled: a match called call.reject() on the call they placed. A quiet-hours rule blocks everything not allowlisted, so inside that window every outgoing call would have been terminated. With the auto-responder enabled it is not a hang-up at all — the app answers and plays the user's own greeting into their own call.
  • As old as the service, and reachable as of yesterday. Placing a call from inside the app was only possible through a call-log row until the keypad shipped. A feature does not only add surface, it makes existing paths reachable; "fine for months" was a statement about traffic, not correctness.
  • Fixed with a direction check extracted to CallDirectionPolicy, for the reason RingerPolicy and NotificationPolicy were extracted: no unit test can construct the service. Call.Details.callDirection is authoritative from API 29; below it, the state at onCallAdded is the signal (Telecom adds an incoming call already ringing). An unrecognised state resolves to not incoming — failing to screen one call is a nuisance, ending a call the user placed is the phone breaking, and the default belongs on the side of the smaller mistake. The early return sits after the in-call UI is launched: an outgoing call still needs this app's screen and its return-to-call notification.
  • Tests for the calling scenario, which is what found it: the direction truth table, and the notification Call back path — permission granted places ACTION_CALL, denied opens this app's own keypad pre-filled, the fallback is pinned to this package so it can never offer the dialer this app replaced, a blank number starts nothing, and calling back dismisses the notification and resets that caller's attempt count.
  • They also caught a bug written an hour earlier: Uri.encode(number) escapes + to %2B, breaking every international number. Uri.encode(number, "+") keeps the plus and still escapes the #. One assertion on a literal tel: string found it.
  • 458 → 472 tests (:androidApp 52 → 66). The direction check was watched failing before its test was kept. ./scripts/verify.sh green.
  • Verified on the Pixel_8_Pro_API_36 emulator, both directions, by A/B against a build with the guard removed. With the guard out: dialling a blocklisted number wrote +34600123456|BLOCKED|MANUAL to the call log, posted a "Blocked call" notification and recorded a CallAttempt — the app screening the user's own outgoing call. With the guard in: the same call reaches state=DIALING with an empty call log, no attempt row and no notification. Incoming screening is intact (BLOCKED|MANUAL), including under an all-day quiet-hours rule (BLOCKED|SCHEDULE) while an outgoing call inside that same window is left alone. Call back on a missed-call notification placed the call and dismissed the notification; two missed calls from one number collapsed to a single notification reading "Missed call · 2 attempts" with Call back and Block. Still unverified on OEM hardware.

2026-08-13 (eighth): the default dialer could not dial. Keypad, call back, and one notification per caller.

  • Taking the dialer role removed the user's ability to place a call. ROLE_DIALER replaces the phone app; the only way to originate a call in Corta Spam was tapping a row that already existed in the call log, so any new number meant leaving for another app. There is now a Keypad tab, second in the navigation bar. + is a key of its own rather than a long-press on 0 — a long-press is undiscoverable, and without it no international number could be dialled at all.
  • The manifest had been advertising a dialer it never implemented. Two ACTION_DIAL intent-filters (one with the tel: scheme) have pointed at MainActivity since the app first claimed the role, with a comment saying they exist for role eligibility. Nothing ever read them: MainActivity had no onNewIntent and never touched intent.data. So Corta Spam appeared in the chooser for every tel: link on the device, launched to Home, and silently dropped the number. This is the inert-feature pattern from Chapter 20 with no code in it at all — a manifest declaration that satisfies a platform check and answers nothing, which no audit of Kotlin can see.
  • ACTION_DIAL fills the keypad; it does not call. DIAL means "show this number, let the user decide" — CALL is the one that dials. Auto-dialling would turn every tel: link on the web into a call placed without confirmation. The number is carried as a DialRequest with an id, because a plain String? compared by value cannot tell "the same link tapped again" from "nothing changed", and the id is also what stops returning to the tab from retyping a number the user deleted.
  • Missed and repeat-caller notifications now offer Call back. Blocked calls deliberately do not get it: the rule existed to stop that caller being reached. Without CALL_PHONE the button opens the app's own keypad pre-filled rather than firing ACTION_CALL — a BroadcastReceiver cannot show a permission dialog, and the SecurityException would look like a button that does nothing. The fallback intent is pinned to this package, or ACTION_DIAL would offer the trip to another app that this whole change removes.
  • One notification per caller, with a count. Posting to a per-number id already replaced the previous notification, but nothing said it had happened, so five calls from one spammer read as one call five times over. The notification now carries "N attempts". Callers are matched with PhoneNumberParser.sameNumber, not a canonical key: that comparison is deliberately asymmetric — a national number may match an international one, while two international numbers with different country codes must never match even when their national parts are identical — and no single canonical string expresses that. Reducing a number to one key is what caused the contact bugs of 2026-08-11 and 2026-08-13. Withheld numbers arrive blank and are never counted, since one blank is indistinguishable from the next.
  • The nav bar had two sources of truth for tab order — the item list and the routeSection when — and inserting a tab shifts every index after it while nothing fails to compile. A new test asserts every entry in sectionRoutes selects its own tab.
  • 421 → 458 tests (:shared 396 → 406, :androidApp 25 → 52). The applied-id guard, the sameNumber matching and the attempt count were each watched failing before their tests were kept. ./scripts/verify.sh green.
  • Verified on the Pixel_8_Pro_API_36 emulator: am start -a android.intent.action.DIAL -d tel:+34600123456 resumes MainActivity with the Keypad tab selected and the number pre-filled, and does not dial it. Tapping Call places the call (state=CONNECTING, matching handle). Still unverified on OEM hardware — the razr has not run any of it.

2026-08-13 (seventh): "the home screen does not scroll" — answered with a test instead of an argument.

  • It scrolls. The report was investigated once by reading the code, which is not the same thing as checking: HomeScreen passes Modifier.verticalScroll(...) into AdaptiveContent, so on paper the question was closed. The device run that followed said otherwise — Home's last quick link clipped at the bottom edge, two swipes producing pixel-identical screenshots — but the emulator threw "System UI isn't responding" mid-swipe and died shortly after, so input delivery itself was suspect and neither the reasoning nor the observation was evidence. The question is now settled by two regression tests that fail against a Home with the scroll modifier removed, watched failing before they were kept.
  • Every existing Home test would have passed on the reported bug. They all assert assertExists, which is true of a node that was composed and then clipped off the bottom of the window — exactly the state a user calls "does not scroll". The new tests shrink the window until Home overflows, assert the last quick link is genuinely not displayed, and then require that scrolling reaches it. An absence assertion could not have caught this; a positive one does.
  • One of the two runs Home inside AdaptiveScaffold, because a screen that scrolls alone can stop scrolling once something is docked under it. Home is never rendered bare — the scaffold's NavigationBar takes the bottom of the window away from it. Testing the screen in isolation cannot see a bug that only exists in the composition the app actually ships.
  • Still not proven on hardware: the tests shrink the viewport rather than raising the font scale. The mechanism is the same (content taller than the window), but the razr has not re-run the original scenario at font_scale 2.0.
  • 419 → 421 tests (:shared 394 → 396). ./scripts/verify.sh green, including the iOS compile, Android Lint and the SQLDelight migration check, which had not run since b418b12.

2026-08-13 (sixth): onboarding was a dead end at a large font size. Version 1.1.4.

  • The default-dialer explainer clipped its own Continue and Not now buttons, and could not be scrolled. PermissionExplainerScreen was a plain Column().fillMaxSize() with no verticalScroll, so on a 1344x2992 emulator at font scale 1.8 the "what we will never do" box was cut mid-sentence and both buttons were simply absent. A non-scrolling Column clips its overflow rather than making it reachable, so there was no way forward and no way to scroll — first-run onboarding could not be completed at all at that font size. Reproduced on device, screenshotted before and after.
  • Audited every screen for this. Four needed fixing: the explainer, WelcomeScreen, RequestingIndicatorScreen and DeniedScreen. The rest already scroll (verticalScroll or LazyColumn). CallScreen is deliberately left non-scrolling: it uses Arrangement.SpaceBetween with a weight(1f) middle, so the answer and decline buttons stay pinned and the middle section shrinks instead — you must never have to scroll to decline a call.
  • The fix is one shared ScrollableScreenColumn, not four copies of verticalScroll. Adding verticalScroll alone would have silently broken the three centred screens: it measures its child with unbounded height, so the column wraps its content, no spare space remains, and Arrangement.Center becomes a no-op — every centred screen would have quietly become top-aligned. Giving the column a minimum height of the viewport keeps centring when content is short and scrolls when it is tall. It also applies safeDrawingPadding to the viewport rather than inside the scroll, so the scrollable area stops short of the system bars.
  • On the Play edge-to-edge advisories, an honest finding. Both activities already call enableEdgeToEdge(), there are only two activities, and AdaptiveScaffold already consumes top and horizontal insets while NavigationBar takes the bottom. The "uses deprecated APIs or parameters" advisory comes from androidx.activity's own implementation: EdgeToEdgeApi23, EdgeToEdgeApi26 and EdgeToEdgeApi29 each call Window.setStatusBarColor/setNavigationBarColor. Checked 1.9.3, 1.10.1 and 1.13.0 by decompiling all three — every version still contains those calls, and 1.13.0 adds an EdgeToEdgeApi35 that does the same. No library bump removes it, so an attempted upgrade was reverted rather than shipped as a fix that fixes nothing. The user-visible half of "edge-to-edge may not display" was the clipped-content bug above, and that is fixed.
  • R8 was already on. Play's advisory notwithstanding, unzipping the rejected code-3 bundle shows BUNDLE-METADATA/com.android.tools.build.obfuscation/proguard.map, written by R8 8.13.19. isMinifyEnabled, isShrinkResources and proguard-android-optimize.txt have all been set since 2026-08-05, and AGP 8.13.2 defaults R8 full mode on. Nothing was changed on the strength of an advisory the artifact contradicts.
  • Picture-in-picture is deliberately not implemented. It is a Play suggestion, not a finding, and it is a feature with real design questions attached (what shows in the PiP window during a screened call, and how it interacts with an InCallActivity that is excludeFromRecents and shows over the lock screen). Adding an unproven PiP surface to a release already carrying a policy history is a poor trade. Recorded here so the decision is visible rather than forgotten.
  • Version name is now 1.1.4 on version code 4. Code 4 was built on 2026-08-13 but never uploaded — production is still on code 3 — so the code is unspent and reused.

2026-08-13 (fifth): three device scripts were lying, and the rule engine was innocent all along.

  • rule_matrix_test.sh reported 4 failures out of 13. All four were the harness. place_call read ORDER BY id DESC LIMIT 1 without checking the row belonged to the call it had just placed, so whenever a write landed slower than its fixed 7-second sleep the assertion read the previous scenario's row. The output showed it plainly: the bundled-spam scenario reported the country scenario's outcome, and the allowlist scenario then reported the spam outcome arriving late. It now records the max row id before dialling and polls up to 15s for a row that beats it, reporting NO_NEW_ROW when none appears. The same defect could hand out false passes — any run where two consecutive scenarios expect the same outcome reads a stale row and calls it a match — so the previously recorded "13/13 on 2026-08-11" was never as strong as it looked.
  • The contact regression scenario was dialling a malformed number. It extracted the address book's national-format contact with sed -n 's/.*data1=\([0-9+][0-9]*\).*/\1/p', whose digit class stops at the first space: the emulator's 611 99 88 77 came out as 611, so the test called +34611. That is five digits, and PhoneNumberParser requires code.length + 4 before it will read 34 as a country code — so the engine correctly declined to match, and the scenario reported a contact-matching regression that did not exist. After the fix it dials +34611998877 and passes. 13/13, every branch, end to end.
  • ring_test.sh watch had never once run on real hardware. It resolved the app's uid by grepping dumpsys package for userId=; current Android prints appId=. On a razr 50 ultra it exited "org.carlospinan.cortaspam is not installed" while the app was installed, running and holding the dialer role — so the mode that exists specifically because an emulator cannot settle ringing had been failing instantly on the only device that could. It now accepts both spellings and distinguishes a genuinely absent package from a future field rename.
  • device_check.sh failed on other apps' crashes. Its crash check ran logcat -d across the device's entire persistent buffer with no logcat -c beforehand and no package filter, then exit 1. On the razr it matched a two-day-old trace and a fatal belonging to com.cpinanbuenosdias.app, and quit before a single database assertion ran — the schema and index checks the script exists for. It now clears the log before the launch it is judging and scopes the check to the app's own pid.
  • Generalisable: all four bugs made a check report something other than what it measured, and three of them had been doing it since the day they were written. A device script is code, and nothing was testing it.

2026-08-13 (fourth): the ringer gets tests, eight days after it was found inert.

  • Ringing had no test at all. The 2026-08-05 audit found the app took every call in silence — IN_CALL_SERVICE_RINGING declared, no ringtone code anywhere — and CallRinger fixed it, but nothing has covered it since: a ringtone lives inside MediaPlayer, Vibrator and an InCallService no unit test can construct, and "untestable" is how it shipped empty in the first place. The decision is now a pure RingerPolicy (the same extraction NotificationPolicy got), so the truth table is six assertions at a desk: silent rings not at all, vibrate mode vibrates regardless of the vibrate-while-ringing setting (there the vibration is the ring), normal plays the ringtone, and an unrecognised ringer mode rings rather than falling silent — the app told Telecom to stop ringing on its behalf, so an unexpected AudioManager value must never be why a call arrives silent.
  • A policy nobody consumes is exactly as silent as no policy. So a second Robolectric test drives the real CallRinger and asserts the vibrator is actually running. Both were checked by breaking them: stubbing out the vibration call failed three of the six, and the three that stayed green were the ones asserting absences — silent mode doesn't vibrate, stop() is safe when nothing started, a double start() doesn't stack. All three pass against a ringer that does nothing whatsoever, which is the state this app shipped in. That is what six inert features with green tests look like from the inside.
  • Still not proven, and not provable here: that a ringtone is audible. That needs a real audio HAL and the OEM behaviour an emulator cannot stand in for. ./scripts/ring_test.sh watch --device <serial> is the check, and it still has to be run on the razr. The test file says so, because a reader who thinks ringing is now covered is worse off than one who knows it is half-covered.
  • 407 → 419 tests (:androidApp 13 → 25).

2026-08-13 (third): the same contact bug in a third place, and this one was the platform's.

  • "Silence unknown callers" was silencing real contacts. The setting shipped earlier the same day asks "is this caller in my address book?", and PassthroughInCallService answered it with ContactNameLookup.displayNameFor(...) != null — that is, with ContactsContract.PhoneLookup. PhoneLookup does not compare the digits it is handed: it matches through the provider's NORMALIZED_NUMBER (data4) column, which the provider computes from the device's default region when the contact is saved. A contact stored 611 99 88 77 only carries +34611998877 there if the phone believed it was in Spain at the time; the call arrives from Telecom in E.164. Disagree, and there is no match — the exact national-versus-international mismatch fixed in the rule engine on 2026-08-11, walked back in by delegating to a component with its own idea of region. On an emulator with no SIM data4 is null for every row, so every contact read as a stranger and a real contact's missed call was suppressed. The probe is now isKnownContact, sitting beside contactDisplayName and asking ContactsGateway — the same address book the rule engine and every screen use — so the country code comes from the number itself and no region has to be guessed. The national-format case was watched failing against a single-key lookup before the fix went in.
  • Why it hid for months: the same PhoneLookup call had been in the notification path since the notifications were built, but only ever to supply a display name, where a miss degrades to showing the number and reads as a design choice. Reading the identical miss as a boolean turned it into a dropped notification. IncomingCallNotifier still uses it for titles, and is left alone here: a wrong name is cosmetic and a separate change.
  • Left unchanged deliberately: the ringing notification and the ongoing-call notification, neither of which consults contacts at all. Asking the gateway is a suspend call — it is cached for five minutes because it runs on the ringing path — so the policy check became suspend, which meant onCallRemoved had to read the number off the Call before launching its coroutine: that callback is the last moment Telecom guarantees details.handle is readable. The permission is read once per check and reused, because a denied read would otherwise make the gateway cache an empty address book for its whole TTL.

2026-08-13 (later): notification control, a credits screen, and the same contact bug in a second place.

  • The block lists had the contact bug the call log was fixed for two days earlier. BlockListScreens.kt looked names up with contactNames[normalizeForComparison(number)] — one key, bare digits — while ContactsGateway keys that map by every comparisonKeys form of each saved contact. So the lookup could miss an entry that was sitting right there, and both block lists showed bare numbers for people in the address book. Nothing tied the two copies together, which is why fixing the call log on 2026-08-11 left this untouched. There is now one contactDisplayName in contacts/, used by both screens, with the two failing cases (a contact saved nationally, and one saved with a trunk zero) watched failing against the old body before the fix went in.
  • Contact names never appeared if the permission arrived after the screen did. Both view models loaded the address book once, in init, behind hasPermission(). Granting contacts from Settings or the onboarding checklist happens while the screen is already on top, so that check had already run and said no — and the list showed bare numbers until the process died. Both now expose a RefreshContactNames intent, dispatched on resume, which is exactly when a returning permission dialog lands.
  • Notifications for unknown callers can be switched off, and the switch deliberately does not touch the ringing screen. The new notifyUnknownCallers setting filters the after-the-fact notifications — blocked, missed, repeat caller — for numbers the address book does not claim. It is not wired to the incoming-call notification: as the default dialer this app owns that UI, so suppressing it would not hide a banner, it would leave a stranger's call with no screen at all over the lock screen. Silencing a stranger outright is what a block rule is for. The decision lives in a pure NotificationPolicy object rather than inside the Telecom service, so it is provable at the desk instead of by placing a real call — including the trap that without READ_CONTACTS every caller looks unknown, which would have silenced the channel for the user's whole address book.
  • Finished-call notifications carry one-tap rule buttons. A blocked call offers Always allow (the undo), a missed or repeat-caller call offers Block. Which button appears depends on the outcome: "Block" on an already-blocked call changes nothing, and a button that does nothing when tapped teaches the user their taps are ignored. They could not go on the ringing notification — CallStyle.forIncomingCall fixes that one's buttons to answer/decline. Each button's PendingIntent carries a per-number data URI, because PendingIntent equality ignores extras: without it, posting a second notification would have rewritten the first one's number through FLAG_UPDATE_CURRENT, so blocking one caller would blocklist a different one. The write runs under goAsync(), and the notification is dismissed only after it lands.
  • Settings has a Credits section. It is reachable and empty on purpose, telling the user names are on the way rather than pretending nobody helped. Adding a name means adding an entry to CONTRIBUTORS and nothing else. Contribution lines are deliberately not localized — a resource key per person would fail the build every time someone was added without all four locales, and a credit is an attribution, not app copy.

2026-08-13: version code 4, cut for a fix that is not in the bundle.

  • A rejection spends a version code even when the remedy is a form answer. What resolved the code 3 rejection was one answer in the Play Console full-screen-intent declaration — No to the pre-grant question — and not a single line of the app changed because of it. Play still will not re-review a code it has ruled on, so shipping that answer costs a rebuild regardless. appVersionCode is now 4, and it carries the onboarding checklist row that declining the pre-grant made necessary. Codes 1, 2 and 3 are all spent; 2 was never even sent for review. Budget for that when planning a release.
  • The artifact was audited, not the source manifest. aapt2 dump badging on the built APK confirms versionCode='4', android.hardware.microphone as uses-feature-not-required (the line that stops RECORD_AUDIO implying it as required, which dropped 6 devices from code 2), no READ_PHONE_STATE, and the only uses-implied-feature being faketouch, which every app gets. The bundle is jar verified under CN=Carlos Pinan. The three superseded bundles are now in rejected/, leaving exactly one file in the release folder — four names one digit apart is how the wrong build gets published.
  • The fix that lives in a form is the one to double-check before sending. No artifact audit can see it. docs/store/SUBMISSION_0.1.0.md now makes re-confirming the No answer a numbered step in the production order, because the thing that would silently undo this release leaves no trace in the build.

2026-08-12: the same policy rejection twice, and the permission nobody would have found.

  • Being right about the policy did not get the app published. Version code 3 was rejected under the Full-Screen Intent policy with the identical finding that took version code 1 — "Permission use is not directly related to your app's core purpose" — except this time the declaration form had been submitted before upload and the listing already opened with "phone app". The theory that the reviewer never saw the form is dead. What actually resolved it was the form's second question, which nobody had read as load-bearing: do you want this permission pre-granted at installation? The rejection is a verdict on that request, so answering No removes the thing being judged. The permission stays in the manifest, setFullScreenIntent stays in IncomingCallNotifier, and no code was deleted to satisfy a policy. See docs/PLAY_FSI_APPEAL.md.
  • Declining the pre-grant made an in-app route mandatory, and there effectively wasn't one. Every install on Android 14+ now starts with the ringing screen off, so the user has to grant the app-op themselves. The only route was a warning card ranked third, behind the dialer role and notifications — and Home renders only the first card, so on a fresh install the one permission that is now always missing sat behind a "more to fix" link into Settings. Full-screen intent is now a row in the onboarding checklist, directly under notifications because they are one feature between them. Its button reads Open settings, not Allow: there is no runtime dialog for an app-op, and a button promising one that never appears is a dead control. onResume picks up the grant on the way back.
  • The warning it replaces was unreachable on the only emulator this project had. canUseFullScreenIntent() is API 34+; below that MainActivity hard-codes the permission as allowed, and the whole warning path is dead code. The project's one AVD was API 33, so every full-screen-intent state had been shipped untested. An API 36 AVD now exists for it.

2026-08-11: contacts the allowlist could not see, a permission with no caller, and a permission that quietly required hardware.

  • Contacts saved the way they are dialled were not allowlisted. Every comparison in the rule engine ran through normalizeForComparison, which is filter { it.isDigit() }. A contact stored 611 99 88 77 normalises to 611998877; the same person calling arrives from Telecom as +34611998877 and normalises to 34611998877. Not equal — and since RulePrecedenceResolver merges contacts into the allowlist by that comparison, a real contact was not allowlisted and could be blocked by quiet hours, a country rule or the default action, silently. Most people save contacts in national format, so this was the common case. Reported as a call log showing bare numbers, which was the visible half; the incoming-call notification had been showing the correct name all along because that path goes through the platform's own ContactsContract.PhoneLookup. Matching now compares an international number against the national form derived from its own country code, so no default region has to be guessed — a guess would need a SIM/locale lookup, an ISO-to-dialling-code table this app does not have, and a trunk-prefix rule that differs per country, since dropping a leading zero is right for the UK and wrong for Italy. Two numbers that both state a country are still decided by their country codes, so +34611998877 and +51611998877 remain different people. Four resolver tests were watched failing against the old comparison before the fix went in.

  • Declaring RECORD_AUDIO was excluding devices that have no microphone. Play reported that versionCode 2 "no longer supports 6 devices that were supported in your previous release", and the cause was not in the manifest as written — aapt2 dump badging showed uses-implied-feature: name='android.hardware.microphone' reason='requested android.permission.RECORD_AUDIO permission'. A permission implies its hardware feature as required unless the feature is declared explicitly, so adding optional call recording silently narrowed the app's device catalogue. Auto-responder recording ships off, and AutoResponderRecorder.start() already returns false on a missing or busy microphone and catches IOException, IllegalStateException and the bare RuntimeException that MediaRecorder.start() throws on some devices — so the app runs fine without one. The manifest now declares android.hardware.microphone with required="false", and the artifact confirms it: uses-feature-not-required. versionCode moved to 3, because 2 had already been uploaded and Play never accepts a code twice.

  • READ_PHONE_STATE was declared in the manifest and read by no code in the app. Its only justification was the comment above it, claiming the permission was required to be eligible for RoleManager.ROLE_DIALER — which is not what AOSP's roles.xml says. Eligibility comes from the two ACTION_DIAL activities listed under <required-components>; the phone permission set sits under <permissions>, and that is what holding the role grants, not what qualifying for it demands. So the app was asking users for a permission in order to be given a permission it never read. Removed, and the comment replaced with what is actually true. The onboarding checklist's Phone row is unaffected: despite the name, AppPermission.PHONE checks CALL_PHONE, which is used to place a call back from the log. Left in place deliberately: <applicationId>.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION, contributed by androidx.core through manifest merging. It is app-private and grants nothing, and while this app calls registerReceiver zero times, the androidx libraries it ships do — removing a permission a library needs surfaces as a runtime SecurityException on a user's phone, not as a build failure, so it is not something to strip on the strength of a grep.

  • The store listing claimed the app requests no microphone access. True of versionCode 1, and false from the moment auto-responder recording shipped the next day — RECORD_AUDIO is in versionCode 2. Both language versions of the full description now say the microphone is requested only when recording is switched on, that the feature ships off, and that recordings stay in app-private storage and are never transmitted, which is what the published privacy policy already said. A listing claim about permissions is checkable against the bundle in one command, which is exactly why it has to be true.

2026-08-08: the first thing a new user sees.

  • Home told the user they were protected while telling them screening was off. Found by installing the release build on a razr 50 ultra and refusing every permission — something no test looked at, because each half was individually correct. The banner said "Call screening is off until Corta Spam is set as your default phone app", and the toggle caption directly beneath it said "Blocking is on — spam and blocked calls are filtered". Without the dialer role no call reaches the app at all, so the second one was simply false, and it is the line a user reads to decide whether they are covered. There is now a third caption for "switched on, but inert", rendered in the error colour. The same install showed four stacked warning cards pushing the toggle and every counter below the fold, so Home now renders only the most severe one — the dialer role outranks a permission that degrades a single feature — followed by a link to Settings, which still lists them all.
  • The warnings were on a screen nobody opens. PermissionWarnings lived privately inside SettingsScreen, so an app that had silently stopped working looked identical to one that was working unless the user went looking. It is now shared, rendered on Home as well, and deliberately placed above the counters — someone whose blocking has stopped needs to see why before reading a "0 blocked today" that looks like good news. It also gained the warning that matters most, the missing dialer role, whose fix button re-runs the system role request rather than dumping the user in Settings. Contacts and microphone are deliberately still absent: both are optional and explained where the feature that needs them lives, and a banner for a permission you have not asked to use is a nag, not a warning.
  • Giving the dialer role away left the app claiming it still had it. DialerOnboardingViewModel.refresh() runs on every resume so that switching the default phone app from system Settings is picked up — but it only ever upgraded, to ALREADY_DEFAULT. Revoke the role and the state stayed GRANTED forever: a fully functional-looking app, blocking toggle on, that could no longer screen a single call, with nothing anywhere saying so. It now moves in both directions, and deliberately leaves REQUESTING alone, since the system role dialog is on screen and its result — not a resume poll — decides what happens next. Three of the five new tests were watched failing against the old body first.
  • The app's first act was a permission dialog nobody had explained. MainActivity.onCreate fired the POST_NOTIFICATIONS request unprompted, on top of a welcome screen that said nothing about permissions — indistinguishable from an app grabbing whatever it can, and the fastest possible way to earn a permanent "Deny". Onboarding now has a checklist step between the dialer explainer and the app: one row per permission, naming the single thing it is used for, with its system dialog behind an explicit Allow. Nothing is mandatory; the continue button is always enabled and only changes label. The microphone is listed but never requested there — it is asked for at the moment recording is switched on, because a dialer asking for the mic during onboarding, for a feature that ships off, reads as overreach. Below API 33 the notifications row is omitted entirely rather than shown as a button that opens nothing.

2026-08-06: Play policy fallout, and a recording toggle that never recorded.

  • "Record caller message" was a switch wired to nothing. The flag was persisted, validated by a consent gate, rendered as a Switch, and covered by a passing repository test — and no code anywhere recorded audio. There was no RECORD_AUDIO permission, no MediaRecorder, no AudioRecord. Worse, the published privacy policy carried a whole "Call recording" section describing the capability in both languages, and onboarding promised never to record "unless you separately turn that on". Four user-facing surfaces asserting a feature that did not exist. Now implemented for real: AutoResponderRecorder captures the caller's message after the greeting on an auto-answered blocked call, into app-private storage, capped at 60 seconds. It records the microphone, not the call. Android gates VOICE_CALL/VOICE_DOWNLINK/VOICE_UPLINK behind CAPTURE_AUDIO_OUTPUT, a signature|privileged permission no third-party app can hold — holding the dialer role does not grant it — so this is acoustic capture through the earpiece and several manufacturers reserve the mic during calls, in which case nothing is captured and the call simply ends as before.
  • A recording had nowhere to live and no way out. New CallLogEntry.recording_path (migration 2.sqm) ties audio to the call it came from, so playback and delete appear on that call's row rather than in a disconnected file list, and clearAll now deletes the audio files before dropping the rows — clearing the log used to be able to leave a stranger's voice on disk with nothing left pointing at it. logCall returns its inserted row id inside a transaction with last_insert_rowid(); connection-scoped as that is, a second call arriving mid-insert (call waiting makes this real) would otherwise file one caller's recording under another caller's entry.
  • The microphone warning is on the Auto-responder screen, not in Settings. Warning about a permission for a feature that ships off would be the same permanent nag showGrantContacts used to be, so it appears only once recording is actually switched on.
  • Play rejected USE_FULL_SCREEN_INTENT as "not directly related to your app's core purpose". The permission stays: the policy auto-grants it to apps whose core function is receiving phone calls, and this app declares IN_CALL_SERVICE_UI and IN_CALL_SERVICE_RINGING, meaning Telecom stops ringing and hands the job here. What was missing was the Play Console declaration form, which no document in this repo mentioned. Store listing copy, which led with call blocking and named the dialer role once in a closing caveat, now opens with "phone app" in both languages. This diagnosis turned out to be wrong — see 2026-08-12 above: the form was submitted for the next upload and the same finding rejected it again. See docs/PLAY_FSI_APPEAL.md.
  • STORE_COMPLIANCE.md described a network call that does not exist. It claimed the optional spam provider sent numbers to a public database. There is no HTTP client in the dependency graph and no INTERNET permission; the only bound implementation is an on-device list. The privacy policy had already been corrected for the same error; the compliance doc had not, and it feeds a legally binding Data Safety form.

2026-08-05 (audit): a review of the whole codebase, and the fixes for what it found.

  • The phone rang silently. The manifest declares IN_CALL_SERVICE_RINGING, which tells Telecom that this app rings for itself — so Telecom didn't. Nothing in the app ever played a ringtone or vibrated, meaning that as the default dialer it took every call in silence. New CallRinger plays the user's ringtone and vibration, honours the system ringer mode, and is stopped the instant a block decision lands — which is what keeps blocked calls silent and is why the declaration was kept rather than handing ringing back to the system.
  • Backup restore disabled the wrong rule. Importing any disabled rule wrote a default (enabled) row and then looked for "the one I just inserted" as the last element of a created_at DESC, id DESC query — the oldest row. Restoring a backup containing one disabled pattern switched off an unrelated pattern the user still wanted, silently, on every restore. Rows now carry their real enabled and created_at in a single insert, the whole import runs in one transaction, counts come from affected rows rather than a loop counter (re-importing your own backup used to claim it added everything twice), created_at survives the round trip instead of being restamped, an action rule's patternId scope is re-linked to the pattern's new id instead of dangling onto whatever row holds it now, and entries the UI could never produce — attempts = 0, which matches every caller — are rejected at the door.
  • The bundled spam list could never match anything. The resolver normalised the number to bare digits before the lookup, stripping the +, while every entry in the list is +E.164. All 32 prefixes were inert in the shipping app, and the tests didn't catch it because they exercised the provider directly rather than through evaluate. Providers now receive the canonical + form. The shape-based pattern list, which was separately unreachable, was removed rather than made live: a "contains a run of zeros" heuristic that has never been measured against real traffic doesn't belong on a path that silently rejects calls.
  • Blocking Morocco blocked Manhattan. Same root cause: with the + stripped, a national-format number is indistinguishable from an international one, so 2125551234 parsed as Morocco (+212) and 912345678 as India (+91). PhoneNumberParser now reports a country only for numbers actually written in international form (+ or the 00 access code), and canonicalises formatting characters on the way.
  • Repeat-caller rules had no UI at all. The table, repository, resolver branch, CallAttempt tracking and backup fields had existed since M2; the only way to create one was hand-editing a backup JSON. There is now a Repeat callers screen with an optional pattern scope. Pattern-scoped rules were also unreachable in the engine — the scope was resolved against enabled patterns, and an enabled pattern already blocks those numbers two steps earlier — so scopes now resolve against every pattern, which is what makes a disabled "scope-only" pattern useful.
  • A pattern of * blocked the entire phone. Matching compares digits, so a pattern with none has an empty core, and an empty core satisfies contains/startsWith/endsWith for every number alive. The add dialog accepted it. Rejected now in the matcher, the ViewModel, and backup import. The test that had asserted the opposite behaviour under the name patternMatch_caseInsensitive was observing this bug — pattern matching has no notion of case.
  • Four countries could not be added. CountryRule is UNIQUE(country_code) with INSERT OR IGNORE, and COUNTRIES listed codes 1, 7, 212 and 590 twice. The second entry appeared in the picker, did nothing when tapped, and never showed up in the list. Merged into one entry per code, pinned by a test.
  • Statistics reset at UTC midnight. "Blocked today" rolled over at 19:00 for a reader in New York, and the chart's Today row held calls from two different local dates. Boundaries are now computed locally with kotlinx-datetime, and the day buckets are consecutive local midnights rather than fixed 86 400 000 ms steps — which also fixes the 23- and 25-hour days around a DST change putting calls in the wrong bucket twice a year. The seven-day chart used to load every column of every row the call log had ever held; it now reads only blocked timestamps back to the oldest bucket, over a new index.
  • English text in a four-locale app. RuleDecision built its reason as an English sentence and wrote it into CallLogEntry.rule_detail, so every user read English and every historical row stayed frozen that way. Reasons are now structured data rendered per locale at display time, with a codec that degrades an unrecognised row to its raw text rather than blanking it. Same treatment for the stats day labels and the backup messages. The recording-consent gate only accepted an English phrase, so a user writing their greeting in Spanish, Hindi or Portuguese could never enable recording; all four phrases are accepted now. The default greeting comes from the platform's own resources instead of an English constant.
  • All database I/O ran on Dispatchers.Default. DriverFactory.databaseDispatcher had been declared, implemented on both platforms, and read by nobody — 43 blocking SQLite calls sat on the CPU-sized pool Compose also uses. Wired up, with tests that fail if any call site drifts back.
  • A second call corrupted the first. onCallAdded cancelled the whole service scope and overwrote single-field state, so a call arriving during another silently killed the first call's in-flight evaluation: never blocked, never logged. State is per-call now, on a service-lifetime scope.
  • Also: the auto-responder never forced the speaker, so the caller heard silence; every incoming call bound a text-to-speech engine whether or not the auto-responder was on; the contacts provider was fully scanned on every ring; the settings repository did its first synchronous disk read on the main thread mid-ring; and history notification ids could collide with the live call's.
  • The privacy policy described something the app doesn't do — it claimed the optional spam check sends numbers to a public database. The bundled provider is entirely on-device and makes no network calls at all. Corrected, and translated into all four locales along with the two other user-visible strings (about_open_source, terms_conditions_body) that were English-only. Eight dead string keys and one Spanish-only orphan were removed. A TranslationCompletenessTest now covers all three resource trees in both directions, because this project has two independent string systems and Android Lint can only see one of them.
  • Android Lint had never run. AGP 8.7.3's UAST frontend reads Kotlin 2.0 metadata only and died on every lint task against our 2.2.20 output — which is why it wasn't in CI. AGP 8.13.2 / Gradle 8.14.3 fixes that and also retires two workarounds it had forced (android.suppressUnsupportedCompileSdk and the androidx.activity downgrade). kotlin-stdlib is pinned to the toolchain's own version; SQLDelight was dragging the graph to 2.3.10, i.e. compiling against a newer stdlib than the compiler. Lint found three missing permission guards, a dead pre-API-26 branch, StateFlow.value read inside composition, plural forms that are wrong in Hindi and Portuguese, and an adaptive-icon monochrome layer that had been drawn but never referenced — all fixed, and lint now runs in CI.
  • androidApp had no release build type, so release was AGP's default: unminified, unshrunk, debug-signed. Added, with ProGuard rules for the reflective corners (serializers whose @SerialName ends up in the database, Telecom entry points named from the manifest), and assembleRelease runs in CI so R8 is exercised before a release depends on it. android:dataExtractionRules now states explicitly what allowBackup="false" already meant: the call log never leaves the device, by cloud backup or device transfer.
  • 273 → 339 tests (JVM); commonTest also runs 227 of them on the iOS simulator.

2026-08-05:

  • M12 is finished. Settings gains the tablet list-detail layout that had been deferred since 2026-07-28: on Expanded (>=840dp) a section list — Blocking, Contacts, Notifications, About — sits beside a detail pane, matching the Call Log's existing split. Phone and Medium layouts are unchanged, down to the ordering of the settings list. Permission warnings deliberately render on every section rather than being filed under the matching one, since a warning findable only by accident isn't a warning. Verified on an AVD at both 448dp (bottom nav, flat list) and 997dp (nav rail, split panes).
  • Closed the remaining test gaps: SqlRuleRepository (~40 previously untested members), BundledSpamProvider, and ContactNameLookup. androidApp had no test source set at all — it does now, running under Robolectric and wired into CI. 239 → 273 tests.
  • Fixed rule lists coming back oldest-first. created_at defaults to whole seconds, so rules added in the same second tied on it and SQLite fell back to insertion order — the opposite of the "most recent first" the repository documents. The five affected queries now break ties by id.
  • Two findings left deliberately unfixed and documented in tests instead: BundledSpamProvider's only spam pattern (+*000*) can never match, because the glob matcher only understands leading and trailing stars, so its 0.65-confidence branch is unreachable — making mid-string globs work would start blocking every number containing "000", which is a product call. And InCallState can't be fully tested without a mocking library the project deliberately avoids; covering it properly needs an interface between the Telecom callbacks and the object.

2026-08-04:

  • Fixed the iOS CI job, red on every run for weeks, and stacked two separate faults:
    • error: Unknown iOS simulator arch: 'x86_64'. shared declares iosArm64 and iosSimulatorArm64 but no iosX64, while CI builds with -destination 'generic/platform=iOS Simulator' — a generic simulator destination resolves ARCHS to arm64 x86_64, so the Kotlin framework task was asked for a slice that was never configured. iosApp/project.yml now sets EXCLUDED_ARCHS[sdk=iphonesimulator*]: x86_64, making the Xcode project and the Kotlin targets describe the same architectures. Adding an iosX64 target was the alternative, rejected because it serves only Intel-Mac simulators and doubles the Kotlin/Native compile work on every iOS build.
    • Behind it, Undefined symbols: _OBJC_CLASS_$_UIViewLayoutRegion. Compose Multiplatform 1.11.1 references that UIKit class from CMPLayoutRegion.o, and it only exists in the iOS 26 SDK — the macos-15 runner ships Xcode 16.4 / iOS 18.5. The job now runs on macos-26 (Xcode 26.6), matching the local toolchain; the deployment target stays at iOS 16.0, only the build SDK moved.
    • Worth knowing for next time: a local xcodebuild passes on Xcode 26 even while CI is broken, because the local SDK has the symbol. The job now prints its Xcode and iOS SDK versions before building, so that mismatch is one glance instead of a mystery linker error.
  • Tested the last three untested ViewModels — SettingsViewModel, BackupViewModel, AutoResponderViewModel — taking ViewModel coverage to 8 of 8 and the suite to 239 tests. Each guards something specific: SettingsViewModel's sixth flow rides a second combine() chained on the first (the typed overloads stop at five), so there's now an assertion that catches a future setting being dropped; AutoResponderViewModel gets a test per validation code, MISSING_CONSENT included, since recording a call without a consent line in the greeting is a legal problem rather than a cosmetic one; BackupViewModel emits only one-time effects on a rendezvous channel, so both export and import failure paths are covered.
  • Moved all 7 Robolectric screen tests to the v2 createComposeRule, clearing the deprecation warnings from Compose UI test 1.11.2. The v2 rule uses StandardTestDispatcher rather than UnconfinedTestDispatcher, so coroutines queue instead of running immediately — no test here depended on that, so it was an import swap with no synchronization added.
  • Fixed the Stats screen filing today's blocked calls under "Yesterday". blockedByDay() built its buckets by stepping forward from now - daysBack in 24-hour jumps, so the newest bucket spanned [now-1d, now) — it held every call from the last 24 hours, including one placed seconds ago — while its label came from the bucket's start, one day earlier. No bucket was ever labelled "Today". Buckets now align to UTC midnight, the same boundary countBlockedCallsToday already used, so the chart's first bar and the "blocked today" stat are computed off the same day boundary and can no longer disagree.
  • Gave the persistence layer its first real tests — 36 of them, against an in-memory SQLite engine rather than a fake. Not one Sql* repository had a dedicated test before: SqlSettingsRepository, SqlCallLogRepository, SqlAutoResponderRepository, SqlSpamProviderRepository, and the KeyValueSettingsStore they all share. Coverage includes every setting surviving a restart (a second repository over the same database, since these hydrate once in init and never re-read), every RuleDecision variant round-tripping through the CallLogEntry.rule_type CHECK constraint, and the strftime-based stats windows. The labelling bug above is what they caught. A second finding is documented but deliberately unfixed: clearing the auto-responder script persists an empty string, but readString treats a stored blank as "unset", so a restart resurrects the default script — readString is shared by three repositories, so changing its semantics is a wider decision.
  • Consolidated the test suite's three biggest duplicated fakes. FakeRuleRepository (2 copies), FakeSettingsRepository (5) and FakeCallLogRepository (3) now exist once each in shared/src/commonTest/.../app/testing/, shared by commonTest and androidUnitTest alike — 15% of all test source was hand-copied fake bodies, and RuleRepository's 64 members meant every interface change had to be applied to each copy by hand. One side effect worth naming: SettingsRepositoryTest.kt turned out to assert nothing about SqlSettingsRepository — all five of its tests ran against the fake declared in the same file. It's now FakeSettingsRepositoryTest.kt and pins the shared fake's write-through setters, which the ViewModel tests genuinely depend on; the real SqlSettingsRepository was left untested at that point, a pre-existing gap the old filename had been hiding — closed later the same day by the persistence-test pass above. Deliberately left duplicated: the 5-to-10-line ContactsGateway/DefaultDialerGateway fakes, where a local declaration reads better than an import.
  • Breaking (pre-release): the database file was renamed bloquellamadas.db → cortaspam.db and rootProject.name is now CortaSpam. A new filename means every device opens a fresh, empty database — existing block lists, rules and call history on dev devices are gone. Done deliberately while there is no public release.
  • Fixed a migration safety net that had never actually run. ./gradlew :shared:verifySqlDelightMigration failed with duplicate column name: pattern_id, and no CI job depended on it, so nothing noticed. Root cause was three-layered: schemaOutputDirectory was never configured so schema snapshots went stale at 5.db while migrations reached 7.sqm; and, once a correct baseline was rebuilt, the check caught a real bug — 5.sqm added pattern_id via ALTER TABLE ADD COLUMN, which SQLite cannot use to attach the REFERENCES PatternRule(id) foreign key that AppDatabase.sq declares, so upgraded databases and fresh installs genuinely had different schemas.
  • Because the database rename leaves the seven historical migrations with no users, they were squashed into a single baseline snapshot at shared/src/commonMain/sqldelight/databases/1.db. verifySqlDelightMigration now runs in CI, and was verified to both pass on a matching schema and fail with a precise column diff on a mismatched one.
  • Pinned the Compose Multiplatform resources package via compose.resources { packageOfResClass = ... }. It was previously derived from rootProject.name, so renaming the project broke every Res.string.* import across 12 screen files.
  • Moved bloquea_llamadas_mockups.html out of the repo root to design/mockups.html, and corrected the rule order it documented. It claimed "Allowlist first, then Manual" in two places; the shipped RulePrecedenceResolver checks manual block at step 1 and allowlist at step 2, deliberately, so that a manual block outranks a contact match.
  • Removed dead files a codebase review turned up: Greeting.kt/GreetingTest.kt (M0 scaffold whose only caller was its own test — test count 176 → 175), the four .claude/hooks/*.sh scripts (unmodified template scaffolding matching a feature//domain//core/ module layout this repo doesn't have, never referenced from settings.json, and weaker than the corta-spam-verify-build skill that superseded them), and the tracked .session_state.md session scratch file.

2026-08-02:

  • Architecture review found the app was consistently MVVM (single StateFlow<UiState> per ViewModel, Koin-scoped) but not MVI — no ViewModel had a single typed entry point for user actions, just N public setter methods each. Every ViewModel with at least one dispatchable action now exposes a sealed Intent type + one onIntent() function; the old public methods are private implementation details now. Read-only ViewModels with nothing external to dispatch (StatsViewModel) deliberately did not get one — a one-case sealed class with no caller is ceremony, not MVI.
  • Fixed 3 ViewModels that had drifted from the app's own "one UiState per ViewModel" rule: BlockListViewModel (11 separate StateFlows → one BlockListUiState with counts as derived properties), CallLogViewModel and DialerOnboardingViewModel (two disconnected flows each → one UiState). CallLogViewModel also absorbed the call-log date-range filtering logic that used to live inline in the navigation file, with new regression tests it never had before.
  • Fixed DialerOnboardingScreen taking the ViewModel directly as a Composable parameter — the one place in the app doing that. It now takes state + an intent-dispatch callback like every other screen.
  • BackupViewModel's export flow used to hand the exported JSON back via a UI-supplied callback parameter, which doesn't fit cleanly into a plain-data Intent. Replaced with a new BackupEffect.Exported(json) case alongside the existing success/failure effects.
  • Moved the Privacy Policy and Terms & Conditions body text out of hardcoded Kotlin string literals into string resources (English only for now).

2026-08-01:

  • New: repeated-caller bypass. An unknown number (no rule matched, falling through to defaultAction = Block) that retries at least N times within 24h gets let through instead of silently blocked forever — off by default, attempts threshold configurable (2-10) in Settings. Deliberately scoped to only the no-rule-matched path in RulePrecedenceResolver: a manual block, pattern, country, spam, action-rule, or schedule match always wins regardless of retry count, since retrying is a robocall hallmark and bypassing those would undo real spam blocking. Shows a "called you N times" hint on the ringing screen and a notification when it fires; reuses the existing attempt-tracking infrastructure built for the mirror-image "block after N attempts" action rule. Required a CallLogEntry.rule_type schema migration for the new REPEATED_ALLOWED tag.
  • Added a "View example format" dialog to the Backup screen showing a sample JSON snippet (with a labeled entry) — the label field was already round-tripped end-to-end in export/import, just undocumented in-app.
  • Added a "Test greeting" button to the Auto-responder screen that plays the current script/audio locally through the phone speaker, so you can preview it without triggering a real call.
  • Added a "Show notifications" setting that mutes every notification the app posts, including the incoming-call alert — off means calls stay fully silent unless the app is already foregrounded. Also centered the app logo in the empty middle of the in-call screen for all phases (ringing/dialing/active).
  • Fixed a crash: tapping "Call back" on a private/restricted call-log entry (blank number, a legitimate case for withheld callers) built an unresolvable tel: intent and threw an uncaught ActivityNotFoundException. The action is now disabled for blank numbers, and the call-placing code is defensive against any other unresolvable-intent case.
  • Architecture cleanup, driven by a full review against clean-architecture/MVVM conventions:
    • All 7 ViewModels are now scoped to their nav route instead of living for the whole app session.
    • The Backup screen's result message was persistent state with nothing ever clearing it, so it re-appeared stale on revisit — replaced with a proper one-shot effect + Snackbar.
    • MainActivity no longer injects repositories/ViewModels directly; the audio-picker result and first-run welcomeShown flag now flow through the ViewModels that actually own them.
    • Settings/Auto-responder/Home now each expose a single UiState instead of several independent state flows.
    • Extracted the incoming-call rule-evaluation logic out of the Android Telecom service into a shared, unit-tested use case — previously the single largest piece of untested logic in the app.
  • New: optional labels on blocked/allowlisted numbers now also surface in Call Log rows for allowlist matches (manual blocks already showed theirs).
  • New: Call Log, Block List, and Allowlist rows show the matching phone contact's name instead of the raw number (Android only — iOS contacts access is still a stub).
  • Fixed install_android.sh's rebuild check: it compared the APK's timestamp against ./gradlew (which never changes) instead of the actual sources, so it silently kept installing a stale build after the first run.

2026-07-31:

  • Added the full incoming-call notification pipeline — previously none existed, so incoming calls only surfaced via a plain startActivity() from a background service, which Android silently drops when the screen is off/locked. Now: a full-screen ringing alert with Answer/Decline actions, a persistent "return to call" notification while a call is active, and post-call missed/blocked notifications showing the caller's contact name (via ContactsContract.PhoneLookup) and block reason. Three notification channels; all strings localized (en/es/hi/pt) via native Android resources.
  • Settings now shows real permission-status warnings — notifications denied, full-screen-intent revoked (Android 14+), Phone permission denied — each with a one-tap link to the right system settings screen. These used to fail silently with no signal to the user.
  • DefaultAction.ASK is now a real behavior instead of a silent alias for Allow: unmatched calls are let through and tagged "Needs review" in the call log. Home shows a "Pending review" count card; Call Log has a matching filter and a distinct visual state.
  • "Call back" in Call Log now actually places the call (ACTION_CALL + runtime CALL_PHONE permission) instead of just opening the dialer with the number pre-filled.
  • Home's blocking toggle now has a caption explaining what it does.
  • Auto-responder's experimental warning now explains why it may not work — newer Android/OEM audio-routing restrictions (the same anti-wiretapping hardening that affects call-recorder apps) — instead of a vague "may not work on all devices."
  • Fixed: Home stats going stale after backgrounding (a resume-refresh had regressed), the contacts-permission prompt nagging forever even after granting (it was checking whether a callback existed, not the actual permission), a dead import that broke ktlintCheck, and a dead unused method in PassthroughInCallService.

iOS (2026-07-30):

  • Fixed Koin initialization — initKoin() now called in MainViewController.kt before Compose UI launches (was only initialized on Android via BloqueaLlamadasApp.onCreate)
  • Added CADisableMinimumFrameDurationOnPhone: true to Info.plist via project.yml (required by Compose Multiplatform for high-refresh-rate iPhones)
  • Replaced Dispatchers.IO with Dispatchers.Default throughout shared module (internal API on Kotlin/Native)
  • Replaced Clock.System with platform expect/actual currentTimeMillis() for iOS compatibility
  • Added databaseDispatcher to DriverFactory interface (Android: IO, iOS: Default)
  • Fixed Navigation Compose arguments?.getString() → Map cast for KMP compatibility
  • Removed leftover import kotlinx.coroutines.IO (internal on Native)
  • Known: Metal GPU rendering may hang on certain iOS simulators (e.g., iPhone 16 Pro on macOS 26). Use iPhone SE or physical device. Software rendering fallback pending.

Android (2026-07-30):

  • Added Call back action in call log (ACTION_DIAL intent)
  • Added local timestamps to call log entries via currentTimeMillis() expect/actual
  • Fixed bottom nav double-tap screen reload with section-aware root comparison
  • InCallActivity now shows instantly on call arrival (wins race against system UI)
  • Added KeyguardManager dismiss for full-screen incoming call takeover
  • Hidden spam provider toggle from settings (backend preserved)
  • Auto-responder marked as Experimental
  • Stats screen de-hardcoded (Loading, blocked count)
  • Copy number now wired to clipboard (ClipboardManager)
  • Phone number normalization for cross-format contact matching (normalizeForComparison)

Support this project

Corta Spam is free, MIT-licensed, ad-free, tracker-free and has no network code at all — it earns nothing and never will. Nothing is asked for inside the app: there is no donation prompt, no nag screen and no billing code, and the entire ask lives here in the repository.

If it has been useful, DONATE.md lists the ways to support it — GitHub Sponsors, Ko-fi, PayPal, and Yape/Plin for donors in Peru, where a domestic transfer costs neither side a fee. Starring the repo, rating the app on Play, translating a locale or filing a well-described bug costs nothing and helps at least as much.

License

MIT — see LICENSE. Corta Spam is free and open source.

About

Open-source call blocker for Android. Screens every incoming call by your own rules, before the phone rings. No ads, no tracking, no network code. Kotlin Multiplatform.

Topics

Resources

Stars

6 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages