Call Directory | Getting Started
Block calls, or show your own caller ID for them, from a list of phone numbers your app keeps. The app writes the list once, through one API, and the platform applies it to every incoming call, even when your app is not running:
- iOS - the CallKit Call Directory. iOS reads the list through a Call Directory app extension, which the
package builds for you during
dotnet build: no Xcode project, Swift orInfo.plistediting. - Android - the call screening role (Android 10+). A
CallScreeningServicethat ships in the package checks every incoming call against the list.
| GitHub | |
| Downloads |
Platform support
Section titled “Platform support”| Platform | What you get |
|---|---|
| iOS 15+ | CallDirectoryManager. Blocking and caller identification through a Call Directory extension the package builds into your app |
| Android 10+ (API 29) | CallDirectoryManager. Blocking, an allow list, optional blocking of unlisted callers, and a caller ID notification, through ShinyCallScreeningService while the app holds the call screening role |
| Android 8-9, and everywhere else | NoOpCallDirectoryManager behaviour. IsSupported is false and every call is a safe no-op |
Shared view models need no #if: branch on IsSupported only when the UI should hide the feature.
iOS setup
Section titled “iOS setup”1. Project file
Section titled “1. Project file”Reference Shiny.Mobile.CallDirectory and turn the extension on. The app group is required: the app and the
extension share the list through it.
<PropertyGroup> <ShinyCallDirectoryExtension>true</ShinyCallDirectoryExtension> <ShinyCallDirectoryAppGroup>group.com.mycompany.myapp</ShinyCallDirectoryAppGroup></PropertyGroup>The build then:
- Compiles the extension with
swiftcintoPlugIns/ShinyCallDirectory.appex. - Adds the app group entitlement to both the app and the extension.
- Stamps the app group into the extension’s
Info.plist. At runtime the C# manager reads the bundle id and app group from that bundle, so the app and the extension can never point at different stores. - Signs the extension with its own provisioning profile on device builds.
| Property | Default | Purpose |
|---|---|---|
ShinyCallDirectoryExtension |
false |
Build the extension |
ShinyCallDirectoryAppGroup |
(required) | The app group shared by the app and the extension |
ShinyCallDirectoryBundleId |
$(ApplicationId).CallDirectory |
The extension’s bundle id |
ShinyCallDirectoryDisplayName |
$(ApplicationTitle) |
The name shown in Settings > Apps > Phone > Call Blocking & Identification |
ShinyCallDirectoryCodesignProvision |
best installed match | The extension’s provisioning profile, by name or UUID |
ShinyCallDirectoryCodesignEntitlements |
generated | Your own entitlements file for the extension (it must list the app group) |
ShinyCallDirectoryAddAppEntitlement |
true |
Set false when the app group is already in your app’s Entitlements.plist |
ShinyCallDirectoryMinimumOSVersion |
SupportedOSPlatformVersion, at least 15.0 |
The extension’s deployment target |
The extension is only built on macOS. A build off a Mac (Windows, Hot Restart) gets a warning and an app with no
extension: IsSupported is false and Reload reports ExtensionNotFound.
2. Provisioning (device builds)
Section titled “2. Provisioning (device builds)”On a device, both bundle ids need a provisioning profile with the App Groups capability and your group:
- the app (
com.mycompany.myapp) - the extension (
com.mycompany.myapp.CallDirectory, unless you setShinyCallDirectoryBundleId)
The build fails with a clear error naming the extension’s bundle id when no profile matches.
Android setup
Section titled “Android setup”There is nothing to add to the project: ShinyCallScreeningService is declared in the package and merges into
your manifest. To show caller ID notifications on Android 13+, declare and request POST_NOTIFICATIONS:
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />Register
Section titled “Register”using Shiny;
builder.Services.AddCallDirectory();AddCallDirectory(o => ...) takes CallDirectoryOptions:
| Option | Platform | Default | Purpose |
|---|---|---|---|
AppGroup |
iOS | from the build | Override the app group stamped into the extension |
ExtensionBundleId |
iOS | the bundled extension | Override the extension bundle id |
DefaultCountryCode |
Android | 1 |
Country code for incoming numbers that arrive in national format |
BlockUnlistedCalls |
Android | false |
Block every caller not in the list, including withheld numbers. Allow and Identify entries still ring |
ShowCallerIdNotification |
Android | true |
Post a notification with the entry’s label while an Identify number rings |
Turning it on
Section titled “Turning it on”var access = await directory.GetCurrentAccess(); // Available, Disabled, NotSupported or Unknownif (access != AccessState.Available) access = await directory.RequestAccess();- iOS has no permission prompt. The user turns the extension on in Settings > Apps > Phone > Call
Blocking & Identification;
RequestAccessopens Settings (best effort - iOS 26 does not land on the exact page) and returns the current state, so check again when the app resumes. Until thenReloadreturnsExtensionDisabled. - Android shows the system “default caller ID & spam app” dialog for the call screening role and returns
Availableonce the app holds it, orDenied. Android stops showing the dialog after repeated refusals; the user can still pick the app in Settings > Apps > Default apps > Caller ID & spam app. Only one app holds the role at a time.
On Android the dialog’s result comes back through Shiny’s activity result forwarding, which Shiny.Hosting.Maui
wires up automatically; with Shiny.Hosting.Native, the activity must derive from ShinyAndroidActivity.
Managing the list
Section titled “Managing the list”Numbers are stored as a long holding the country code and digits, with no + or formatting: 18005550100.
PhoneNumbers.ToCallDirectory converts what a person typed:
using Shiny.CallDirectory;
PhoneNumbers.ToCallDirectory("+1 (800) 555-0100"); // 18005550100PhoneNumbers.ToCallDirectory("800-555-0100"); // 18005550100 - default country code 1PhoneNumbers.ToCallDirectory("020 7946 0018", 44); // 442079460018 - national trunk 0 droppedEach entry is a block, an identification, or an allow:
CallDirectoryEntryType |
Effect |
|---|---|
Block |
Calls from the number are blocked |
Identify |
Calls ring with Label as the caller ID |
Allow |
Android: always rings, even with BlockUnlistedCalls. iOS has no allow list, so it is loaded as Identify - a label, with no guarantee the call gets past another blocker or Silence Unknown Callers |
On Android, an app cannot change the caller ID the dialer shows, so Identify posts a notification with the
label while the call rings (ShowCallerIdNotification). An Identify entry with no label simply rings.
public class SpamListService(ICallDirectoryManager directory){ // a full list from your server: one transaction, and the next load replaces everything iOS holds public async Task Sync(IEnumerable<SpamNumber> fromServer) { await directory.ReplaceAll(fromServer.Select(x => new CallDirectoryEntry( x.Number, x.Label, x.IsSpam ? CallDirectoryEntryType.Block : CallDirectoryEntryType.Identify ))); await this.Apply(); }
// small edits: only what changed is sent to iOS on the next load public async Task Edit() { await directory.Set([new(18005550100, "Shiny Support")]); await directory.Remove([18005550101]); await this.Apply(); }
async Task Apply() { var result = await directory.Reload(); if (result != CallDirectoryReloadResult.Success) { // ExtensionDisabled, CurrentlyLoading, MaximumEntriesExceeded, ... - see below } }}| Method | What it does |
|---|---|
Set(entries) |
Adds or replaces entries. Re-sending an unchanged entry costs nothing |
Remove(numbers) |
Removes entries. Numbers that are not stored are ignored |
ReplaceAll(entries) |
Replaces the whole list in one transaction and makes the next load a full one |
Clear() |
Removes every entry |
Get(number) / Count() |
Reads the stored list |
Reload() |
Asks iOS to load the extension, which applies the list |
Edits do not reach iOS until Reload(). Batch your edits and reload once: every reload is a full extension
launch. Android checks the list on every call, so edits apply immediately and Reload() only reports whether
screening is active - call it anyway so the same code works on both.
Reload results
Section titled “Reload results”CallDirectoryReloadResult |
Meaning |
|---|---|
Success |
iOS loaded the list |
NotSupported |
The platform has no call directory |
ExtensionDisabled |
iOS: the user has not turned the extension on in Settings. Android: the app does not hold the call screening role |
ExtensionNotFound |
The app has no extension - the build did not include it |
CurrentlyLoading |
A load is already running; try again shortly |
MaximumEntriesExceeded |
The list is larger than iOS accepts |
LoadingInterrupted |
The extension ran out of memory or time |
Failed |
Anything else. The CallKit error and the extension’s own error are logged |
How it works
Section titled “How it works”Android
Section titled “Android”ShinyCallScreeningService is called for every incoming call while the app holds the call screening role. It
normalizes the caller’s number (DefaultCountryCode for national-format numbers), looks it up in the list and
answers within Android’s 5 second limit: blocked calls are rejected without a notification and still appear in
the call log; everything else rings. Outgoing calls are never touched, and any failure lets the call through - a
broken list never blocks the user’s phone.
- One SQLite file, one writer. The list lives in
shiny_calldirectory.dbin the app group container. The app is the only writer; the extension only ever reads it. Both use the SQLite that ships with iOS (no SQLitePCLRaw bundle), so the package is trim and AOT safe. - Incremental loads. Every
Set/Removeis logged with what the number was before, so a reload only sends iOS what changed since the last load it accepted.ReplaceAllandClearstart a new generation, which forces a full load. The extension records what iOS accepted in the app group’s defaults, and the app trims the change log up to that point on the nextReload. - No lingering locks. iOS kills a suspended app that holds a lock on a file in a shared container
(
0xdead10cc), so every call opens the database, works in one transaction and closes it. - Off the UI thread. The store’s calls run on a background thread; they are safe to await from a view model.
Step 1 — Add the marketplace:
claude plugin marketplace add shinyorg/skillsStep 2 — Install the plugin:
claude plugin install shiny@shinyOne plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.
Step 1 — Add the marketplace:
copilot plugin marketplace add https://github.com/shinyorg/skillsStep 2 — Install the plugin:
copilot plugin install shiny@shinyOne plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.


