Block or allow incoming calls with regular expressions.
Native Android CallScreeningService, no background daemons, no contacts permission, no network.
For every incoming call, RegexPhone matches the caller's phone number against your rules and either lets it ring, rejects it, or silences the ringtone. Each rule is a regular expression with an action (BLOCK, SILENCE, or ALLOW) and, for block rules, control over whether the missed-call notification should appear.
- Three actions per rule.
BLOCKrejects the call;SILENCEmutes the ringtone but lets the call still hit the call log and notification shade;ALLOWwhitelists. - Per-block flag. Skip the missed-call notification, per rule. Android itself always records blocked calls in the call log; only carrier and system screeners may suppress that, so RegexPhone does not pretend to offer it.
- Predictable precedence. Allow beats block beats silence; otherwise the call is allowed. The verdict does not depend on rule order; only the notification flag comes from the first matching block rule.
- Live tester. The edit screen previews, as you type, the decision the service would take for a sample number across all enabled rules, naming the rule that decides.
- Import / Export. Save the full rule set to a JSON file via Storage Access Framework, restore it on another device, or merge two sets together. No permissions needed.
- Simple storage. Rules are JSON in
SharedPreferences, kept in device-protected storage so screening already works between a reboot and the first unlock. Device-protected storage is encrypted with a device key rather than the lock-screen credential. - No background services, no contacts permission, no network access.
Left: rules list with role-status banner. Right: edit screen with the per-rule notification toggle.
| Aspect | Behaviour |
|---|---|
| Source | Call.Details.handle.schemeSpecificPart, URI-decoded |
| Number forms | each rule is tried against the string as delivered by the carrier, its separator-stripped form, and its E.164 form (derived from the current network or SIM country); the rule matches if any form matches |
| Hidden / withheld numbers | match as the empty string; block them with ^$ |
| Match function | Matcher.find() (substring); anchor with ^ and $ for whole-number match |
| Invalid regex | never matches; the editor refuses to save it |
For each incoming call:
- If any enabled
ALLOWrule matches, the call is allowed. - Else if any enabled
BLOCKrule matches, the call is rejected. The skip notification flag of the first matching block rule applies. - Else if any enabled
SILENCErule matches, the call rings silently (no audible ringtone), but the call log and notifications are unaffected. - Otherwise the call is allowed.
All numbers below use the NANP fictional ranges (
555exchange in any area code, or area code555) so they cannot belong to any real subscriber.
| Pattern | Action | What it does |
|---|---|---|
^\+12025550123$ |
BLOCK |
Block exactly one specific number. Substitute the real number from your call log. |
^\+44 |
BLOCK |
Block every call from a country (here +44 is the UK). Works for any country code. |
^$ |
BLOCK |
Block withheld / hidden numbers — Android delivers an empty string in that case. |
^\+1555\d{7}$ |
BLOCK |
Block a whole carrier or number range. \d{7} matches the seven digits after the fixed +1555 prefix. |
^\+32.* |
ALLOWBLOCK |
Two rules together: whitelist a country (Belgium), reject everything else. .* matches any sequence including the empty string. |
^\+1 |
SILENCE |
Mute the ringtone for calls from a country. The call still hits the call log and notification shade. |
Grab the latest signed APK from the Releases page, then:
adb install -r regexphone-X.Y.Z.apkOn first launch tap Set as default in the status card and accept the system dialog; the card turns green once the role is granted.
| Tool | Version |
|---|---|
| JDK | 17 or 21 |
| Android SDK | Platform 35 and Build-tools 35.0.x |
| Gradle | 8.10.2 (via wrapper) |
git clone https://github.com/renaudallard/regexphone.git
cd regexphone
# One-time, only if gradle/wrapper/gradle-wrapper.jar is missing:
gradle wrapper --gradle-version 8.10.2
./gradlew testDebugUnitTest
./gradlew assembleDebugThe APK lands at app/build/outputs/apk/debug/regexphone-X.Y.Z.apk (the build renames every variant after the version).
./gradlew assembleRelease produces app/build/outputs/apk/release/regexphone-X.Y.Z.apk. The build script picks up signing credentials from Gradle properties (typically ~/.gradle/gradle.properties):
REGEXPHONE_KEYSTORE_PATH=/absolute/path/to/keystore.jks
REGEXPHONE_KEYSTORE_PASSWORD=...
REGEXPHONE_KEY_ALIAS=regexphone
REGEXPHONE_KEY_PASSWORD=...
If REGEXPHONE_KEYSTORE_PATH is unset or points to a missing file, assembleRelease still works and emits the same regexphone-X.Y.Z.apk, just unsigned. Generate a fresh keystore with:
keytool -genkeypair -keystore ~/.keystores/regexphone-release.jks \
-storetype PKCS12 -alias regexphone -keyalg RSA -keysize 2048 \
-validity 36500 -dname "CN=Your Name, O=RegexPhone"Keystore files (*.jks, *.keystore) are gitignored. Back the keystore up off-device; losing it means you can never sign a follow-up release with the same identity.
Debian arm64 setup (the official google-android-*-installer packages are amd64-only)
sudo apt install openjdk-21-jdk
curl -LO https://dl.google.com/android/repository/commandlinetools-linux-13114758_latest.zip
mkdir -p ~/Android/Sdk/cmdline-tools
unzip commandlinetools-linux-13114758_latest.zip -d ~/Android/Sdk/cmdline-tools
mv ~/Android/Sdk/cmdline-tools/cmdline-tools ~/Android/Sdk/cmdline-tools/latest
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-arm64
export PATH=~/Android/Sdk/cmdline-tools/latest/bin:$PATH
yes | sdkmanager --licenses
sdkmanager 'platforms;android-35' 'build-tools;35.0.1' 'platform-tools'
echo "sdk.dir=$HOME/Android/Sdk" > local.propertiesDebian's gradle is 4.4.1, which is too old to bootstrap AGP 8. Either copy gradle/wrapper/gradle-wrapper.jar from a host that has it, or grab a standalone Gradle 8.10.2:
curl -LO https://services.gradle.org/distributions/gradle-8.10.2-bin.zip
unzip gradle-8.10.2-bin.zip -d ~/Android
~/Android/gradle-8.10.2/bin/gradle wrapper --gradle-version 8.10.2Google Play distributes an Android App Bundle (.aab), not an APK. Build it with:
./gradlew bundleReleaseThe bundle lands at app/build/outputs/bundle/release/app-release.aab, signed with the same REGEXPHONE_* credentials as the APK. The outputFileName rename only applies to APK outputs, so the bundle keeps its default name. Under Play App Signing this keystore acts as the upload key; Google manages the final distribution signing key.
app/src/main/java/it/allard/regexphone/
├── MainActivity.kt
├── data/
│ ├── Rule.kt data class + compiled-Pattern cache
│ ├── RegexGuard.kt watchdog-bounded regex matching
│ ├── RuleIO.kt pure encode / decode / merge helpers
│ └── RuleRepository.kt singleton, SharedPreferences-backed
├── service/
│ └── FilterCallScreeningService.kt pure decide() + the Android binding
└── ui/
├── Theme.kt
├── RulesListScreen.kt list + role-status card + FAB + menu
└── EditRuleScreen.kt form + live tester
Tests live under app/src/test/java/it/allard/regexphone/: DecideTest.kt exercises FilterCallScreeningService.decide(), RuleIOTest.kt covers JSON round-trip, import filtering, salvage and id reassignment, and RegexGuardTest.kt covers the match watchdog. All run without Android stubs.
- Only incoming calls are screened. The platform also hands outgoing calls to the screening service (for caller-ID purposes) but ignores any screening response for them; RegexPhone answers those with a no-op without evaluating rules.
- Blocked calls always appear in the call log with the blocked type. The
CallScreeningServiceAPI reserves call-log suppression for carrier and system apps. - Rules are deliberately excluded from Google cloud backup and device-to-device transfer: the rule set reveals who you block, so it never leaves the device on its own. After migrating to a new phone the app starts empty; carry rules over with Export on the old device and Import on the new one.
- Callers already in your contact list bypass the regex entirely. Android's telecom layer short-circuits
CallScreeningServicewhen the incoming number matches a saved contact: it returns allow without ever invoking the screening service, so no rule of yours can run. To block a number that is in contacts, delete (or temporarily delete) the contact entry first. This is by design at the system level — the officialCallScreeningServicedocumentation states the service is "called when a new incoming or outgoing call is added which is not in the user's contact list." java.util.regex.Patternhas no built-in match timeout, and a regex with nested quantifiers like(a+)+bcan backtrack catastrophically. RegexPhone runs every match on a watchdog thread with a 1 second deadline and bounds a whole screening pass to 3.5 seconds, so the response always stays within Telecom's ~5 second budget. A pattern that misses its full deadline is treated as no match and skipped for the rest of the process lifetime; the live tester keeps a separate blacklist, so experimenting in the editor cannot disable a saved rule for real calls. A runaway match cannot be cancelled mid-flight: it is dropped to minimum priority, and at most six watchdog threads exist at once, with further matches treated as no match while all are stuck.
If you find RegexPhone useful, you can support development:
BSD 2-Clause "Simplified" License. Copyright (c) 2026, Renaud Allard renaud@allard.it. See LICENSE for the full text. Every Kotlin source file carries the same header.
The icon set under branding/ is generated from branding/source/*.svg and theme color #5E5BFF. The monochrome layer is wired up for Android 13+ themed icons.


