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.
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.
CHANGELOG.md— one line per change, newest first; the reasoning is in Recent Fixes belowdocs/SPEC.md— product spec, platform capability matrix, architecture, tech stackdocs/MILESTONES.md— milestone breakdown with acceptance testsdocs/ADAPTIVE_PLAN.md— landscape/tablet layout plandocs/STORE_COMPLIANCE.md— Google Play declaration + privacy policydocs/PLAY_ADVISORIES.md— Play Console quality advisories, triaged once with the evidenceLICENSE— MIT LicenseDONATE.md— ways to support the project; nothing is asked for inside the app
- 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…or0034…), so blocking Morocco never blocks a Manhattan212number - 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_DIALintent 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
611finds+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 77and called from+34611998877, or saved+34611998877and called from900123456. 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)
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
- JDK 17
- Android SDK (
ANDROID_HOMEset), platform 36 + build-tools - Xcode 16+ and xcodegen (
brew install xcodegen) — iOS only - Node.js (for icon regeneration)
./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.shcd 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./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 compileSome 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 phonerule_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.
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."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.
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.
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:CallRingerdecided everything fromAudioManager.ringerMode, and nothing in the codebase readgetCurrentInterruptionFilter()at all. The decision now lives inRingerPolicyas 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()returnsRINGER_MODE_SILENTwhile Do Not Disturb is on even thoughSettings.Global.MODE_RINGERstill reads2. So the gate would correctly decide "this starred contact may ring" andplan()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_callsasked 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 withoutACCESS_NOTIFICATION_POLICY; the doubled sound was real. %dwas 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 shippedclasses.dexof 1.6.1. A bare%dmatches nothing, so it is not an argument at all.call_repeated_caller_hintwas 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_OUTPUTbeingsignature|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, whileMediaRecorder'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/setMutedare 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
611998877is 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 (
:shared642 → 649,:androidApp98 → 113). Both new suites were watched failing first: the%dtest 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 newring_test.sh dndmode, 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 whosepriorityCallSendersisPRIORITY_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,setNavigationBarColorandLAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGESstarting inc.w.b,c.y.banda4.b.t, which read like three places in this app. Resolved against the uploaded bundle'smapping.txt, all three areandroidx.activity.EdgeToEdgeApi26.setUp,EdgeToEdgeApi29.setUpand an outline called fromEdgeToEdgeApi28.adjustLayoutInDisplayCutoutMode— the inside ofenableEdgeToEdge(), which is what the first advisory recommends calling. Nothing inandroidApp/orshared/touches those APIs, in Kotlin or in eitherthemes.xml. Unfixable here without trading a cosmetic advisory for a real one. - An obfuscated trace is worth a minute with
mapping.txtbefore 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 resolvesc.wto 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 whya4.bsaysandroidx.emoji2.text.ConcurrencyHelpers$…while the call site is inandroidx.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(…)inAdaptiveScaffold,asPaddingValues()in the keypad. That advisory fires ontargetSdkalone. 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 aTextFieldValue, 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.ktholdstypeIntoanddeleteBackwardsover 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:
5551typed on the pad, caret moved to the front,9tapped, field reads55519. Against the fixed build the same taps read9|5551, then91|5551, delete takes the1before 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.
AdaptiveScaffoldcallscontent()from three branches of onewhen (windowSizeClass), so a size-class change moves every screen to a different composition slot and discards itsremember/rememberSaveablestate. It needsmovableContentOfand a pass over every screen, which is its own change with its own verification. - 731 → 740 tests (
:shared633 → 642,:androidApp98 unchanged). Six of the nine were watched failing against the old append-and-drop-last behaviour before being trusted../scripts/verify.shgreen, 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 becauseProximityPolicydeliberately 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.BlockedCallPolicynow 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 — andspeak()returningERROR, 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.shdrives 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 readingBLOCKED {"type":"default_block"}. Two traps are handled in the script because they produce confident wrong answers: pollingdumpsys telecomcosts 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 callstill 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) —isShrinkResourceswas 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.activitywent 1.9.3 → 1.13.0. The edge-to-edge advisory needed no code: both activities have calledenableEdgeToEdge()for a while and every screen pads withsafeDrawing(verified again on an API 36 emulator, where the system enforces it). The deprecated window APIs advisory does not clear, and cannot:setStatusBarColor,setNavigationBarColorandLAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGESare called byandroidx.activity's ownEdgeToEdgeApi35.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.shgreen), 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
1key 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=truebefore and after) — the point of the non-focusable popup. On the Agenda: the favourites strip picked up a contact starred throughContactsContract; 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 leftBlockedNumberempty. 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.shgreen. - 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
showRecentCallersOnKeypadis 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_HEIGHTwas 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,InCallActivitybinds, 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 (
+51987654321and987654321are one person and two strings — it dedupes bycomparisonKeysnow), and with two coroutines writing the keypad's state a read-modify-write lost the permission flag, so every write usesupdate(). - 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
DialPada 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.
viewModelScopedispatches onDispatchers.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 inCallLogViewModelTest; the new class did not follow it. Every test in it now installs aStandardTestDispatcher, and that exposed a second, real defect: the cache test waited withstate.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.shcompilescommonTestfor Native but does not run it —:shared:iosSimulatorArm64Testis 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 withArrangement.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_DIALERreplaces 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'sSTARRED, 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
sameNumberrather 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.
routeSectionreturnsNO_SECTIONfor 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.
AndroidContactsGatewaycaches 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 — soContactsGateway.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 aLazyColumneven 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 (
:shared560 → 600,:androidApp92 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.shgreen, including the iOS compile andTranslationCompletenessTestover 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.ktis 223 hardcoded English strings, and the picker wrote the chosen name into theCountryRulerow — 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 intorule_detailon a blocked call. Two changes:Countries.ktnow 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.shseeds 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 withcmd locale set-app-localesrather 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 arule_detailin 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, whiledumpsys telephony.registryreportedmECBMReason=0.rule_matrix_test.shsaid 13 of 14 scenarios failed, allALLOWED/-, 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 callALLOWEDwith{"type":"emergency_callback"}, then the marker wound back 31 minutes and the same call from the same still-claiming platform isBLOCKED/MANUAL. - Two device scripts were reporting the wrong thing about themselves.
device_check.shwaited a fixed three seconds for the schema — enough on a warm install, not on a cold one, where a fresh install reporteduser_version 0and the identical re-run reported 4. It now polls.rule_matrix_test.shprinted "expected BLOCKED/MANUAL, got ALLOWED/-" while the row it had already pulled said{"type":"emergency_callback"}; it selected onlyactionandrule_type, andrule_typeis 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.ktsits 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
MissingFormatArgumentExceptionat 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'sMissingTranslationis 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$spasses 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" foroneand uses%1$donly inother, 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
+34number. The gesture was not missing from the handler: the control was a MaterialTextButton, which exposes noonLongClick, so there was nowhere to write it. It is now aBoxwithcombinedClickable, which has to re-supply what the button gave for free — the 56 dp touch target, the disabled colour of the glyph, and anonLongClickLabel, 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 tap1234567→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
112did not ring anyone. This app holdsROLE_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_PHONEisGRANTED_BY_ROLE, whichdumpsys packagestates outright.startActivity(ACTION_CALL)is routed through Telecom's ownUserCallActivitytrampoline, and Telecom decides whether an emergency number may be dialled by asking which package started the intent — andgetCallingPackage()is null for a plainstartActivity, 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 useTelecomManager.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_CALLsurvives 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
1matched five of them and moved the1key fromy=702toy=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.
AdaptiveScaffolddraws 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.CortaSpamThemeTestenforces dark mode by hunting forMaterialTheme { }— 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 composescontent()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 toAdaptiveScaffoldexplaining the gap namesMaterialTheme { }. 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.dpand 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 atw411dp-h891dp. Shrinking the region until the suite went green would have fitted the product to a window nobody owns. - 632 → 652 tests (
:shared541 → 560,:androidApp91 → 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.shgreen, including the iOS compile. - Verified on the Pixel_8_Pro_API_36 emulator, release build, dialer role held.
112connects 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 ownInCallActivitywith the timer running and Mute/Speaker/Keypad present, which is the check that it did not overreach. The pad's1key reads[246,1057][283,1141]both before and after a digit is typed, and the same blind tap sequence that previously produced+34900123999now types112. Dark mode was set withcmd uimode night yesand 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_LOCKat 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_LOCKis 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_LOCKimplies nouses-feature, confirmed withaapt2 dump badgingon the built APK rather than by reading the manifest, andisWakeLockLevelSupportedmeans 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.
ProximityPolicyjoinsRingerPolicy,NotificationPolicyandCallDirectionPolicy: the activity cannot be constructed in a unit test, and blanking at the wrong moment is not cosmetic. Both directions are asserted, andRINGINGexplicitly — 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.OTHERresolves to leaving the screen on, the same wayCallDirectionPolicyresolves 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
LaunchedEffectkeyed on the policy's answer. Pressing Home released the lock throughonPause— 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 inonResume. - 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 (
:androidApp85 → 91)../scripts/verify.shgreen, 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 powershowsPROXIMITY_SCREEN_OFF_WAKE_LOCK 'CortaSpam:InCallProximity'held by the app's uid on a connected call, released on Home, and — only after theonResumefix — re-acquired on returning. The same A/B run withonResumedeleted 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.
InCallStateonly cleared when Telecom calledonCallRemoved, and Telecom holds a call inSTATE_DISCONNECTEDfor as long as it likes before it does — seconds, on a real network. For that whole window the screen renderedDISCONNECTING, 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_DISCONNECTEDnow detaches the call the moment it arrives, so the screen goes when the call goes;DISCONNECTINGgained 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.detachwas the last line ofonCallRemoved, 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. AndPassthroughInCallService.onDestroynever clearedInCallStateat all: theCallobjects 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 anmm:sstimer 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.
resolveCallerNameaskedContactsContract.PhoneLookup, which matches through the provider's region-deriveddata4column: a contact saved nationally does not match a call in E.164 unless the phone's region agrees, and with no SIMdata4is null and every contact reads as a stranger. This app already knew that — it is written onisKnownContact, and it is why the call log gets names right where the call screen did not. The lookup now falls back toContactsGatewayandcomparisonKeys, 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_DIALERtook 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 throughContactsContract.Intents.Insert— noWRITE_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 (
:shared528 → 541,:androidApp84 → 85)../scripts/verify.shgreen, 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 andInCallActivityreachesonDestroywithdumpsys telecomshowing no calls left. "Create new contact" opens Google Contacts' editor with5559991111already in the Phone field. Still unverified on hardware: the disconnecting-state Close button needs a call that lingers inSTATE_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 namedTheme.Material.Light.NoActionBardirectly, so the window Android paints before the first Compose frame was white too. The colours were not missing —design/mockups.htmlhas carried a full light and dark pair since the UI was designed, and they are transcribed intoCortaSpamThemewith 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:primaryis the "allowed" colour in the call log, and the two hard-codedColor(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.
InCallActivitydrawsCallScreenoutside 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 becausePixelCopynever redraws, and the first attempt — reading aCortaSpamThemeblock placed next toCallScreen— passed against a deliberately revertedCallScreen, because it was asking the theme what the theme says. - Call waiting stranded the surviving call.
InCallStateheld oneCallin one field. A second call overwrote the first; when that second call ended,detachsaw the call it held was the one going away and cleared the state — soInCallActivityfinished 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.Callbackwas 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.setDisplayNameandsetRepeatedCallAttemptsapplied 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.
CallUiPhasehad four values andOTHERwas 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.HOLDINGandDISCONNECTINGare now real phases,HOLDINGgets a Resume button, andOTHERkeeps a hang-up button deliberately — whatever the state turns out to be, ending the call is the escape hatch.DISCONNECTINGis 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_MODEplus the last emergency number this app saw dialled, so it needs no new permission —TelephonyManager.isEmergencyNumberwould needREAD_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.isBlockedis now an exhaustivewhen. The old chain of!iscompiled 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 (
:shared503 → 528,:androidApp77 → 84). The dark-mode source scan and the call-waiting promotion test were each watched failing against the bug first../scripts/verify.shgreen, 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 throughCall.playDtmfTone. It is offered only on anACTIVEcall, 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
DialPadshared 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 castNavBackStackEntry.argumentstoMap<String, *>— a type it has never been — and the compiler said so on every build ("this cast can never succeed"); theas?turned that impossibility into null and the elvis replaced it with"all".CallLogViewModel.applyFilterhad 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 fromPackageManageron Android and the bundle'sInfo.pliston iOS. Not a constant incommonMain: 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 (
:shared494 → 503)../scripts/verify.shgreen, 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.sameNumberwas made right three days ago, and thenAndroidContactsGatewayhanded it numbers it had already run throughnormalizeForComparison, which strips the+. A contact saved+34611998877therefore arrived stating no country, sosameNumbercould no longer split off its national form, and a call delivered as611998877— 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 bycomparisonKeysand was never wrong, which is what made it look inexplicable. The lesson is bigger than the line:sameNumberonly works if every caller hands it numbers as saved, so any normalisation upstream silently disables it. That contract is now written onContactsGateway.contactNumbers, and the cursor-loop mapping moved into a purebuildContactsSnapshotthat unit tests can reach without a ContentResolver. - Proven by A/B on a real call path, not by reasoning.
rule_matrix_test.shgained 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, thegrep -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 timePassthroughInCallServicereads the URI days later in another process.setDataSourcethrew, the blanketcatchreported completion, and the blocked caller was answered and hung up on in silence. NowActionOpenDocumentplustakePersistableUriPermission, 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.
RecordingReadinessnames 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 readyour phone\'s loudspeaker— andblocklist_duplicate_blocked_bodyhad been showingwon\'t override the blockin the duplicate-number dialog for as long as it has existed. Nothing could catch it: it compiles,TranslationCompletenessTestonly counts keys, and Android Lint cannot see that resource tree at all. Found by screenshotting the screen; now a test. verify.shnever compiled a line ofcommonTestfor 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), andviewModelScopework never runs in a Native unit test withoutDispatchers.setMain, soCallLogViewModelTestwaited outrunTest's full one-minute timeout. Both fixed, and the compile task added to the script.ring_test.shcried wolf after the rule matrix. The matrix leaves the device ondefault_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 (
:shared406 → 494,:androidApp66 → 77). The contacts fix, the escaping check and phase F were each watched failing first../scripts/verify.shgreen; on a Pixel API 36 emulatorrule_matrix_test.shreports 14 passed, 0 failed, 0 skipped, andring_test.sh autoverifies 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
commonMainfromlocal.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 reads14 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 900service-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 indocs/store/, 9:16 padded copies for upload indocs/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
CallStylenotification that is not tied to a foreground service or a user-initiated job and carries no full-screen intent — and it refuses it by throwingIllegalArgumentExceptionout ofnotify(), on the main thread, inside aCall.Callback. The "return to call" notification is posted the instant a call goesACTIVEand 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.
CallStylebought nothing there.post()also swallowsRuntimeExceptionnow: 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.
MissedCallNotifierImplposts its own unless the default dialer declares a receiver forACTION_SHOW_MISSED_CALLS_NOTIFICATION— the test is the receiver existing, not what it does. It is declared for both theandroid.telecom.actionandandroid.telephony.actionspellings, and it will usually never fire, because Telecom sends the broadcast withREAD_PHONE_STATEand 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
InCallActivitystill resumed afterwards, the ongoing notification posted with its Hang up action, the process id unchanged either side of the answer, zeroFATAL EXCEPTIONlines — 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.
CallLogEntrynow carries adirectioncolumn and outgoing calls are written to it. 3.sqmrebuilds the table rather than usingALTER TABLE ADD COLUMN, because the new column carries aCHECKconstraint and a table built byADD COLUMNis not guaranteed to compare equal to theCREATE TABLEthatverifyMigrationsdiffs it against. Two traps came with that:DROP TABLEtakes the index with it (idx_call_log_action_timehas 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 explicitDROP INDEXfirst, generation fails withDuplicate index name.- Outgoing rows are
ALLOWEDwith a NULLrule_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_versionreads 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.
ContactsGatewayhad read the address book since the allowlist needed it, but only ever as aSet<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 returnsList<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
+34611998877and the user types the611998877they 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
dataURI matters as much here as on the buttons: PendingIntent equality ignores extras, so without itFLAG_UPDATE_CURRENTwould repoint one caller's notification at the next caller's number. placeCallwas escaping nothing.MainActivitybuilttel:$numberraw whileCallActionReceiverusedUri.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
Anareturns four contacts, including one matched on the second word of the name rather than its start, and searching0000matches 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, showsInCallActivityand 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
+34655111222with 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 indocs/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.microphoneasuses-feature-not-required, noREAD_PHONE_STATE,faketouchthe onlyuses-implied-feature,jar verifiedasCN=Carlos Pinan, R8 mapping present, es/hi/pt all packaged../scripts/verify.shgreen 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.mdsays 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
InCallServicereceives every call, in both directions — that is the price of owning the in-call UI — andonCallAddedgated 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 calledcall.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 reasonRingerPolicyandNotificationPolicywere extracted: no unit test can construct the service.Call.Details.callDirectionis authoritative from API 29; below it, the state atonCallAddedis 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 literaltel:string found it. - 458 → 472 tests (
:androidApp52 → 66). The direction check was watched failing before its test was kept../scripts/verify.shgreen. - 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|MANUALto the call log, posted a "Blocked call" notification and recorded aCallAttempt— the app screening the user's own outgoing call. With the guard in: the same call reachesstate=DIALINGwith 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_DIALERreplaces 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 on0— 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_DIALintent-filters (one with thetel:scheme) have pointed atMainActivitysince the app first claimed the role, with a comment saying they exist for role eligibility. Nothing ever read them:MainActivityhad noonNewIntentand never touchedintent.data. So Corta Spam appeared in the chooser for everytel: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_DIALfills the keypad; it does not call.DIALmeans "show this number, let the user decide" —CALLis the one that dials. Auto-dialling would turn everytel:link on the web into a call placed without confirmation. The number is carried as aDialRequestwith an id, because a plainString?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_PHONEthe button opens the app's own keypad pre-filled rather than firingACTION_CALL— aBroadcastReceivercannot show a permission dialog, and theSecurityExceptionwould look like a button that does nothing. The fallback intent is pinned to this package, orACTION_DIALwould 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
routeSectionwhen— and inserting a tab shifts every index after it while nothing fails to compile. A new test asserts every entry insectionRoutesselects its own tab. - 421 → 458 tests (
:shared396 → 406,:androidApp25 → 52). The applied-id guard, thesameNumbermatching and the attempt count were each watched failing before their tests were kept../scripts/verify.shgreen. - Verified on the Pixel_8_Pro_API_36 emulator:
am start -a android.intent.action.DIAL -d tel:+34600123456resumesMainActivitywith 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:
HomeScreenpassesModifier.verticalScroll(...)intoAdaptiveContent, 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'sNavigationBartakes 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 (
:shared394 → 396)../scripts/verify.shgreen, including the iOS compile, Android Lint and the SQLDelight migration check, which had not run sinceb418b12.
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.
PermissionExplainerScreenwas a plainColumn().fillMaxSize()with noverticalScroll, 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-scrollingColumnclips 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,RequestingIndicatorScreenandDeniedScreen. The rest already scroll (verticalScrollorLazyColumn).CallScreenis deliberately left non-scrolling: it usesArrangement.SpaceBetweenwith aweight(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 ofverticalScroll. AddingverticalScrollalone 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, andArrangement.Centerbecomes 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 appliessafeDrawingPaddingto 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, andAdaptiveScaffoldalready consumes top and horizontal insets whileNavigationBartakes the bottom. The "uses deprecated APIs or parameters" advisory comes from androidx.activity's own implementation:EdgeToEdgeApi23,EdgeToEdgeApi26andEdgeToEdgeApi29each callWindow.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 anEdgeToEdgeApi35that 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,isShrinkResourcesandproguard-android-optimize.txthave 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
InCallActivitythat isexcludeFromRecentsand 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.shreported 4 failures out of 13. All four were the harness.place_callreadORDER BY id DESC LIMIT 1without 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, reportingNO_NEW_ROWwhen 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's611 99 88 77came out as611, so the test called+34611. That is five digits, andPhoneNumberParserrequirescode.length + 4before it will read34as 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+34611998877and passes. 13/13, every branch, end to end. ring_test.sh watchhad never once run on real hardware. It resolved the app's uid by greppingdumpsys packageforuserId=; current Android printsappId=. 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.shfailed on other apps' crashes. Its crash check ranlogcat -dacross the device's entire persistent buffer with nologcat -cbeforehand and no package filter, thenexit 1. On the razr it matched a two-day-old trace and a fatal belonging tocom.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_RINGINGdeclared, no ringtone code anywhere — andCallRingerfixed it, but nothing has covered it since: a ringtone lives insideMediaPlayer,Vibratorand anInCallServiceno unit test can construct, and "untestable" is how it shipped empty in the first place. The decision is now a pureRingerPolicy(the same extractionNotificationPolicygot), 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 unexpectedAudioManagervalue 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
CallRingerand 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 doublestart()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 (
:androidApp13 → 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
PassthroughInCallServiceanswered it withContactNameLookup.displayNameFor(...) != null— that is, withContactsContract.PhoneLookup.PhoneLookupdoes not compare the digits it is handed: it matches through the provider'sNORMALIZED_NUMBER(data4) column, which the provider computes from the device's default region when the contact is saved. A contact stored611 99 88 77only carries+34611998877there 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 SIMdata4is null for every row, so every contact read as a stranger and a real contact's missed call was suppressed. The probe is nowisKnownContact, sitting besidecontactDisplayNameand askingContactsGateway— 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
PhoneLookupcall 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.IncomingCallNotifierstill 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
onCallRemovedhad to read the number off theCallbefore launching its coroutine: that callback is the last moment Telecom guaranteesdetails.handleis 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.ktlooked names up withcontactNames[normalizeForComparison(number)]— one key, bare digits — whileContactsGatewaykeys that map by everycomparisonKeysform 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 onecontactDisplayNameincontacts/, 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, behindhasPermission(). 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 aRefreshContactNamesintent, 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
notifyUnknownCallerssetting 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 pureNotificationPolicyobject rather than inside the Telecom service, so it is provable at the desk instead of by placing a real call — including the trap that withoutREAD_CONTACTSevery 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.forIncomingCallfixes that one's buttons to answer/decline. Each button'sPendingIntentcarries a per-numberdataURI, becausePendingIntentequality ignores extras: without it, posting a second notification would have rewritten the first one's number throughFLAG_UPDATE_CURRENT, so blocking one caller would blocklist a different one. The write runs undergoAsync(), 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
CONTRIBUTORSand 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.
appVersionCodeis 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 badgingon the built APK confirmsversionCode='4',android.hardware.microphoneasuses-feature-not-required(the line that stopsRECORD_AUDIOimplying it as required, which dropped 6 devices from code 2), noREAD_PHONE_STATE, and the onlyuses-implied-featurebeingfaketouch, which every app gets. The bundle isjar verifiedunderCN=Carlos Pinan. The three superseded bundles are now inrejected/, 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.mdnow 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,
setFullScreenIntentstays inIncomingCallNotifier, and no code was deleted to satisfy a policy. Seedocs/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.
onResumepicks 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 thatMainActivityhard-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 isfilter { it.isDigit() }. A contact stored611 99 88 77normalises to611998877; the same person calling arrives from Telecom as+34611998877and normalises to34611998877. Not equal — and sinceRulePrecedenceResolvermerges 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 ownContactsContract.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+34611998877and+51611998877remain different people. Four resolver tests were watched failing against the old comparison before the fix went in. -
Declaring
RECORD_AUDIOwas 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 badgingshoweduses-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, andAutoResponderRecorder.start()already returns false on a missing or busy microphone and catchesIOException,IllegalStateExceptionand the bareRuntimeExceptionthatMediaRecorder.start()throws on some devices — so the app runs fine without one. The manifest now declaresandroid.hardware.microphonewithrequired="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_STATEwas 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 forRoleManager.ROLE_DIALER— which is not what AOSP'sroles.xmlsays. Eligibility comes from the twoACTION_DIALactivities 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.PHONEchecksCALL_PHONE, which is used to place a call back from the log. Left in place deliberately:<applicationId>.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION, contributed byandroidx.corethrough manifest merging. It is app-private and grants nothing, and while this app callsregisterReceiverzero times, the androidx libraries it ships do — removing a permission a library needs surfaces as a runtimeSecurityExceptionon 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_AUDIOis 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.
PermissionWarningslived privately insideSettingsScreen, 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, toALREADY_DEFAULT. Revoke the role and the state stayedGRANTEDforever: 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 leavesREQUESTINGalone, 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.onCreatefired thePOST_NOTIFICATIONSrequest 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_AUDIOpermission, noMediaRecorder, noAudioRecord. 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:AutoResponderRecordercaptures 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 gatesVOICE_CALL/VOICE_DOWNLINK/VOICE_UPLINKbehindCAPTURE_AUDIO_OUTPUT, asignature|privilegedpermission 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(migration2.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, andclearAllnow 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.logCallreturns its inserted row id inside a transaction withlast_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
showGrantContactsused to be, so it appears only once recording is actually switched on. - Play rejected
USE_FULL_SCREEN_INTENTas "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 declaresIN_CALL_SERVICE_UIandIN_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. Seedocs/PLAY_FSI_APPEAL.md. STORE_COMPLIANCE.mddescribed 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 noINTERNETpermission; 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. NewCallRingerplays 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 DESCquery — 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 realenabledandcreated_atin 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_atsurvives the round trip instead of being restamped, an action rule'spatternIdscope 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 throughevaluate. 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, so2125551234parsed as Morocco (+212) and912345678as India (+91).PhoneNumberParsernow reports a country only for numbers actually written in international form (+or the00access code), and canonicalises formatting characters on the way. - Repeat-caller rules had no UI at all. The table, repository, resolver branch,
CallAttempttracking 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 satisfiescontains/startsWith/endsWithfor 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 namepatternMatch_caseInsensitivewas observing this bug — pattern matching has no notion of case. - Four countries could not be added.
CountryRuleisUNIQUE(country_code)withINSERT OR IGNORE, andCOUNTRIESlisted codes1,7,212and590twice. 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.
RuleDecisionbuilt its reason as an English sentence and wrote it intoCallLogEntry.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.databaseDispatcherhad 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.
onCallAddedcancelled 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. ATranslationCompletenessTestnow 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.suppressUnsupportedCompileSdkand theandroidx.activitydowngrade).kotlin-stdlibis 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.valueread 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. androidApphad no release build type, soreleasewas AGP's default: unminified, unshrunk, debug-signed. Added, with ProGuard rules for the reflective corners (serializers whose@SerialNameends up in the database, Telecom entry points named from the manifest), andassembleReleaseruns in CI so R8 is exercised before a release depends on it.android:dataExtractionRulesnow states explicitly whatallowBackup="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, andContactNameLookup.androidApphad 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_atdefaults 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 byid. - 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. AndInCallStatecan'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'.shareddeclaresiosArm64andiosSimulatorArm64but noiosX64, while CI builds with-destination 'generic/platform=iOS Simulator'— a generic simulator destination resolvesARCHStoarm64 x86_64, so the Kotlin framework task was asked for a slice that was never configured.iosApp/project.ymlnow setsEXCLUDED_ARCHS[sdk=iphonesimulator*]: x86_64, making the Xcode project and the Kotlin targets describe the same architectures. Adding aniosX64target 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 fromCMPLayoutRegion.o, and it only exists in the iOS 26 SDK — themacos-15runner ships Xcode 16.4 / iOS 18.5. The job now runs onmacos-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
xcodebuildpasses 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 secondcombine()chained on the first (the typed overloads stop at five), so there's now an assertion that catches a future setting being dropped;AutoResponderViewModelgets a test per validation code,MISSING_CONSENTincluded, since recording a call without a consent line in the greeting is a legal problem rather than a cosmetic one;BackupViewModelemits 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 usesStandardTestDispatcherrather thanUnconfinedTestDispatcher, 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 fromnow - daysBackin 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 boundarycountBlockedCallsTodayalready 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 theKeyValueSettingsStorethey all share. Coverage includes every setting surviving a restart (a second repository over the same database, since these hydrate once ininitand never re-read), everyRuleDecisionvariant round-tripping through theCallLogEntry.rule_typeCHECK constraint, and thestrftime-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, butreadStringtreats a stored blank as "unset", so a restart resurrects the default script —readStringis 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) andFakeCallLogRepository(3) now exist once each inshared/src/commonTest/.../app/testing/, shared bycommonTestandandroidUnitTestalike — 15% of all test source was hand-copied fake bodies, andRuleRepository's 64 members meant every interface change had to be applied to each copy by hand. One side effect worth naming:SettingsRepositoryTest.ktturned out to assert nothing aboutSqlSettingsRepository— all five of its tests ran against the fake declared in the same file. It's nowFakeSettingsRepositoryTest.ktand pins the shared fake's write-through setters, which the ViewModel tests genuinely depend on; the realSqlSettingsRepositorywas 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-lineContactsGateway/DefaultDialerGatewayfakes, where a local declaration reads better than an import. - Breaking (pre-release): the database file was renamed
bloquellamadas.db→cortaspam.dbandrootProject.nameis nowCortaSpam. 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:verifySqlDelightMigrationfailed withduplicate column name: pattern_id, and no CI job depended on it, so nothing noticed. Root cause was three-layered:schemaOutputDirectorywas never configured so schema snapshots went stale at5.dbwhile migrations reached7.sqm; and, once a correct baseline was rebuilt, the check caught a real bug —5.sqmaddedpattern_idviaALTER TABLE ADD COLUMN, which SQLite cannot use to attach theREFERENCES PatternRule(id)foreign key thatAppDatabase.sqdeclares, 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.verifySqlDelightMigrationnow 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 fromrootProject.name, so renaming the project broke everyRes.string.*import across 12 screen files. - Moved
bloquea_llamadas_mockups.htmlout of the repo root todesign/mockups.html, and corrected the rule order it documented. It claimed "Allowlist first, then Manual" in two places; the shippedRulePrecedenceResolverchecks 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/*.shscripts (unmodified template scaffolding matching afeature//domain//core/module layout this repo doesn't have, never referenced fromsettings.json, and weaker than thecorta-spam-verify-buildskill that superseded them), and the tracked.session_state.mdsession 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 sealedIntenttype + oneonIntent()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 separateStateFlows → oneBlockListUiStatewith counts as derived properties),CallLogViewModelandDialerOnboardingViewModel(two disconnected flows each → one UiState).CallLogViewModelalso 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
DialerOnboardingScreentaking 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 newBackupEffect.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 inRulePrecedenceResolver: 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 aCallLogEntry.rule_typeschema migration for the newREPEATED_ALLOWEDtag. - 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 uncaughtActivityNotFoundException. 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.
MainActivityno longer injects repositories/ViewModels directly; the audio-picker result and first-runwelcomeShownflag now flow through the ViewModels that actually own them.- Settings/Auto-responder/Home now each expose a single
UiStateinstead 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 (viaContactsContract.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.ASKis 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+ runtimeCALL_PHONEpermission) 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 inPassthroughInCallService.
iOS (2026-07-30):
- Fixed Koin initialization —
initKoin()now called inMainViewController.ktbefore Compose UI launches (was only initialized on Android viaBloqueaLlamadasApp.onCreate) - Added
CADisableMinimumFrameDurationOnPhone: trueto Info.plist viaproject.yml(required by Compose Multiplatform for high-refresh-rate iPhones) - Replaced
Dispatchers.IOwithDispatchers.Defaultthroughout shared module (internal API on Kotlin/Native) - Replaced
Clock.Systemwith platformexpect/actual currentTimeMillis()for iOS compatibility - Added
databaseDispatchertoDriverFactoryinterface (Android:IO, iOS:Default) - Fixed Navigation Compose
arguments?.getString()→Mapcast 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_DIALintent) - 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)
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.
MIT — see LICENSE. Corta Spam is free and open source.