Skip to content
Shiny.NET

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 or Info.plist editing.
  • Android - the call screening role (Android 10+). A CallScreeningService that ships in the package checks every incoming call against the list.
GitHub GitHub stars for shinyorg/shiny
Downloads NuGet downloads for Shiny.Mobile.CallDirectory
Operating Systems
iOS
Android
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.

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:

  1. Compiles the extension with swiftc into PlugIns/ShinyCallDirectory.appex.
  2. Adds the app group entitlement to both the app and the extension.
  3. 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.
  4. 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.

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 set ShinyCallDirectoryBundleId)

The build fails with a clear error naming the extension’s bundle id when no profile matches.

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" />
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
var access = await directory.GetCurrentAccess(); // Available, Disabled, NotSupported or Unknown
if (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; RequestAccess opens 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 then Reload returns ExtensionDisabled.
  • Android shows the system “default caller ID & spam app” dialog for the call screening role and returns Available once the app holds it, or Denied. 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.

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"); // 18005550100
PhoneNumbers.ToCallDirectory("800-555-0100"); // 18005550100 - default country code 1
PhoneNumbers.ToCallDirectory("020 7946 0018", 44); // 442079460018 - national trunk 0 dropped

Each 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.

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

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.db in 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 / Remove is logged with what the number was before, so a reload only sends iOS what changed since the last load it accepted. ReplaceAll and Clear start 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 next Reload.
  • 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.
claude plugin marketplace add shinyorg/skills
claude plugin install shiny@shiny

One 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.

copilot plugin marketplace add https://github.com/shinyorg/skills
copilot plugin install shiny@shiny

One 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.

View Skills Repository